devnomads 0.2.0__tar.gz → 0.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- devnomads-0.2.2/.gitlab-ci.yml +82 -0
- {devnomads-0.2.0 → devnomads-0.2.2}/PKG-INFO +2 -2
- {devnomads-0.2.0 → devnomads-0.2.2}/pyproject.toml +2 -2
- {devnomads-0.2.0 → devnomads-0.2.2}/src/devnomads/acme/__init__.py +8 -1
- {devnomads-0.2.0 → devnomads-0.2.2}/src/devnomads/acme/client.py +11 -3
- {devnomads-0.2.0 → devnomads-0.2.2}/src/devnomads/acme/keys.py +21 -0
- devnomads-0.2.2/tests/test_acme_client.py +125 -0
- {devnomads-0.2.0 → devnomads-0.2.2}/tests/test_acme_keys.py +20 -0
- devnomads-0.2.2/tools/bump_version.py +33 -0
- {devnomads-0.2.0 → devnomads-0.2.2}/uv.lock +1 -1
- devnomads-0.2.0/.gitlab-ci.yml +0 -43
- devnomads-0.2.0/tests/test_acme_client.py +0 -64
- {devnomads-0.2.0 → devnomads-0.2.2}/.flake8 +0 -0
- {devnomads-0.2.0 → devnomads-0.2.2}/.gitignore +0 -0
- {devnomads-0.2.0 → devnomads-0.2.2}/Makefile +0 -0
- {devnomads-0.2.0 → devnomads-0.2.2}/README.md +0 -0
- {devnomads-0.2.0 → devnomads-0.2.2}/openapi.json +0 -0
- {devnomads-0.2.0 → devnomads-0.2.2}/src/devnomads/__init__.py +0 -0
- {devnomads-0.2.0 → devnomads-0.2.2}/src/devnomads/acme/challenge_server.py +0 -0
- {devnomads-0.2.0 → devnomads-0.2.2}/src/devnomads/acme/dns01.py +0 -0
- {devnomads-0.2.0 → devnomads-0.2.2}/src/devnomads/acme/errors.py +0 -0
- {devnomads-0.2.0 → devnomads-0.2.2}/src/devnomads/acme/http01.py +0 -0
- {devnomads-0.2.0 → devnomads-0.2.2}/src/devnomads/acme/verify.py +0 -0
- {devnomads-0.2.0 → devnomads-0.2.2}/src/devnomads/api/__init__.py +0 -0
- {devnomads-0.2.0 → devnomads-0.2.2}/src/devnomads/api/_services.py +0 -0
- {devnomads-0.2.0 → devnomads-0.2.2}/src/devnomads/api/client.py +0 -0
- {devnomads-0.2.0 → devnomads-0.2.2}/src/devnomads/api/credentials.py +0 -0
- {devnomads-0.2.0 → devnomads-0.2.2}/src/devnomads/api/errors.py +0 -0
- {devnomads-0.2.0 → devnomads-0.2.2}/src/devnomads/dns/__init__.py +0 -0
- {devnomads-0.2.0 → devnomads-0.2.2}/src/devnomads/dns/errors.py +0 -0
- {devnomads-0.2.0 → devnomads-0.2.2}/src/devnomads/dns/names.py +0 -0
- {devnomads-0.2.0 → devnomads-0.2.2}/src/devnomads/dns/zones.py +0 -0
- {devnomads-0.2.0 → devnomads-0.2.2}/src/devnomads/py.typed +0 -0
- {devnomads-0.2.0 → devnomads-0.2.2}/tests/conftest.py +0 -0
- {devnomads-0.2.0 → devnomads-0.2.2}/tests/test_acme_challenges.py +0 -0
- {devnomads-0.2.0 → devnomads-0.2.2}/tests/test_client.py +0 -0
- {devnomads-0.2.0 → devnomads-0.2.2}/tests/test_credentials.py +0 -0
- {devnomads-0.2.0 → devnomads-0.2.2}/tests/test_dns.py +0 -0
- {devnomads-0.2.0 → devnomads-0.2.2}/tests/test_names.py +0 -0
- {devnomads-0.2.0 → devnomads-0.2.2}/tests/test_services.py +0 -0
- {devnomads-0.2.0 → devnomads-0.2.2}/tools/generate.py +0 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
stages:
|
|
2
|
+
- check
|
|
3
|
+
- test
|
|
4
|
+
- publish
|
|
5
|
+
|
|
6
|
+
default:
|
|
7
|
+
image: ghcr.io/astral-sh/uv:python3.14-alpine
|
|
8
|
+
before_script:
|
|
9
|
+
# dev tools + the acme extra, so linting and tests cover devnomads.acme.
|
|
10
|
+
- uv sync --dev --extra acme
|
|
11
|
+
|
|
12
|
+
check:
|
|
13
|
+
stage: check
|
|
14
|
+
rules:
|
|
15
|
+
- if: $CI_PIPELINE_SOURCE != "trigger"
|
|
16
|
+
script:
|
|
17
|
+
- uv run python tools/generate.py --check
|
|
18
|
+
- uv run black --check .
|
|
19
|
+
- uv run isort --check-only .
|
|
20
|
+
- uv run flake8 src tests
|
|
21
|
+
- uv run bandit -r src
|
|
22
|
+
- uv run semgrep scan --quiet --error --config p/python src
|
|
23
|
+
- uv run mypy src
|
|
24
|
+
|
|
25
|
+
test:
|
|
26
|
+
stage: test
|
|
27
|
+
rules:
|
|
28
|
+
- if: $CI_PIPELINE_SOURCE != "trigger"
|
|
29
|
+
parallel:
|
|
30
|
+
matrix:
|
|
31
|
+
- PYTHON: ["3.10", "3.14"]
|
|
32
|
+
image: ghcr.io/astral-sh/uv:python$PYTHON-alpine
|
|
33
|
+
script:
|
|
34
|
+
- uv run pytest -v
|
|
35
|
+
|
|
36
|
+
# Runs on version tags (v0.1.0). PYPI_TOKEN (masked) and
|
|
37
|
+
# PYPI_PUBLISH_URL are CI variables pointing at pypi.org; the
|
|
38
|
+
# PYPI_TEST_* variants hold the test.pypi.org equivalents - swap them
|
|
39
|
+
# in to rehearse a release without publishing for real.
|
|
40
|
+
publish:
|
|
41
|
+
stage: publish
|
|
42
|
+
rules:
|
|
43
|
+
- if: $CI_COMMIT_TAG =~ /^v/
|
|
44
|
+
before_script: []
|
|
45
|
+
script:
|
|
46
|
+
- uv build
|
|
47
|
+
- uv publish --publish-url "$PYPI_PUBLISH_URL" --token "$PYPI_TOKEN"
|
|
48
|
+
|
|
49
|
+
# Triggered by the DevNomads API deploy pipeline (a pipeline trigger
|
|
50
|
+
# token). Refreshes the OpenAPI spec, regenerates the SDK, and - only if
|
|
51
|
+
# the spec actually changed - auto-bumps the patch version, commits the
|
|
52
|
+
# regenerated code back, and pushes a tag. The publish job above turns
|
|
53
|
+
# that tag into a PyPI release. Runs only on a trigger, so it never
|
|
54
|
+
# recurses on the branch/tag pushes it creates.
|
|
55
|
+
#
|
|
56
|
+
# Requires CI variables:
|
|
57
|
+
# GITLAB_PUSH_TOKEN - project/group access token (Maintainer role,
|
|
58
|
+
# write_repository scope) allowed to push the default branch and tags.
|
|
59
|
+
# PYPI_TOKEN / PYPI_PUBLISH_URL - used by the publish job on the tag.
|
|
60
|
+
auto-release:
|
|
61
|
+
stage: check
|
|
62
|
+
rules:
|
|
63
|
+
- if: $CI_PIPELINE_SOURCE == "trigger"
|
|
64
|
+
resource_group: auto-release
|
|
65
|
+
script:
|
|
66
|
+
- apk add --no-cache git curl
|
|
67
|
+
- curl -sf "https://api.devnomads.nl/docs?api-docs.json" -o openapi.json
|
|
68
|
+
- uv run python tools/generate.py
|
|
69
|
+
- uv run black -q . && uv run isort -q .
|
|
70
|
+
- |
|
|
71
|
+
if git diff --quiet -- openapi.json src/devnomads/api/_services.py; then
|
|
72
|
+
echo "spec unchanged; nothing to release"; exit 0
|
|
73
|
+
fi
|
|
74
|
+
- NEW=$(uv run python tools/bump_version.py)
|
|
75
|
+
- uv lock
|
|
76
|
+
- git config user.name "devnomads-ci"
|
|
77
|
+
- git config user.email "ci@devnomads.nl"
|
|
78
|
+
- git add openapi.json src/devnomads/api/_services.py pyproject.toml uv.lock
|
|
79
|
+
- 'git commit -m "chore: auto-regenerate sdk from api spec (v${NEW})"'
|
|
80
|
+
- git tag -a "v${NEW}" -m "v${NEW}"
|
|
81
|
+
- git remote set-url origin "https://oauth2:${GITLAB_PUSH_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git"
|
|
82
|
+
- git push origin "HEAD:${CI_DEFAULT_BRANCH}" --follow-tags
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devnomads
|
|
3
|
-
Version: 0.2.
|
|
4
|
-
Summary: Python client for the DevNomads API (transport, DNS, ACME)
|
|
3
|
+
Version: 0.2.2
|
|
4
|
+
Summary: Python client library for the DevNomads API (transport, DNS, ACME)
|
|
5
5
|
Project-URL: Homepage, https://devnomads.nl
|
|
6
6
|
Author-email: Loek Geleijn <support@devnomads.nl>
|
|
7
7
|
License: MIT
|
|
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "devnomads"
|
|
7
|
-
version = "0.2.
|
|
8
|
-
description = "Python client for the DevNomads API (transport, DNS, ACME)"
|
|
7
|
+
version = "0.2.2"
|
|
8
|
+
description = "Python client library for the DevNomads API (transport, DNS, ACME)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
11
11
|
license = { text = "MIT" }
|
|
@@ -21,7 +21,13 @@ try:
|
|
|
21
21
|
from .dns01 import DevNomadsDnsProvider, DnsProvider
|
|
22
22
|
from .errors import AcmeError
|
|
23
23
|
from .http01 import Http01Solver, StandaloneSolver, WebrootSolver
|
|
24
|
-
from .keys import
|
|
24
|
+
from .keys import (
|
|
25
|
+
build_csr,
|
|
26
|
+
generate_key,
|
|
27
|
+
jws_alg,
|
|
28
|
+
load_or_create_account_key,
|
|
29
|
+
serialize_key,
|
|
30
|
+
)
|
|
25
31
|
from .verify import verify_txt_record
|
|
26
32
|
except ImportError as exc: # pragma: no cover - exercised via packaging
|
|
27
33
|
if (exc.name or "").split(".")[0] in _EXTRA_MODULES:
|
|
@@ -44,6 +50,7 @@ __all__ = [
|
|
|
44
50
|
"build_csr",
|
|
45
51
|
"generate_key",
|
|
46
52
|
"serialize_key",
|
|
53
|
+
"jws_alg",
|
|
47
54
|
"load_or_create_account_key",
|
|
48
55
|
"verify_txt_record",
|
|
49
56
|
]
|
|
@@ -14,7 +14,13 @@ from acme import messages
|
|
|
14
14
|
from .dns01 import DnsProvider
|
|
15
15
|
from .errors import AcmeError
|
|
16
16
|
from .http01 import Http01Solver
|
|
17
|
-
from .keys import
|
|
17
|
+
from .keys import (
|
|
18
|
+
PrivateKey,
|
|
19
|
+
build_csr,
|
|
20
|
+
jws_alg,
|
|
21
|
+
load_or_create_account_key,
|
|
22
|
+
serialize_key,
|
|
23
|
+
)
|
|
18
24
|
from .verify import verify_txt_record
|
|
19
25
|
|
|
20
26
|
log = logging.getLogger("devnomads.acme")
|
|
@@ -72,11 +78,13 @@ class AcmeClient:
|
|
|
72
78
|
account_key = load_or_create_account_key(
|
|
73
79
|
self.account_key_path, self.account_key_algorithm
|
|
74
80
|
)
|
|
75
|
-
net = acme_client.ClientNetwork(
|
|
81
|
+
net = acme_client.ClientNetwork(
|
|
82
|
+
account_key, alg=jws_alg(account_key), user_agent=self.user_agent
|
|
83
|
+
)
|
|
76
84
|
directory = messages.Directory.from_json(net.get(self.directory_url).json())
|
|
77
85
|
client = acme_client.ClientV2(directory, net)
|
|
78
86
|
|
|
79
|
-
new_account = messages.
|
|
87
|
+
new_account = messages.NewRegistration(
|
|
80
88
|
contact=(f"mailto:{self.contact_email}",) if self.contact_email else (),
|
|
81
89
|
terms_of_service_agreed=True,
|
|
82
90
|
only_return_existing=False,
|
|
@@ -8,6 +8,7 @@ from __future__ import annotations
|
|
|
8
8
|
|
|
9
9
|
import os
|
|
10
10
|
|
|
11
|
+
import josepy
|
|
11
12
|
from cryptography import x509
|
|
12
13
|
from cryptography.hazmat.backends import default_backend
|
|
13
14
|
from cryptography.hazmat.primitives import hashes, serialization
|
|
@@ -88,6 +89,26 @@ def load_or_create_account_key(path: str, algorithm: str):
|
|
|
88
89
|
raise AcmeError("unsupported account key type")
|
|
89
90
|
|
|
90
91
|
|
|
92
|
+
def jws_alg(account_key):
|
|
93
|
+
"""Return the JWS signing algorithm matching an account JWK.
|
|
94
|
+
|
|
95
|
+
``acme.client.ClientNetwork`` defaults to RS256, so pairing it with an
|
|
96
|
+
EC account key makes josepy reject the signature (the key is not an
|
|
97
|
+
instance of the algorithm's key type). The alg must follow the key.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
if isinstance(account_key, jwk.JWKRSA):
|
|
101
|
+
return josepy.RS256
|
|
102
|
+
if isinstance(account_key, jwk.JWKEC):
|
|
103
|
+
curve = account_key.key.curve.name
|
|
104
|
+
if curve == "secp256r1":
|
|
105
|
+
return josepy.ES256
|
|
106
|
+
if curve == "secp384r1":
|
|
107
|
+
return josepy.ES384
|
|
108
|
+
raise AcmeError(f"unsupported account key curve: {curve}")
|
|
109
|
+
raise AcmeError("unsupported account key type")
|
|
110
|
+
|
|
111
|
+
|
|
91
112
|
def build_csr(private_key: PrivateKey, identifiers: list[str]) -> bytes:
|
|
92
113
|
"""Build a PEM CSR: ``identifiers[0]`` is the CN, all are SANs."""
|
|
93
114
|
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Tests for the pure helpers of the ACME client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from types import SimpleNamespace
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from devnomads.acme import AcmeError
|
|
10
|
+
from devnomads.acme.client import (
|
|
11
|
+
_challenge_for_authz,
|
|
12
|
+
_normalize_challenge_types,
|
|
13
|
+
_normalize_identifiers,
|
|
14
|
+
_split_fullchain,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_normalize_identifiers_dedup_preserves_order():
|
|
19
|
+
assert _normalize_identifiers(["a.com", "b.com", "a.com", ""]) == ["a.com", "b.com"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_normalize_challenge_types_string_applies_to_all():
|
|
23
|
+
assert _normalize_challenge_types("dns-01", ["a.com", "b.com"]) == {
|
|
24
|
+
"a.com": "dns-01",
|
|
25
|
+
"b.com": "dns-01",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_normalize_challenge_types_dict_per_identifier():
|
|
30
|
+
mapping = {"a.com": "dns-01", "b.com": "http-01"}
|
|
31
|
+
assert _normalize_challenge_types(mapping, ["a.com", "b.com"]) == mapping
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_normalize_challenge_types_missing_raises():
|
|
35
|
+
with pytest.raises(AcmeError):
|
|
36
|
+
_normalize_challenge_types({"a.com": "dns-01"}, ["a.com", "b.com"])
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _authz(value, wildcard):
|
|
40
|
+
return SimpleNamespace(identifier=SimpleNamespace(value=value), wildcard=wildcard)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_challenge_for_authz_plain():
|
|
44
|
+
body = _authz("example.com", False)
|
|
45
|
+
assert _challenge_for_authz({"example.com": "dns-01"}, body) == "dns-01"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_challenge_for_authz_wildcard_reprefixes():
|
|
49
|
+
body = _authz("example.com", True)
|
|
50
|
+
assert _challenge_for_authz({"*.example.com": "dns-01"}, body) == "dns-01"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_split_fullchain():
|
|
54
|
+
leaf = "-----BEGIN CERTIFICATE-----\nAAA\n-----END CERTIFICATE-----\n"
|
|
55
|
+
rest = "-----BEGIN CERTIFICATE-----\nBBB\n-----END CERTIFICATE-----\n"
|
|
56
|
+
leaf_pem, chain_pem = _split_fullchain(leaf + rest)
|
|
57
|
+
assert leaf_pem == leaf
|
|
58
|
+
assert chain_pem == rest
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_split_fullchain_no_marker():
|
|
62
|
+
leaf_pem, chain_pem = _split_fullchain("garbage")
|
|
63
|
+
assert leaf_pem == "garbage"
|
|
64
|
+
assert chain_pem == ""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_init_client_registers_with_new_registration(monkeypatch, tmp_path):
|
|
68
|
+
"""_init_client must build a real acme.messages.NewRegistration and hand
|
|
69
|
+
it to new_account; this exercises the message construction that a wrong
|
|
70
|
+
class name (e.g. the non-existent NewAccount) would break."""
|
|
71
|
+
|
|
72
|
+
import josepy
|
|
73
|
+
from acme import messages
|
|
74
|
+
from cryptography.hazmat.backends import default_backend
|
|
75
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
76
|
+
from josepy import jwk
|
|
77
|
+
|
|
78
|
+
from devnomads.acme import client as client_mod
|
|
79
|
+
|
|
80
|
+
captured = {}
|
|
81
|
+
account_key = jwk.JWKEC(
|
|
82
|
+
key=ec.generate_private_key(ec.SECP256R1(), backend=default_backend())
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
class FakeResponse:
|
|
86
|
+
def json(self):
|
|
87
|
+
return {}
|
|
88
|
+
|
|
89
|
+
class FakeNetwork:
|
|
90
|
+
def __init__(self, *args, **kwargs):
|
|
91
|
+
captured["alg"] = kwargs.get("alg")
|
|
92
|
+
self.account = None
|
|
93
|
+
|
|
94
|
+
def get(self, url):
|
|
95
|
+
return FakeResponse()
|
|
96
|
+
|
|
97
|
+
class FakeClientV2:
|
|
98
|
+
def __init__(self, directory, net):
|
|
99
|
+
self.net = net
|
|
100
|
+
|
|
101
|
+
def new_account(self, registration):
|
|
102
|
+
captured["registration"] = registration
|
|
103
|
+
return SimpleNamespace(uri="https://acme.example/acct/1")
|
|
104
|
+
|
|
105
|
+
monkeypatch.setattr(
|
|
106
|
+
client_mod, "load_or_create_account_key", lambda *a, **k: account_key
|
|
107
|
+
)
|
|
108
|
+
monkeypatch.setattr(client_mod.acme_client, "ClientNetwork", FakeNetwork)
|
|
109
|
+
monkeypatch.setattr(client_mod.acme_client, "ClientV2", FakeClientV2)
|
|
110
|
+
monkeypatch.setattr(
|
|
111
|
+
client_mod.messages.Directory, "from_json", lambda data: object()
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
ac = client_mod.AcmeClient(
|
|
115
|
+
str(tmp_path / "account.pem"), contact_email="ops@example.com"
|
|
116
|
+
)
|
|
117
|
+
client, _account_key = ac._init_client()
|
|
118
|
+
|
|
119
|
+
# an EC account key must be signed with ES256, not the ClientNetwork
|
|
120
|
+
# default of RS256, or josepy rejects the JWS.
|
|
121
|
+
assert captured["alg"] == josepy.ES256
|
|
122
|
+
registration = captured["registration"]
|
|
123
|
+
assert isinstance(registration, messages.NewRegistration)
|
|
124
|
+
assert registration.contact == ("mailto:ops@example.com",)
|
|
125
|
+
assert client.net.account is not None
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import os
|
|
6
6
|
import stat
|
|
7
7
|
|
|
8
|
+
import josepy
|
|
8
9
|
import pytest
|
|
9
10
|
from cryptography import x509
|
|
10
11
|
from cryptography.hazmat.primitives.asymmetric import ec, rsa
|
|
@@ -14,9 +15,11 @@ from devnomads.acme import (
|
|
|
14
15
|
AcmeError,
|
|
15
16
|
build_csr,
|
|
16
17
|
generate_key,
|
|
18
|
+
jws_alg,
|
|
17
19
|
load_or_create_account_key,
|
|
18
20
|
serialize_key,
|
|
19
21
|
)
|
|
22
|
+
from devnomads.acme.keys import jwk
|
|
20
23
|
|
|
21
24
|
|
|
22
25
|
def test_generate_rsa():
|
|
@@ -68,3 +71,20 @@ def test_account_key_reused(tmp_path):
|
|
|
68
71
|
content = open(path, "rb").read()
|
|
69
72
|
load_or_create_account_key(path, "ec256")
|
|
70
73
|
assert open(path, "rb").read() == content
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_jws_alg_rsa():
|
|
77
|
+
assert jws_alg(jwk.JWKRSA(key=generate_key("rsa2048"))) is josepy.RS256
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_jws_alg_ec256():
|
|
81
|
+
assert jws_alg(jwk.JWKEC(key=generate_key("ec256"))) is josepy.ES256
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_jws_alg_ec384():
|
|
85
|
+
assert jws_alg(jwk.JWKEC(key=generate_key("ec384"))) is josepy.ES384
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_jws_alg_unsupported_type():
|
|
89
|
+
with pytest.raises(AcmeError):
|
|
90
|
+
jws_alg(object())
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Bump the patch version in pyproject.toml and print the new version.
|
|
3
|
+
|
|
4
|
+
Used by the auto-release CI job so each spec-driven regeneration gets a
|
|
5
|
+
fresh, monotonically increasing version (PyPI versions are immutable).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import pathlib
|
|
11
|
+
import re
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
PYPROJECT = pathlib.Path(__file__).resolve().parent.parent / "pyproject.toml"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main() -> int:
|
|
18
|
+
text = PYPROJECT.read_text()
|
|
19
|
+
match = re.search(r'^version = "(\d+)\.(\d+)\.(\d+)"', text, re.MULTILINE)
|
|
20
|
+
if not match:
|
|
21
|
+
print("error: could not find a version in pyproject.toml", file=sys.stderr)
|
|
22
|
+
return 1
|
|
23
|
+
major, minor, patch = (int(g) for g in match.groups())
|
|
24
|
+
new_version = f"{major}.{minor}.{patch + 1}"
|
|
25
|
+
PYPROJECT.write_text(
|
|
26
|
+
text[: match.start()] + f'version = "{new_version}"' + text[match.end() :]
|
|
27
|
+
)
|
|
28
|
+
print(new_version)
|
|
29
|
+
return 0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
if __name__ == "__main__":
|
|
33
|
+
raise SystemExit(main())
|
devnomads-0.2.0/.gitlab-ci.yml
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
stages:
|
|
2
|
-
- check
|
|
3
|
-
- test
|
|
4
|
-
- publish
|
|
5
|
-
|
|
6
|
-
default:
|
|
7
|
-
image: ghcr.io/astral-sh/uv:python3.14-alpine
|
|
8
|
-
before_script:
|
|
9
|
-
# dev tools + the acme extra, so linting and tests cover devnomads.acme.
|
|
10
|
-
- uv sync --dev --extra acme
|
|
11
|
-
|
|
12
|
-
check:
|
|
13
|
-
stage: check
|
|
14
|
-
script:
|
|
15
|
-
- uv run python tools/generate.py --check
|
|
16
|
-
- uv run black --check .
|
|
17
|
-
- uv run isort --check-only .
|
|
18
|
-
- uv run flake8 src tests
|
|
19
|
-
- uv run bandit -r src
|
|
20
|
-
- uv run semgrep scan --quiet --error --config p/python src
|
|
21
|
-
- uv run mypy src
|
|
22
|
-
|
|
23
|
-
test:
|
|
24
|
-
stage: test
|
|
25
|
-
parallel:
|
|
26
|
-
matrix:
|
|
27
|
-
- PYTHON: ["3.10", "3.14"]
|
|
28
|
-
image: ghcr.io/astral-sh/uv:python$PYTHON-alpine
|
|
29
|
-
script:
|
|
30
|
-
- uv run pytest -v
|
|
31
|
-
|
|
32
|
-
# Runs on version tags (v0.1.0). PYPI_TOKEN (masked) and
|
|
33
|
-
# PYPI_PUBLISH_URL are CI variables pointing at pypi.org; the
|
|
34
|
-
# PYPI_TEST_* variants hold the test.pypi.org equivalents - swap them
|
|
35
|
-
# in to rehearse a release without publishing for real.
|
|
36
|
-
publish:
|
|
37
|
-
stage: publish
|
|
38
|
-
rules:
|
|
39
|
-
- if: $CI_COMMIT_TAG =~ /^v/
|
|
40
|
-
before_script: []
|
|
41
|
-
script:
|
|
42
|
-
- uv build
|
|
43
|
-
- uv publish --publish-url "$PYPI_PUBLISH_URL" --token "$PYPI_TOKEN"
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
"""Tests for the pure helpers of the ACME client."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from types import SimpleNamespace
|
|
6
|
-
|
|
7
|
-
import pytest
|
|
8
|
-
|
|
9
|
-
from devnomads.acme import AcmeError
|
|
10
|
-
from devnomads.acme.client import (
|
|
11
|
-
_challenge_for_authz,
|
|
12
|
-
_normalize_challenge_types,
|
|
13
|
-
_normalize_identifiers,
|
|
14
|
-
_split_fullchain,
|
|
15
|
-
)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def test_normalize_identifiers_dedup_preserves_order():
|
|
19
|
-
assert _normalize_identifiers(["a.com", "b.com", "a.com", ""]) == ["a.com", "b.com"]
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def test_normalize_challenge_types_string_applies_to_all():
|
|
23
|
-
assert _normalize_challenge_types("dns-01", ["a.com", "b.com"]) == {
|
|
24
|
-
"a.com": "dns-01",
|
|
25
|
-
"b.com": "dns-01",
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def test_normalize_challenge_types_dict_per_identifier():
|
|
30
|
-
mapping = {"a.com": "dns-01", "b.com": "http-01"}
|
|
31
|
-
assert _normalize_challenge_types(mapping, ["a.com", "b.com"]) == mapping
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def test_normalize_challenge_types_missing_raises():
|
|
35
|
-
with pytest.raises(AcmeError):
|
|
36
|
-
_normalize_challenge_types({"a.com": "dns-01"}, ["a.com", "b.com"])
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def _authz(value, wildcard):
|
|
40
|
-
return SimpleNamespace(identifier=SimpleNamespace(value=value), wildcard=wildcard)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def test_challenge_for_authz_plain():
|
|
44
|
-
body = _authz("example.com", False)
|
|
45
|
-
assert _challenge_for_authz({"example.com": "dns-01"}, body) == "dns-01"
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def test_challenge_for_authz_wildcard_reprefixes():
|
|
49
|
-
body = _authz("example.com", True)
|
|
50
|
-
assert _challenge_for_authz({"*.example.com": "dns-01"}, body) == "dns-01"
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def test_split_fullchain():
|
|
54
|
-
leaf = "-----BEGIN CERTIFICATE-----\nAAA\n-----END CERTIFICATE-----\n"
|
|
55
|
-
rest = "-----BEGIN CERTIFICATE-----\nBBB\n-----END CERTIFICATE-----\n"
|
|
56
|
-
leaf_pem, chain_pem = _split_fullchain(leaf + rest)
|
|
57
|
-
assert leaf_pem == leaf
|
|
58
|
-
assert chain_pem == rest
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
def test_split_fullchain_no_marker():
|
|
62
|
-
leaf_pem, chain_pem = _split_fullchain("garbage")
|
|
63
|
-
assert leaf_pem == "garbage"
|
|
64
|
-
assert chain_pem == ""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|