Skip to main content

Python client 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

  • 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, compatible with dnctl and dnsync.
  • 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/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 (default default).
  • 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 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.

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

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

devnomads-0.2.0.tar.gz (156.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

devnomads-0.2.0-py3-none-any.whl (32.4 kB view details)

Uploaded Python 3

File details

Details for the file devnomads-0.2.0.tar.gz.

File metadata

  • Download URL: devnomads-0.2.0.tar.gz
  • Upload date:
  • Size: 156.1 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

Hashes for devnomads-0.2.0.tar.gz
Algorithm Hash digest
SHA256 841cbc9abfac4485bafdc64b1dea671ee368ea7287bb97da413583277ffca9e5
MD5 a6452c9ba406cda7b590f19dd258ce2d
BLAKE2b-256 1b8f1e03ae13611b72b7d175a4214db8a64c3b36d7662cede3b741f84744b784

See more details on using hashes here.

File details

Details for the file devnomads-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: devnomads-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 32.4 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

Hashes for devnomads-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 981d50ba2a62fa95918a8c1da11eb0dc818ebcd60d47106075b907a5fb9f8ed0
MD5 16cbaf6f3d7cc8bc3440ee8c85dff8a0
BLAKE2b-256 2fbbc10ce133a867323375eaffdce3672076fa5e68b4ddafbd6ded09957022fc

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page