import httpx
from collections import CounterFastCF
acctok = os.getenv('CLOUDFLARE_ACC_TOK')
usrtok = os.environ['CLOUDFLARE_USR_TOK']
acctid = os.getenv('CLOUDFLARE_ACCT_ID')
acceml = os.getenv('CLOUDFLARE_EML_ADD')base = 'https://api.cloudflare.com/client/v4'uhdrs = {'Authorization': f'Bearer {usrtok}'}
r = httpx.get(f'{base}/user/tokens/verify', headers=uhdrs)
r.json()['success']True
if acctok and acctid:
ahdrs = {'Authorization': f'Bearer {acctok}'}
r = httpx.get(f'{base}/accounts/{acctid}/tokens/verify', headers=ahdrs)
print(r.json()['success'])True
glbtok = os.getenv('CLOUDFLARE_API_KEY')
if acceml and glbtok:
ghdrs = {'X-Auth-Email': acceml, 'X-Auth-Key': glbtok, 'Content-Type': 'application/json'}
r = httpx.get(f'{base}/user', headers=ghdrs)
print(r.json()['success'])True
r = httpx.get(f'{base}/zones', headers=uhdrs)
[(z['name'], z['id'][:8]) for z in r.json()['result'][:1]][('aaa.as', '6afde8dc')]
if acctok and acctid:
r = httpx.get(f'{base}/zones', headers=ahdrs, params={'account.id': acctid})
print([(z['name'], z['id'][:8]) for z in r.json()['result'][:1]])[('aaa.as', '6afde8dc')]
CloudflareApi
Cloudflare’s operationIds are machine artifacts (zones-get vs zones-0-get — the -0- is a generator’s dedup counter), so rather than cleaning them up we name every op mechanically from its verb and path: the group is the path’s non-parameter segments, nested (zones.dns_records), and the method is the HTTP verb — except that when a group has both a collection GET and a single-item GET they become list and get. Two fixups keep the whole surface collision-free: sibling paths differing only by an interior parameter gain a _by_<param> suffix, and an action segment that would shadow an op folds into its parent as <segment>_<verb>. So GET /zones is zones.list, GET /zones/{zone_id} is zones.get, and GET /user/tokens/verify is user.tokens.verify.get — a user who knows the URL knows the call.
cf_names
def cf_names(
spec
):Map each op’s canonical (verb, snaked-segments) key to (group_segments, op_name), collision-free across the surface
cf_group_func
def cf_group_func(
names
):A fastspec group_func looking up precomputed names by canonical verb+path
The parsed surface ships with the package as cf_spec.py, fastspec’s compact form (SpecParser.save): 2.6MB instead of the 8MB raw schema, loaded in ~30ms instead of a ~0.3s parse, with the naming machinery above running only at regeneration time. build_spec refreshes it from Cloudflare’s published schema; op names can shift when the spec changes, so review that diff before shipping it.
build_spec
def build_spec(
nm:str='fastcflare/cf_spec.py',
url:str='https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.json'
):Regenerate the compact spec module cf_spec.py from Cloudflare’s OpenAPI schema.
cli = OpenAPIClient(_cf_spec(), headers={'Authorization': f'Bearer {usrtok}', 'Content-Type': 'application/json'})
sorted(cli.groups)['accounts',
'certificates',
'internal',
'ips',
'live',
'memberships',
'organizations',
'radar',
'ready',
'signed_url',
'system',
'tenants',
'user',
'users',
'zones']
The naming is only trustworthy if it can never silently collide — OpGroup exposes ops with setattr, so a duplicate name would shadow its sibling (exactly the bug this design replaces). This walk asserts the built tree is clean: within every group, op names are unique (duplicates allowed only for the same endpoint spelled two ways in the spec) and no op shares a name with a subgroup.
def _surface_ok(g):
nms = [o.name for o in g.ops]
for n,c in Counter(nms).items():
if c>1: assert len({tuple(snake(s) for s in o.op_spec.path.strip('/').split('/')) for o in g.ops if o.name==n})==1, f'dup op {g.name}.{n}'
assert not set(nms)&set(g.subgroups), f'op/subgroup shadow in {g.name}: {set(nms)&set(g.subgroups)}'
for sg in g.subgroups.values(): _surface_ok(sg)
for grp in cli.groups.values(): _surface_ok(grp)
len(cli.groups), len(cli.groups['zones'].subgroups)(15, 60)
CloudflareApi
def CloudflareApi(
token:NoneType=None, # API token, defaulting to `CF_API_TOKEN` when neither `token` nor `api_key` is given
account_id:NoneType=None, # Account that owns the token, for account-owned tokens
email:NoneType=None, # Account email for a global API key, defaulting to `CF_API_EMAIL`
api_key:NoneType=None, # Global API key, used with `email` in place of a token
):Cloudflare API client supporting both user and account tokens
ucf = CloudflareApi(token=usrtok)
(await ucf.verify()).result.status'active'
if acctok and acctid:
acf = CloudflareApi(token=acctok, account_id=acctid)
print((await acf.verify()).result.status)active
Listing and fetching zones are now distinct calls: zones.list filters the collection, zones.get takes a zone_id.
ucf.zones.listList Zones
Parameters: - name (str, optional) - status (str, optional) - account_id (str, optional) - account_name (str, optional) - order (str, optional) - direction (str, optional) - page (float, default: 1) - per_page (float, default: 20) - match (str, default: ‘all’)
(await ucf.zones.list(name='answer.ai')).result[0].name'answer.ai'
zid = (await ucf.zones.list()).result[0].id
(await ucf.zones.dns_records.list(zone_id=zid)).result[0].name'*.aaa.as'
if acctok and acctid: print((await acf.zones.dns_records.list(zone_id=zid)).result[0].name)*.aaa.as
CloudflareApi.create_token
async def create_token(
doms, # Domain names the token may act on
perm_names, # Permission-group names, such as 'Zone Read' or 'DNS Write'. Names that don't match are skipped
name, # Name for the new token
grp:str='account.zone.', # Resource prefix after `com.cloudflare.api.`
):Create a user API token limited to doms. The token’s value is shown only in this response.
pgs = (await ucf.user.tokens.permission_groups.get()).result
[p.name for p in pgs if 'DNS' in p.name or 'Zone' in p.name][:20]['Account DNS Settings Read',
'Account DNS Settings Write',
'Cloudflare Zero Trust Secure DNS Locations Write',
'DNS Firewall Read',
'DNS Firewall Write',
'DNS Read',
'DNS View Read',
'DNS View Write',
'DNS Write',
'Zone Custom Asset Read',
'Zone Custom Asset Write',
'Zone DNS Settings Read',
'Zone DNS Settings Write',
'Zone Read',
'Zone Security Center Insights Read',
'Zone Security Center Insights Write',
'Zone Settings Read',
'Zone Settings Write',
'Zone Transform Rules Read',
'Zone Transform Rules Write']
With two domains, the policy must carry two distinct zone resources — under the old colliding surface the name filter was silently dropped, every domain resolved to the same zone, and the dict collapsed them to one:
tok = await ucf.create_token(['answer.ai', 'aaa.as'], ['Zone Read'], 'fastcflare-test-token')
assert len(tok.result.policies[0].resources)==2
tok.success, tok.result.name(True, 'fastcflare-test-token')
Clean up the token we just created:
res = await ucf.user.tokens.delete(tok.result.id)
assert res.success