Python client library for the DevNomads API (transport, DNS, ACME)
Project description
devnomads
Python client library for the DevNomads API: HTTP transport, a generated resource SDK covering the whole API, DNS zone/record management, and ACME certificate issuance.
It is the shared foundation of the DevNomads command-line tools - dnctl
and dnsync - exposed so you can build against the same primitives
directly instead of reimplementing them.
Contents
- Features
- Installation
- Quick start
- Architecture
- Authentication
- Transport
- Services
- DNS
- Certificates
- Errors
- Logging
- Design
- Development
- License
Features
- Authenticated HTTP transport with retries (
Retry-Afteraware) and Laravel resource-envelope unwrapping. - A generated resource SDK covering the whole API as client
attributes, e.g.
client.servers.list(). - DNS zone discovery (longest-suffix matching), merge-aware TXT updates, and generic record management.
- ACME certificate issuance over DNS-01 and HTTP-01 - including wildcards and per-identifier challenge mixing in a single order.
- Two HTTP-01 strategies: write challenge files to a web root, or answer from a built-in standalone server.
- One credential scheme, compatible with
dnctlanddnsync. - Typed exceptions, standard-library logging, full type hints
(
py.typed). No printing, nosys.exit.
Installation
pip install devnomads # transport + DNS (httpx only)
pip install devnomads[acme] # + the ACME client and its dependencies
Requires Python 3.10 or newer. The core install depends only on httpx.
The acme extra adds acme, josepy, cryptography, and dnspython.
devnomads.acme only imports with that extra present; without it the
import raises an ImportError naming the extra.
Quick start
from devnomads.api import Client
from devnomads.dns import Dns
with Client.from_environment() as client:
dns = Dns(client)
dns.set_txt("_acme-challenge.example.com", "token-value")
dns.unset_txt("_acme-challenge.example.com", "token-value")
Architecture
The package is split into three layers by dependency weight. Import only what you need.
devnomads.api transport + generated resource SDK (httpx)
▲
devnomads.dns zones, records, TXT/ACME challenges (httpx)
▲
devnomads.acme ACME issuance: DNS-01 + HTTP-01 (+ acme extra deps)
devnomads.api carries both the low-level Client transport and a
generated SDK that maps the full API onto client attributes (see
Services).
The DevNomads DNS API is a PowerDNS proxy scoped to the zones a key may access, so zone ids and record names are absolute (trailing dot) and TXT content is stored quoted; the library handles these conventions for you.
Authentication
Build a client from the environment, from explicit credentials, or from a
resolved Credentials object.
from devnomads.api import Client, resolve
client = Client.from_environment() # env + credentials file
client = Client("https://api.devnomads.nl", "dn_xxx") # explicit
creds = resolve(profile="work") # -> Credentials
client = Client.from_credentials(creds)
Client is a context manager and holds one connection pool:
with Client.from_environment() as client:
...
Resolution order
The API key is resolved as:
- an explicit value passed to
Client/resolve; DN_API_KEY(or theDEVNOMADS_API_KEYalias);- the
api_keyof the selected profile in an INI credentials file.
The credentials file is DN_CREDENTIALS_FILE if set, otherwise the first
of /etc/devnomads/credentials and <config dir>/credentials. The config
directory is DN_CONFIG_DIR, else $XDG_CONFIG_HOME/dnctl, else
~/.config/dnctl - i.e. the file dnctl configure writes. The profile is
DN_PROFILE, else default.
Credentials file
[default]
api_key = dn_xxxxxxxxxxxxxxxx
[work]
api_key = dn_yyyyyyyyyyyyyyyy
api_url = https://api.devnomads.nl
Environment variables
DN_API_KEY/DEVNOMADS_API_KEY- API key.DN_API_URL/DEVNOMADS_API_URL- base URL override.DN_PROFILE/DEVNOMADS_PROFILE- profile name (defaultdefault).DN_CREDENTIALS_FILE- explicit credentials file path.DN_CONFIG_DIR- config directory (default~/.config/dnctl).
config_dir() and credentials_path() expose the resolved paths.
Transport
devnomads.api.Client is a thin wrapper over httpx that owns auth,
retries, and error handling. Use it directly for endpoints the higher
layers don't cover.
Client(api_url, api_key, *, user_agent=None, timeout=30.0)
with Client.from_environment() as client:
zones = client.request("GET", "/services/dns/zones")
client.request(
"PATCH",
"/services/dns/zones/example.com.",
json_body={"rrsets": [rrset]},
)
request(method, path, *, params=None, json_body=None) returns parsed
JSON and:
- unwraps the Laravel
{"data": ...}envelope; - retries
429and5xxwith backoff, honouringRetry-After; - returns
Nonefor404and for empty/204bodies, so a missing resource reads as absence; - raises
AuthErroron401/403andApiErroron any other4xx.
Services
Beyond the curated DNS and ACME helpers, the Client exposes the rest
of the API through a generated, resource-attribute SDK. Each top-level
resource is an attribute on the client; nested resources chain as
further attributes; operations are methods.
from devnomads.api import Client
with Client.from_environment() as client:
client.servers.list()
client.servers.show(server_id)
client.databases.clusters.list()
client.databases.create(body={"name": "blog"})
client.emails.mailboxes.list(service_id)
client.emails.mailboxes.create(service_id, body={...})
client.proxies.up(service_id)
The top-level resources are servers, databases, emails, spams,
containers, proxies, sites, buckets, domains, forwards,
searches, apps, and handles. (DNS stays the curated
devnomads.dns interface and is not generated here.)
Conventions
- Path parameters are positional, in URL order, snake-cased from
the path template - e.g.
client.emails.mailboxes.show(service_id, emailaddress). - Query parameters go through
params=as a dict. - Request bodies (POST/PUT/PATCH) go through
body=, sent as JSON. - Method names map the API action:
index->list,store/create->create,show,update,destroy/delete->delete,patch; any other action (state,deploy,logs,up,down,enable, ...) passes through snake-cased.
Returns are the parsed JSON payload (envelope already unwrapped). The
OpenAPI spec ships no component schemas, so there are no typed response
models - methods return Any. The SDK is generated from the committed
openapi.json; see Development.
mailbox = client.emails.mailboxes.create(
service_id, body={"address": "hi@example.com"}
)
aliases = client.emails.aliases.list(service_id, params={"page": 2})
DNS
Dns wraps a Client with zone-scoped operations. Zone discovery lists
the accessible zones and selects the longest matching suffix, so a deep
host resolves to its flat parent zone automatically.
from devnomads.api import Client
from devnomads.dns import Dns
with Client.from_environment() as client:
dns = Dns(client)
TXT records and ACME challenges
result = dns.set_txt("_acme-challenge.example.com", "token")
# result.zone, result.values, result.changed (a TxtResult)
dns.unset_txt("_acme-challenge.example.com", "token")
dns.unset_txt("_acme-challenge.example.com") # remove the whole rrset
set_txt merges with any values already present - so a wildcard and its
base domain can validate the same name concurrently - and makes no request
when the value is already there. unset_txt removes one value (or the
whole rrset when no value is given) and deletes the rrset once its last
value is gone. Both return TxtResult(zone, values, changed) and accept
an optional ttl (default 60).
Generic records
dns.replace_records(
"host.example.com", "A", ["192.0.2.1", "192.0.2.2"], ttl=300
)
dns.delete_records("host.example.com", "A")
replace_records sends content verbatim (use it for A/AAAA/CNAME/MX/...);
set_txt is the TXT-specific path that handles quoting and merging.
Zones and name helpers
names = dns.list_zone_names()
zone = dns.find_zone("_acme-challenge.deep.host.example.com")
data = dns.get_zone(zone)
dns.patch_rrset(zone, rrset) # low-level rrset change
from devnomads.dns import challenge_name, fqdn, zone_id, current_txt_values
challenge_name("*.example.com") # "_acme-challenge.example.com"
fqdn("www", "example.com") # "www.example.com."
zone_id("example.com") # "example.com."
current_txt_values(data, "_acme-challenge.example.com") # ["token"]
Also exported: absolute, quote_txt, unquote_txt, DEFAULT_TXT_TTL,
and the ZoneNotFound error.
Certificates
devnomads.acme (needs the acme extra) issues certificates from an ACME
CA - Let's Encrypt by default - over DNS-01, HTTP-01, or a mix of both in
one order. It manages the account key, builds the CSR, verifies DNS
propagation against the authoritative nameservers, and can select a
preferred chain.
AcmeClient(
account_key_path,
*,
directory_url=DEFAULT_DIRECTORY_URL, # Let's Encrypt production
account_key_algorithm="ec256", # rsa2048/rsa4096/ec256/ec384
contact_email=None,
preferred_chain=None, # match alternate chain by CN
recursive_nameservers=None, # resolvers for propagation
user_agent="devnomads",
)
The account key at account_key_path is loaded if present and created
(mode 0600) otherwise.
obtain_certificate returns
(cert_pem, fullchain_pem, chain_pem, domain_key_pem) - the first three
as str, the key as bytes.
DNS-01
from pathlib import Path
from devnomads.api import Client
from devnomads.dns import Dns
from devnomads.acme import AcmeClient, DevNomadsDnsProvider, generate_key
acme = AcmeClient(
"/etc/devnomads/account.key",
contact_email="ops@example.com",
preferred_chain="ISRG Root X1",
)
domain_key = generate_key("ec256")
with Client.from_environment() as client:
provider = DevNomadsDnsProvider(Dns(client))
cert, fullchain, chain, key_pem = acme.obtain_certificate(
"example.com",
"dns-01",
domain_key,
sans=["www.example.com", "*.example.com"],
dns_provider=provider,
)
out = Path("/etc/pki/tls/private")
(out / "fullchain.pem").write_text(fullchain)
(out / "privkey.pem").write_bytes(key_pem)
dns_propagation_delay (default 2s) sets how long to wait after writing
records before polling; recursive_nameservers (IPv6 first for graceful
failover) picks the resolvers used for that check.
HTTP-01
Answer HTTP-01 challenges from the built-in server, or by writing files under an existing web root:
from devnomads.acme import StandaloneSolver, WebrootSolver
# Standalone: bind :80 and serve challenges directly.
with StandaloneSolver(port=80) as solver:
acme.obtain_certificate(
"example.com", "http-01", domain_key, http01_solver=solver
)
# Webroot: write files for an existing server to serve.
acme.obtain_certificate(
"example.com",
"http-01",
domain_key,
http01_solver=WebrootSolver("/var/www/html"),
)
StandaloneSolver is a context manager that starts/stops the bundled
ChallengeServer; a bind failure is logged (not raised) so a co-located
instance already holding the port is tolerated.
Mixed challenges
Pass a {identifier: challenge_type} mapping to use different challenge
types per name in one order - e.g. HTTP-01 for the base domain and DNS-01
for its wildcard:
acme.obtain_certificate(
"example.com",
{"example.com": "http-01", "*.example.com": "dns-01"},
domain_key,
sans=["*.example.com"],
dns_provider=provider,
http01_solver=solver,
)
Staging
acme = AcmeClient(
"/etc/devnomads/account.key",
directory_url=(
"https://acme-staging-v02.api.letsencrypt.org/directory"
),
)
Key helpers
from devnomads.acme import (
generate_key,
serialize_key,
build_csr,
load_or_create_account_key,
)
key = generate_key("rsa4096") # rsa2048/rsa4096/ec256/ec384
pem = serialize_key(key) # unencrypted PEM bytes
csr = build_csr(key, ["example.com", "www.example.com"])
jwk = load_or_create_account_key("/etc/devnomads/account.key", "ec256")
Custom backends
DevNomadsDnsProvider is the bundled DNS-01 backend. Implement
DnsProvider (or Http01Solver) to drive your own:
from devnomads.acme import DnsProvider
class MyProvider(DnsProvider):
def create_challenge(self, fqdn, validation):
...
def delete_challenge(self, fqdn, validation=None):
...
verify_txt_record(fqdn, expected_value, *, timeout=120, interval=5, nameservers=None) is exposed if you want to run the propagation check
yourself.
Errors
The library never prints or exits; operations return values and failures
raise a DevNomadsError subclass, so the caller decides how to present
them.
DevNomadsError
├── ConfigError # credentials / config could not be resolved
├── ApiError # API returned an error (.status, .detail)
│ └── AuthError # 401 / 403
├── DnsError # a DNS operation failed
│ └── ZoneNotFound # no accessible zone matches the name
└── AcmeError # an ACME issuance step failed
from devnomads.api import DevNomadsError, AuthError
try:
dns.set_txt(name, value)
except AuthError:
... # missing, invalid, or unauthorized key
except DevNomadsError as exc:
... # everything else this library raises
ApiError carries .status and .detail. Remember that request
returns None (rather than raising) for a 404.
Logging
Progress and warnings go through the standard logging module under the
devnomads logger (devnomads.api, devnomads.acme). It is silent
unless you configure logging:
import logging
logging.getLogger("devnomads").setLevel(logging.INFO)
Design
- Library, not application. It raises and returns; it never prints, exits, or formats for humans. The calling CLI, hook, or daemon owns presentation.
- Layered by dependency weight. DNS-only consumers never pull the
ACME stack; the heavy dependencies live behind the
acmeextra. - One credential scheme shared with the DevNomads CLIs, so a host
configured for
dnctlworks unchanged.
Development
uv sync --dev --extra acme
uv run pytest
uv run black . && uv run flake8 src tests
The Services SDK in src/devnomads/api/_services.py is generated from
the committed openapi.json snapshot. Regenerate it after refreshing
the spec; CI fails if the checked-in file is stale.
make spec # refresh openapi.json from the live API
make generate # rewrite _services.py from openapi.json
make check # lint + type + test + generate --check
License
MIT
Project details
Release history Release notifications | RSS feed
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 devnomads-0.2.2.tar.gz.
File metadata
- Download URL: devnomads-0.2.2.tar.gz
- Upload date:
- Size: 158.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.21 {"installer":{"name":"uv","version":"0.11.21","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Alpine Linux","version":"3.23.4","id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1c3611bdcc3d4fb3c2958a8ec92bb7c22d0702dfab1b4e638da2835f06e256c2
|
|
| MD5 |
2511598e624ddcb7b38962c1528e0bbd
|
|
| BLAKE2b-256 |
b49d628f4b7df76440830f9a06e01c9872d0969c1f43809d1c446b767a420e24
|
File details
Details for the file devnomads-0.2.2-py3-none-any.whl.
File metadata
- Download URL: devnomads-0.2.2-py3-none-any.whl
- Upload date:
- Size: 32.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.21 {"installer":{"name":"uv","version":"0.11.21","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Alpine Linux","version":"3.23.4","id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
680be13d644de81c3fa0d8d1b48675dbc5f03adaf0d795c8f3f11ba73ccdc865
|
|
| MD5 |
414a73d7ff064c0f2c8978b3983991d1
|
|
| BLAKE2b-256 |
19765c86c941ca616de697dad659645b79f1bae63e4351f705366c78667de876
|