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 tool dncli,
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, shared with the
dnclicommand-line tool. - 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/dncli, else
~/.config/dncli - i.e. the file dncli 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/dncli).
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.
send(method, path, *, params=None, json_body=None, timeout=None) does
the same but returns a Response with status, headers and data,
and treats every non-2xx status as an error, a 404 included.
timeout overrides the client's request timeout for that call.
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, handles, keys, jobs, clients, invoices,
products, and services (operations on any service, such as
client.services.jobs.list(service_id)). (DNS stays the curated
devnomads.dns interface and is not generated here.)
Many create, update and delete calls run in the background: they
answer 202 with the list of queued jobs, all in one batch. Follow it
with client.jobs.batches.show(batch_id) until every job's status
is completed, failed or cancelled. Use client.send(...) instead
of request when you also need the status code or the Location
header that points at the batch.
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/ec521
contact_email=None,
preferred_chain=None, # match alternate chain by CN
recursive_nameservers=None, # resolvers for propagation
user_agent="devnomads",
eab_kid=None, # external account binding key id
eab_hmac_key=None, # external account binding HMAC key
eab_hmac_alg="HS256", # HS256/HS384/HS512
)
Let's Encrypt remains the default CA. Any ACME v2 CA works by passing its
directory_url; CAs that mandate External Account Binding (such as ZeroSSL)
also need eab_kid and eab_hmac_key.
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"
),
)
Providers (Let's Encrypt, ZeroSSL)
for_provider resolves a named CA from CA_DIRECTORIES
(letsencrypt, letsencrypt-staging, zerossl) so you do not have to
remember directory URLs. Let's Encrypt stays the default everywhere else.
from devnomads.acme import AcmeClient
# Let's Encrypt (default CA) - no EAB needed.
acme = AcmeClient.for_provider("letsencrypt", "/etc/devnomads/account.key")
# ZeroSSL - requires External Account Binding credentials from your
# ZeroSSL account (Developer -> EAB Credentials, or the REST API).
acme = AcmeClient.for_provider(
"zerossl",
"/etc/devnomads/zerossl-account.key",
contact_email="ops@example.com",
eab_kid="...", # EAB key id
eab_hmac_key="...", # base64url EAB HMAC key
)
Issuance is otherwise identical - the same obtain_certificate calls,
DNS-01/HTTP-01 solvers, and chain selection apply to every provider. The
directory URL and EAB fields can also be passed straight to the
AcmeClient(...) constructor if you prefer not to use the alias.
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/ec521
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 CLI, so a host
configured for
dncliworks 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
Metadata
Release files for devnomads 0.6.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| devnomads-0.6.1.tar.gz | 187.8 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| devnomads-0.6.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 226.2 kB
Release files / devnomads-0.6.1.tar.gz
| Download URL | devnomads-0.6.1.tar.gz |
|---|---|
| Size | 187.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
f1c37341995bd2a48fea27212b6085962a7275925dfb32412a0334e225ef16c6
|
|
BLAKE2b-256 checksum How to use checksums |
b51c468b08d1f48d565ba6984cfc41f725b4fa98dc25f202ef381181d8844fac
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.6 {"installer":{"name":"uv","version":"0.12.6","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Alpine Linux","version":"3.23.5","id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / devnomads-0.6.1-py3-none-any.whl
| Download URL | devnomads-0.6.1-py3-none-any.whl |
|---|---|
| Size | 38.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
ee5286c0ed1d8fd7bcada9200389fd67612730d82650571430ff093790dd6b2a
|
|
BLAKE2b-256 checksum How to use checksums |
99119a0c703c0d568d08c626f0521d12ea2afb0029b99ca049148863e5789195
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.6 {"installer":{"name":"uv","version":"0.12.6","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Alpine Linux","version":"3.23.5","id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Security for devnomads 0.6.1
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.