Skip to main content

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

  • Authenticated HTTP transport with retries (Retry-After aware) 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 dncli command-line tool.
  • Typed exceptions, standard-library logging, full type hints (py.typed). No printing, no sys.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:

  1. an explicit value passed to Client/resolve;
  2. DN_API_KEY (or the DEVNOMADS_API_KEY alias);
  3. the api_key of 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 (default default).
  • 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 429 and 5xx with backoff, honouring Retry-After;
  • returns None for 404 and for empty/204 bodies, so a missing resource reads as absence;
  • raises AuthError on 401/403 and ApiError on any other 4xx.

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. challenge_cname(fqdn, nameservers=None, *, resolver=None) returns the target of a CNAME at _acme-challenge.<fqdn> (delegated DNS-01), or None. A CNAME synthesized from a wildcard such as *.shop.example.com returns None too: the planted TXT record makes the name exist, so the wildcard no longer applies to it.

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 acme extra.
  • One credential scheme shared with the DevNomads CLI, so a host configured for dncli works 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.2

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for devnomads 0.6.2
File Size Uploaded Provenance
devnomads-0.6.2.tar.gz 189.7 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for devnomads 0.6.2
File Interpreter ABI Platform
devnomads-0.6.2-py3-none-any.whl Python 3 none any Details

Total release size: 229.2 kB

Release files / devnomads-0.6.2.tar.gz

Download URL devnomads-0.6.2.tar.gz
Size 189.7 kB
Tags Source
SHA-256 checksum
How to use checksums
34f556d4573546ec5cc3381b9c4cc65eca3050defe6d9ac57a7e3fb2ebff01a6
BLAKE2b-256 checksum
How to use checksums
c7411c3520fc21c5a3ea972d5c37c3abedefbe7b77ed83996db3808b36c1b6d4
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.13.1 {"installer":{"name":"uv","version":"0.13.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Alpine Linux","version":"3.23.6","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.2-py3-none-any.whl

Download URL devnomads-0.6.2-py3-none-any.whl
Size 39.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
9fb435941e785772e7d27872997b9e3425dde79550ed3e152c8a5b648196ff60
BLAKE2b-256 checksum
How to use checksums
8da117f028d5558524db26dcf619afec6737dced2512a2b620e6558d8ff731e6
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.13.1 {"installer":{"name":"uv","version":"0.13.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Alpine Linux","version":"3.23.6","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.2

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.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page