Metadata-Version: 2.4
Name: devnomads
Version: 0.1.1
Summary: Python client for the DevNomads API (transport, DNS, ACME)
Project-URL: Homepage, https://devnomads.nl
Author-email: Loek Geleijn <support@devnomads.nl>
License: MIT
Keywords: acme,api,devnomads,dns,powerdns
Requires-Python: >=3.10
Requires-Dist: httpx>=0.27
Provides-Extra: acme
Requires-Dist: acme>=2.11; extra == 'acme'
Requires-Dist: cryptography>=42; extra == 'acme'
Requires-Dist: dnspython>=2.6; extra == 'acme'
Requires-Dist: josepy>=1.14; extra == 'acme'
Description-Content-Type: text/markdown

# devnomads

Python client library for the [DevNomads](https://devnomads.nl) API:
HTTP transport, 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.

## Layers

The package is split by dependency weight, lightest first. `httpx` is the
only hard dependency; import what you need.

- **`devnomads.api`** - HTTP transport: authentication, retries with
  `Retry-After`, Laravel envelope unwrapping, and a typed error hierarchy.
- **`devnomads.dns`** - DNS helpers on top of the transport: zone
  discovery, rrset merging, A/AAAA records, and TXT/ACME-challenge
  handling.
- **`devnomads.acme`** - ACME client for certificate issuance over DNS-01
  and HTTP-01. Requires the `acme` extra (`acme`, `josepy`,
  `cryptography`, `dnspython`).

## Installation

```bash
pip install devnomads          # api + dns (httpx only)
pip install devnomads[acme]    # + the ACME client and its dependencies
```

`devnomads.acme` only imports cleanly with the `acme` extra installed;
without it, importing the module raises an `ImportError` that names the
extra. Requires Python 3.10 or newer.

## Authentication

Build a client from the environment, or pass credentials explicitly:

```python
from devnomads.api import Client

# Resolved from the environment and credentials file.
client = Client.from_environment()

# Or explicit:
client = Client("https://api.devnomads.nl", "dn_xxx")
```

`Client` is a context manager and reuses one underlying connection pool:

```python
with Client.from_environment() as client:
    ...
```

The API key is resolved in this order:

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 taken from `DN_CREDENTIALS_FILE` if set, otherwise
the first of `/etc/devnomads/credentials` and `~/.config/dnctl/credentials`
(the file `dnctl configure` writes). The profile is `DN_PROFILE`, else
`default`. This matches the resolution used by `dnctl` and `dnsync`, so a
host already configured for those works unchanged.

## DNS

`Dns` wraps a `Client` with zone-scoped operations. Zone discovery is
done by listing accessible zones and picking the longest matching suffix,
so a deep host resolves to its flat parent zone automatically.

```python
from devnomads.api import Client
from devnomads.dns import Dns

with Client.from_environment() as client:
    dns = Dns(client)

    # Merge-aware, idempotent TXT updates (used for ACME challenges).
    dns.set_txt("_acme-challenge.example.com", "token-value")
    dns.unset_txt("_acme-challenge.example.com", "token-value")

    # Generic records (content sent verbatim).
    dns.replace_records("host.example.com", "A", ["192.0.2.1"], ttl=300)
    dns.delete_records("host.example.com", "A")

    zone = dns.find_zone("_acme-challenge.deep.host.example.com")
```

`set_txt`/`unset_txt` return a `TxtResult(zone, values, changed)`;
`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` deletes the rrset once its
last value is gone.

The `devnomads.dns` module also exports name helpers - `challenge_name`,
`fqdn`, `zone_id`, `quote_txt`, `unquote_txt` - and the `ZoneNotFound`
error.

## ACME

`AcmeClient` issues certificates from an ACME CA (Let's Encrypt by
default) over DNS-01, HTTP-01, or a mix of both within one order. It
manages the account key, builds the CSR, verifies DNS propagation against
the authoritative nameservers, and selects the preferred chain.

```python
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",
)
domain_key = generate_key("ec256")

# DNS-01, including wildcards:
with Client.from_environment() as client:
    provider = DevNomadsDnsProvider(Dns(client))
    leaf, fullchain, chain, key_pem = acme.obtain_certificate(
        "example.com",
        "dns-01",
        domain_key,
        sans=["*.example.com"],
        dns_provider=provider,
    )
```

`obtain_certificate` returns
`(cert_pem, fullchain_pem, chain_pem, domain_key_pem)` as strings (the key
as bytes).

### HTTP-01

Answer HTTP-01 challenges either from the bundled in-memory server or by
writing files under an existing web root:

```python
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"),
)
```

### Mixed challenges

Pass a `{identifier: challenge_type}` mapping to use different challenge
types per name in a single order - e.g. HTTP-01 for the base domain and
DNS-01 for its wildcard:

```python
acme.obtain_certificate(
    "example.com",
    {"example.com": "http-01", "*.example.com": "dns-01"},
    domain_key,
    sans=["*.example.com"],
    dns_provider=provider,
    http01_solver=solver,
)
```

To target staging, pass
`directory_url="https://acme-staging-v02.api.letsencrypt.org/directory"`.
Implement `devnomads.acme.DnsProvider` to drive a DNS backend other than
DevNomads.

## Errors

The library never prints or exits; operations return values and failures
raise `devnomads.api.DevNomadsError` subclasses, so the caller decides how
to present them.

```python
from devnomads.api import DevNomadsError, AuthError

try:
    dns.set_txt(name, value)
except AuthError:
    ...  # 401/403: missing, invalid, or unauthorized key
except DevNomadsError as exc:
    ...  # ApiError, ConfigError, DnsError, AcmeError, ...
```

`ApiError` carries `status` and `detail`. Note that `Client.request`
returns `None` (rather than raising) for a 404, so a missing resource
reads as absence.

## 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:

```python
import logging

logging.getLogger("devnomads").setLevel(logging.INFO)
```

## Development

```bash
uv sync --dev --extra acme
uv run pytest
uv run black . && uv run flake8 src tests
```

## License

MIT
