aiopyinfrahub
Fully async Infrahub API client for Python, built on
httpx2 (Pydantic's maintained
continuation of httpx), with httpx2 as its only runtime dependency (the
official infrahub-sdk carries nine).
Inspired by the pynetbox/pynautobot client lineage and by the official infrahub-sdk, and a sister project to aiopynetbox and aiopynautobot. This is not a port of any of them: the sync clients' ergonomics (lazy attribute fetches, eagerly materialized result lists, sync pagination) depend on Python protocols that cannot be awaited, and the official SDK makes different tradeoffs (an identity map populated by default on every read, wrapper objects around every attribute, eagerly materialized result lists). All I/O is explicit and awaitable, and nothing does network I/O behind your back.
Requirements
- Python 3.11+
- Infrahub 1.3+
Installation
uv add aiopyinfrahub # or: pip install aiopyinfrahub
Quick start
import asyncio
import aiopyinfrahub
async def main():
async with aiopyinfrahub.api("https://infrahub.example.com", token="...") as ih:
# attribute access does no I/O; the first awaited operation fetches
# and caches the branch schema
device = await ih.InfraDevice.get(name__value="atl1-edge1")
print(device.name) # "atl1-edge1", wrappers are flattened
print(device.site.display_label) # brief related Record
# lazy async iteration, pages fetched concurrently
async for d in ih.InfraDevice.filter(site__name__value="atl1"):
print(d.name)
# diff-based save: only changed fields go into InfraDeviceUpdate
device.name = "atl1-edge2"
await device.save()
asyncio.run(main())
Try it against the public sandbox
OpsMill's sandbox allows anonymous reads, so the whole read surface works with no token and no instance of your own:
async with aiopyinfrahub.api("https://sandbox.infrahub.app") as ih:
async for device in ih.InfraDevice.all(limit=10):
print(device.name)
Writes need credentials, so point them at your own instance.
Coming from infrahub-sdk
Kind-based access, diff-based save(), and branch/time-travel semantics all
carry over. What changes is that implicit I/O becomes explicit, results become
lazy, and the wrappers get out of your way:
| infrahub-sdk | aiopyinfrahub |
|---|---|
await client.all("InfraDevice") (returns a list) |
async for d in ih.InfraDevice.all() |
await client.get("InfraDevice", name__value="x") |
await ih.InfraDevice.get(name__value="x") |
await client.filters("InfraDevice", **filters) |
ih.InfraDevice.filter(**filters) |
len(await client.all("InfraDevice")) |
await ih.InfraDevice.count() |
node.name.value / node.site.peer |
device.name / device.site |
await client.execute_graphql(query, variables) |
await ih.graphql.query(query, variables) |
await client.branch.all() |
await ih.branches.list() |
Config(default_branch=...), branch= per call |
api(url, branch=...), branch= per call |
await client.object_store.get(identifier) |
await ih.storage.get(identifier) (bytes) |
await client.allocate_next_ip_address(...) |
await ih.pools.next_ip_address(pool) |
await client.task.wait_for_completion(id) |
await ih.tasks.wait(task_id) |
client.schema.load() + convergence wait |
await ih.load_schemas() + wait_schemas_converged() |
await client.login() before requests |
automatic on the first request needing it |
client.store.get(id) (identity map) |
not included: every read is an explicit request |
batch = client.create_batch() |
asyncio.gather + Semaphore (recipe below) |
| nine runtime dependencies | one (httpx2) |
The kind name is an attribute, so ih.InfraDevice is the whole traversal.
ih.kind("InfraDevice") is the escape hatch when the kind is held in a string.
Nested records come back brief (just id, hfid, display_label,
__typename, as Infrahub sends them). Touching a field that isn't loaded
raises AttributeError telling you to await record.full_details(). It never
fires a hidden request.
Concurrency without a batch API
There is no create_batch(). asyncio.gather with a Semaphore is the whole
abstraction, it is one import you already have, and the concurrency limit stays
visible at the call site instead of inside a client object:
sem = asyncio.Semaphore(10)
async def retire(device):
async with sem:
device.status = "retired"
await device.save()
devices = [d async for d in ih.InfraDevice.filter(role__value="edge")]
await asyncio.gather(*(retire(d) for d in devices))
Reads are already concurrent inside one result set (pages are fanned out
through max_concurrency), so reach for this when you are writing, or when you
are querying several kinds at once.
Features
- Explicit async everywhere:
httpx2.AsyncClientunder the hood, used as an async context manager so the connection pool closes deterministically. Nothing is lazily fetched behind an attribute access or a property. - Two ways in:
token=sendsX-INFRAHUB-KEYexactly as before, andusername=/password=runs a JWT session instead: one lazy login on the first request that needs it (concurrent first requests share it rather than racing for a session each), a refresh-and-replay on a 401, and a fresh login if the refresh has expired too.await ih.login()/logout()are there for callers who want the timing in their own hands. - Concurrent pagination: Infrahub's
countcomes back with page 1, so the remaining offsets are fanned out through a sliding window bounded bymax_concurrency(default 4) and yielded in order. Breaking out of an iteration early stops the fetching instead of buffering the rest. - Diff-based writes:
save()sends<Kind>Updatecarrying only the fields you actually changed, attributes re-wrapped as{"value": ...}and relationships as{"id": ...}, so concurrent edits to other fields are not clobbered. - Metadata when you ask for it:
properties=Trueon a read also fetches each attribute'sis_protected,is_default,updated_at,source, andowner(relationships carry the same minusis_default), andrecord.meta("name")hands them back. Reads stay flattened either way (device.nameis still the value), and metadata is never diffed or saved. - Explicit relationship reads and writes: relationships the schema doesn't
mark
AttributeorParentstay out of the default selection so list queries do not fan out, soawait device.fetch("interfaces")is how you pull one in: one call, one query, nothing prefetched behind your back.await device.add_related(...)andremove_related(...)send RelationshipAdd/Remove and take peers as ids, Records, or wire-shape dicts. - Tasks you can wait on:
ih.taskslists, counts, and gets Infrahub's server-side tasks, andawait ih.tasks.wait(task_id)polls one to a terminal state or raisesTaskTimeoutError. Branch operations called withwait=Falsereturn the queued task's id to feed straight into it. - Resource pools:
await ih.pools.next_ip_address(pool)andnext_ip_prefix(pool)allocate; passingidentifier=makes a repeat allocation hand back the same resource instead of consuming another.utilization()andallocated()report on a pool as plain data. - Diffs:
ih.diff.tree(branch)andsummary(branch)over the GraphQL diff queries,files(branch)andartifacts(branch)over the REST routes. All four return plain dicts, because a diff is a report and Records are for nodes. - Storage, artifacts, and transforms:
ih.storagereads and writes the object store and downloadsCoreFileObjectcontent as bytes;ih.artifactsfetches generated artifacts and queues regeneration;ih.transformsrenders a server-side Python transform (decoded) or Jinja2 template (as text), with extra keyword arguments becoming the transform's query variables. - Schema management:
load_schemas()andcheck_schemas()sit besideschema(), and because workers adopt a new schema asynchronously,schema_in_sync()andwait_schemas_converged()are what you wait on before reading against it. A load drops that branch's cached schema. - Search and graph traversal:
ih.search("atl1")lazily yields brief Records across every kind, andih.graphwalks relationships withpaths(),path_exists(), andreachable_nodes()(server 1.10+). - Branches and time travel:
api(url, branch="feature-x")sets the default, and every read,graphql.query(), andschema()takes a per-callbranch=.at="2026-08-01T00:00:00Z"reads the graph as it was.ih.branchescreates, merges, rebases, validates, and deletes. - Schema-driven kind access:
ih.InfraDeviceresolves againstGET /api/schema, cached per branch behind a lock so concurrent first calls make one fetch.default_filterpowersget("atl1-edge1"), andhfidfiltering lights up for kinds that definehuman_friendly_id. - Raw GraphQL is first-class, not a fallback:
await ih.graphql.query("query { InfraDevice { count } }")returns aGraphQLRecordwith.dataand.errors, so partial results stay reachable.await ih.graphql.stored(name_or_id, variables={...})runs a query stored on the server on the same terms. - Retries with backoff: 429 is retried for every request (honoring
Retry-After); transient 502/503/504 and connection failures are retried for idempotent requests only. Because every node read is a POST to/graphql, the policy keys on idempotency rather than HTTP method. Exponential backoff with jitter; tune withretries=, disable withretries=0. - Custom models:
aiopyinfrahub.register_model("InfraDevice", MyDevice)maps a kind to your ownRecordsubclass. - Typed: full type hints and a
py.typedmarker. Kind hints generated from the public sandbox's schema ship in the box, soih.InfraDeviceautocompletes without any setup; runuv run scripts/generate_kinds.py --url https://your-infrahub --token ...to regenerate them from your own instance's schema. The hints never restrict what you can reach: any kind your instance defines keeps working, hinted or not.
API tour
# auth: token=... as below, or username=/password= for a JWT session that
# logs in on the first request that needs it and refreshes itself on a 401
async with aiopyinfrahub.api(url, token=token) as ih:
# read
device = await ih.InfraDevice.get(name__value="atl1-edge1")
device = await ih.InfraDevice.get("atl1-edge1") # via default_filter
device = await ih.InfraDevice.get(hfid=["atl1-edge1"])
total = await ih.InfraDevice.count(role__value="edge")
async for d in ih.InfraDevice.filter(site__name__value="atl1"):
...
async for d in ih.InfraDevice.all():
...
# write
new = await ih.InfraDevice.create(name="atl1-edge3", site=site_id)
up = await ih.InfraDevice.upsert(name="atl1-edge3", site=site_id)
device.name = "atl1-edge2"
await device.save() # InfraDeviceUpdate with only the changed fields
await device.delete() # InfraDeviceDelete
# kinds held in strings
async for t in ih.kind("BuiltinTag").all():
...
# branches
branches = await ih.branches.list()
await ih.branches.create("feature-x")
await ih.branches.merge("feature-x")
# per-call branch and time overrides
async for d in ih.InfraDevice.all(branch="feature-x"):
...
async for d in ih.InfraDevice.all(at="2026-08-01T00:00:00Z"):
...
# raw graphql
result = await ih.graphql.query("query { InfraDevice { count } }")
print(result.data, result.errors)
# instance info
schema = await ih.schema() # cached per branch
print(await ih.version()) # from GET /api/info
# metadata: opt in per read, reachable through meta(), never saved
device = await ih.InfraDevice.get("atl1-edge1", properties=True)
print(device.name, device.meta("name").is_protected)
# relationships outside the default selection are fetched explicitly
interfaces = await device.fetch("interfaces")
await device.add_related("tags", ["4b8f...", "9d21..."])
# resource pools; identifier= makes the allocation repeatable
address = await ih.pools.next_ip_address(pool_id, identifier="atl1-edge1-mgmt")
print(address.display_label)
# tasks: wait=False hands back the queued task's id to poll
task_id = await ih.branches.merge("feature-x", wait=False)
task = await ih.tasks.wait(task_id, timeout=120)
print(task.state, task.conclusion)
# object store round trip
stored = await ih.storage.upload("interface Ethernet1\n")
content = await ih.storage.get(stored["identifier"]) # bytes
# what a branch changed, and a search across every kind
print(await ih.diff.summary("feature-x"))
async for hit in ih.search("atl1", limit=10):
print(hit.id, hit.kind) # brief: full_details() loads the rest
Long-lived apps (FastAPI, services)
Create the client once and share it; the async context manager is one-shot, so enter it for the app's lifetime, not per request:
@asynccontextmanager
async def lifespan(app: FastAPI):
async with aiopyinfrahub.api(url, token=token) as ih:
app.state.ih = ih
yield # handlers use `await app.state.ih...`; pool closes on shutdown
One shared instance is safe under concurrent requests, and the branch schema is fetched once rather than per request. See examples/fastapi_app.py for a runnable app.
Custom httpx2 client
Pass your own httpx2.AsyncClient for custom SSL, proxies, event hooks, or
MockTransport in tests:
client = httpx2.AsyncClient(verify="/path/to/ca.pem", timeout=60)
async with aiopyinfrahub.api(url, token=token, client=client) as ih:
...
Per httpx2 convention, a client you pass in is yours to close: aclose() and
the context manager only close clients the Api created itself, so one client
can safely back several Api instances.
Response caching is deliberately not built in: Infrahub is a source of truth, and the library can't know your staleness tolerance. If you want HTTP caching, pass a client with a caching transport and set the policy yourself. Note that node reads are POSTs, which most HTTP caches will not cache.
Development
Managed with uv:
uv sync # install environment
uv run pytest # tests (in-memory fake Infrahub, no network)
uv run ruff check # lint
uv run ruff format # format
uv run pyright # type check
Live tests against a real instance are opt-in, off by default:
# reads only, anonymous against the public sandbox
AIOPYINFRAHUB_DEMO_URL=https://sandbox.infrahub.app uv run pytest tests/test_demo_integration.py
# writes too, against a disposable instance of your own
AIOPYINFRAHUB_DEMO_URL=http://localhost:8000 \
AIOPYINFRAHUB_DEMO_TOKEN=... AIOPYINFRAHUB_DEMO_WRITES=1 uv run pytest tests/test_demo_integration.py
uv run scripts/generate_kinds.py regenerates the kind hints
(src/aiopyinfrahub/kinds_generated.py and hints_generated.pyi) from a live
/api/schema; a weekly workflow does it against the sandbox and opens a PR.
See CONTRIBUTING.md.
License
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file aiopyinfrahub-0.2.0.tar.gz.
File metadata
- Download URL: aiopyinfrahub-0.2.0.tar.gz
- Upload date:
- Size: 115.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
59cff225dc809619f93729b644b2ee1259b0455dcef4169912e28039d068963a
|
|
| MD5 |
8eff4a5aeafe2b4549ee52f1d4996cd2
|
|
| BLAKE2b-256 |
6dd744e6e89ef858b419ab5ec7ffec7b7654859c7d9149818181ae115feaf7cb
|
Provenance
The following attestation bundles were made for aiopyinfrahub-0.2.0.tar.gz:
Publisher:
release.yml on challey74/aiopyinfrahub
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
aiopyinfrahub-0.2.0.tar.gz -
Subject digest:
59cff225dc809619f93729b644b2ee1259b0455dcef4169912e28039d068963a - Sigstore transparency entry: 2501601593
- Sigstore integration time:
-
Permalink:
challey74/aiopyinfrahub@df587f05c84b95615b089f8f507b789ab69de4cf -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/challey74
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@df587f05c84b95615b089f8f507b789ab69de4cf -
Trigger Event:
release
-
Statement type:
File details
Details for the file aiopyinfrahub-0.2.0-py3-none-any.whl.
File metadata
- Download URL: aiopyinfrahub-0.2.0-py3-none-any.whl
- Upload date:
- Size: 81.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5321c83bd7d176823816b8f8e42167b79ed6154175cc1f2491e356d0c1b03cee
|
|
| MD5 |
93c59b5c45c5ed7985534189decef264
|
|
| BLAKE2b-256 |
a4bde4d56272f6876aa60f38cd97263f85a5462d41da3cc811866637e6ef3961
|
Provenance
The following attestation bundles were made for aiopyinfrahub-0.2.0-py3-none-any.whl:
Publisher:
release.yml on challey74/aiopyinfrahub
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
aiopyinfrahub-0.2.0-py3-none-any.whl -
Subject digest:
5321c83bd7d176823816b8f8e42167b79ed6154175cc1f2491e356d0c1b03cee - Sigstore transparency entry: 2501601606
- Sigstore integration time:
-
Permalink:
challey74/aiopyinfrahub@df587f05c84b95615b089f8f507b789ab69de4cf -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/challey74
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@df587f05c84b95615b089f8f507b789ab69de4cf -
Trigger Event:
release
-
Statement type: