certmate-sdk 0.1.0__py3-none-any.whl

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.
certmate/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """certmate-sdk — a thin Python client for the CertMate REST API.
2
+
3
+ from certmate import Client
4
+ with Client("https://certmate.example.com", token="...") as c:
5
+ job = c.create_certificate("app.example.com", dns_provider="cloudflare", wait=True)
6
+ """
7
+ from .client import Client
8
+ from .errors import (APIError, AuthError, CertMateError, ConflictError,
9
+ JobFailed, JobTimeout, NotFoundError)
10
+ from .models import Certificate, Job
11
+
12
+ __version__ = "0.1.0"
13
+ __all__ = [
14
+ "Client", "Certificate", "Job",
15
+ "CertMateError", "APIError", "AuthError", "NotFoundError", "ConflictError",
16
+ "JobFailed", "JobTimeout",
17
+ ]
certmate/client.py ADDED
@@ -0,0 +1,198 @@
1
+ """Synchronous HTTP client for the CertMate REST API.
2
+
3
+ Endpoints mirror the ones the MCP server drives (the working reference client),
4
+ so the SDK talks to the same `/api/...` surface the web UI and agents use.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import time
10
+ from typing import Any, Callable, Dict, List, Optional
11
+
12
+ import httpx
13
+
14
+ from .errors import (APIError, AuthError, ConflictError, JobFailed, JobTimeout,
15
+ NotFoundError)
16
+ from .models import Certificate, Job
17
+
18
+ _DEFAULT_URL = "http://localhost:8000"
19
+
20
+
21
+ class Client:
22
+ """A thin CertMate API client.
23
+
24
+ >>> with Client("https://certmate.example.com", token="...") as c:
25
+ ... for cert in c.list_certificates():
26
+ ... print(cert.domain, cert.days_until_expiry)
27
+
28
+ ``base_url``/``token`` fall back to ``CERTMATE_URL`` / ``CERTMATE_TOKEN``.
29
+ """
30
+
31
+ def __init__(self, base_url: Optional[str] = None, token: Optional[str] = None,
32
+ *, timeout: float = 30.0, verify: bool = True):
33
+ base_url = (base_url or os.getenv("CERTMATE_URL") or _DEFAULT_URL).rstrip("/")
34
+ token = token if token is not None else os.getenv("CERTMATE_TOKEN")
35
+ headers = {"Accept": "application/json"}
36
+ if token:
37
+ headers["Authorization"] = f"Bearer {token}"
38
+ self._http = httpx.Client(base_url=base_url, headers=headers,
39
+ timeout=timeout, verify=verify)
40
+ self.base_url = base_url
41
+
42
+ # -- lifecycle -----------------------------------------------------------
43
+ def __enter__(self) -> "Client":
44
+ return self
45
+
46
+ def __exit__(self, *exc) -> None:
47
+ self.close()
48
+
49
+ def close(self) -> None:
50
+ self._http.close()
51
+
52
+ # -- low level -----------------------------------------------------------
53
+ def _request(self, method: str, path: str, **kw) -> Any:
54
+ resp = self._http.request(method, path, **kw)
55
+ if resp.status_code // 100 == 2:
56
+ if not resp.content:
57
+ return None
58
+ ctype = resp.headers.get("content-type", "")
59
+ return resp.json() if "application/json" in ctype else resp.content
60
+ # Error path: pull CertMate's {error, code} shape when present.
61
+ payload: Any = None
62
+ code = None
63
+ message = f"HTTP {resp.status_code}"
64
+ try:
65
+ payload = resp.json()
66
+ if isinstance(payload, dict):
67
+ message = payload.get("error") or payload.get("message") or message
68
+ code = payload.get("code")
69
+ except Exception:
70
+ payload = resp.text
71
+ exc_cls = {401: AuthError, 403: AuthError, 404: NotFoundError,
72
+ 409: ConflictError}.get(resp.status_code, APIError)
73
+ raise exc_cls(message, status=resp.status_code, code=code, payload=payload)
74
+
75
+ # -- certificates --------------------------------------------------------
76
+ def list_certificates(self) -> List[Certificate]:
77
+ data = self._request("GET", "/api/certificates")
78
+ items = data.get("certificates", data) if isinstance(data, dict) else data
79
+ return [Certificate.from_dict(d) for d in (items or [])]
80
+
81
+ def get_certificate(self, domain: str) -> Certificate:
82
+ data = self._request("GET", f"/api/certificates/{domain}")
83
+ return Certificate.from_dict(data if isinstance(data, dict) else {"domain": domain})
84
+
85
+ def create_certificate(self, domain: str, *, dns_provider: Optional[str] = None,
86
+ ca_provider: Optional[str] = None,
87
+ san_domains: Optional[List[str]] = None,
88
+ account_id: Optional[str] = None,
89
+ wait: bool = False,
90
+ on_progress: Optional[Callable[[Job], None]] = None,
91
+ **extra: Any) -> Job:
92
+ """Submit an async issuance. Returns the accepted Job immediately;
93
+ with ``wait=True`` polls until the job reaches a terminal state (and
94
+ raises :class:`JobFailed` on failure)."""
95
+ body: Dict[str, Any] = {"domain": domain, "async": True}
96
+ if dns_provider:
97
+ body["dns_provider"] = dns_provider
98
+ if ca_provider:
99
+ body["ca_provider"] = ca_provider
100
+ if san_domains:
101
+ body["san_domains"] = san_domains
102
+ if account_id:
103
+ body["account_id"] = account_id
104
+ body.update(extra)
105
+ data = self._request("POST", "/api/certificates/create", json=body)
106
+ job = Job.from_dict(data if isinstance(data, dict) else {})
107
+ if not job.job_id:
108
+ # Server ran it synchronously (async not honoured): treat as done.
109
+ job.status = job.status or "succeeded"
110
+ return job
111
+ return self.wait_for_job(job.job_id, on_progress=on_progress) if wait else job
112
+
113
+ def renew_certificate(self, domain: str, *, force: bool = False) -> Dict[str, Any]:
114
+ # Always send an (empty) JSON body so endpoints that read request.json
115
+ # don't 415 on a bodyless POST.
116
+ params = {"force": "true"} if force else None
117
+ return self._request("POST", f"/api/certificates/{domain}/renew",
118
+ params=params, json={}) or {}
119
+
120
+ def reissue_certificate(self, domain: str, **body: Any) -> Dict[str, Any]:
121
+ return self._request("POST", f"/api/certificates/{domain}/reissue", json=body) or {}
122
+
123
+ def delete_certificate(self, domain: str) -> None:
124
+ self._request("DELETE", f"/api/certificates/{domain}")
125
+
126
+ def download_certificate(self, domain: str, fmt: str = "json") -> Any:
127
+ """Download the bundle. ``fmt`` is one of json/pem/zip/pfx (json returns
128
+ a dict, the binary formats return bytes)."""
129
+ return self._request("GET", f"/api/certificates/{domain}/download",
130
+ params={"format": fmt})
131
+
132
+ def set_auto_renew(self, domain: str, enabled: bool) -> Dict[str, Any]:
133
+ return self._request("PUT", f"/api/certificates/{domain}/auto-renew",
134
+ json={"enabled": bool(enabled)}) or {}
135
+
136
+ def deploy_certificate(self, domain: str) -> Dict[str, Any]:
137
+ return self._request("POST", f"/api/certificates/{domain}/deploy", json={}) or {}
138
+
139
+ # -- async jobs ----------------------------------------------------------
140
+ def get_job(self, job_id: str) -> Job:
141
+ return Job.from_dict(self._request("GET", f"/api/certificates/jobs/{job_id}") or {})
142
+
143
+ def wait_for_job(self, job_id: str, *, interval: float = 2.0, timeout: float = 600.0,
144
+ on_progress: Optional[Callable[[Job], None]] = None) -> Job:
145
+ deadline = None # computed lazily to avoid importing time at module import
146
+ start = time.monotonic()
147
+ while True:
148
+ job = self.get_job(job_id)
149
+ if on_progress:
150
+ on_progress(job)
151
+ if job.is_terminal:
152
+ if job.failed:
153
+ raise JobFailed(job.error or f"job {job_id} failed", job=job)
154
+ return job
155
+ if time.monotonic() - start > timeout:
156
+ raise JobTimeout(f"job {job_id} did not finish within {timeout:.0f}s")
157
+ time.sleep(interval)
158
+
159
+ # -- dns -----------------------------------------------------------------
160
+ def list_dns_providers(self) -> Any:
161
+ return self._request("GET", "/api/settings/dns-providers")
162
+
163
+ def list_dns_accounts(self, provider: Optional[str] = None) -> Any:
164
+ path = f"/api/dns/{provider}/accounts" if provider else "/api/dns/accounts"
165
+ return self._request("GET", path)
166
+
167
+ def test_dns_provider(self, provider: str, **config: Any) -> Dict[str, Any]:
168
+ """Preflight a DNS provider without issuing. Backs the CLI ``--dry-run``."""
169
+ body = {"provider": provider}
170
+ body.update(config)
171
+ return self._request("POST", "/api/web/certificates/test-provider", json=body) or {}
172
+
173
+ # -- audit / misc --------------------------------------------------------
174
+ def audit_verify(self) -> Dict[str, Any]:
175
+ """Verify the audit chain. The endpoint returns 200 when intact and
176
+ 409 (WITH the full result body) when the chain is broken/absent — both
177
+ are valid verification results, so return the dict in either case and
178
+ only raise on a genuine transport/auth error. Inspect ``result['ok']``.
179
+ """
180
+ try:
181
+ return self._request("GET", "/api/audit/verify") or {}
182
+ except ConflictError as e:
183
+ if isinstance(e.payload, dict):
184
+ return e.payload
185
+ raise
186
+
187
+ def activity(self, limit: int = 100) -> Any:
188
+ return self._request("GET", "/api/activity", params={"limit": limit})
189
+
190
+ def health(self) -> Dict[str, Any]:
191
+ return self._request("GET", "/health") or {}
192
+
193
+ # -- backups -------------------------------------------------------------
194
+ def list_backups(self) -> Any:
195
+ return self._request("GET", "/api/web/backups")
196
+
197
+ def create_backup(self, *, reason: str = "manual") -> Dict[str, Any]:
198
+ return self._request("POST", "/api/web/backups/create", json={"reason": reason}) or {}
certmate/errors.py ADDED
@@ -0,0 +1,47 @@
1
+ """Exception hierarchy for the CertMate SDK."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Optional
5
+
6
+
7
+ class CertMateError(Exception):
8
+ """Base class for every SDK error."""
9
+
10
+
11
+ class APIError(CertMateError):
12
+ """The API returned a non-2xx response.
13
+
14
+ ``status`` is the HTTP status code; ``code`` is CertMate's machine-readable
15
+ error code (e.g. ``DOMAIN_OUT_OF_SCOPE``) when present; ``payload`` is the
16
+ parsed response body."""
17
+
18
+ def __init__(self, message: str, *, status: int, code: Optional[str] = None,
19
+ payload: Any = None):
20
+ super().__init__(message)
21
+ self.status = status
22
+ self.code = code
23
+ self.payload = payload
24
+
25
+
26
+ class AuthError(APIError):
27
+ """401/403 — missing, invalid, or insufficiently-scoped credentials."""
28
+
29
+
30
+ class NotFoundError(APIError):
31
+ """404 — the domain / resource does not exist."""
32
+
33
+
34
+ class ConflictError(APIError):
35
+ """409 — e.g. the certificate already exists, or an operation is in flight."""
36
+
37
+
38
+ class JobFailed(CertMateError):
39
+ """An async issuance/renewal job finished in a failed state."""
40
+
41
+ def __init__(self, message: str, *, job: Any = None):
42
+ super().__init__(message)
43
+ self.job = job
44
+
45
+
46
+ class JobTimeout(CertMateError):
47
+ """wait_for_job exceeded its timeout before the job reached a terminal state."""
certmate/models.py ADDED
@@ -0,0 +1,78 @@
1
+ """Light, tolerant dataclasses over the CertMate API responses.
2
+
3
+ Kept deliberately forgiving: the API is the source of truth, so every model
4
+ keeps the raw dict and only surfaces the well-known fields as typed
5
+ attributes. Unknown/absent fields never raise — the SDK must not break when
6
+ the server adds a field."""
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Any, Dict, List, Optional
11
+
12
+
13
+ @dataclass
14
+ class Certificate:
15
+ domain: str
16
+ expiry_date: Optional[str] = None
17
+ days_until_expiry: Optional[int] = None
18
+ needs_renewal: Optional[bool] = None
19
+ ca_provider: Optional[str] = None
20
+ dns_provider: Optional[str] = None
21
+ auto_renew: Optional[bool] = None
22
+ san_domains: List[str] = field(default_factory=list)
23
+ raw: Dict[str, Any] = field(default_factory=dict)
24
+
25
+ @classmethod
26
+ def from_dict(cls, d: Dict[str, Any]) -> "Certificate":
27
+ d = d or {}
28
+ return cls(
29
+ domain=d.get("domain") or d.get("name") or "",
30
+ expiry_date=d.get("expiry_date") or d.get("expires") or d.get("not_after"),
31
+ days_until_expiry=d.get("days_until_expiry"),
32
+ needs_renewal=d.get("needs_renewal"),
33
+ ca_provider=d.get("ca_provider") or d.get("staging"),
34
+ dns_provider=d.get("dns_provider"),
35
+ auto_renew=d.get("auto_renew"),
36
+ san_domains=list(d.get("san_domains") or []),
37
+ raw=d,
38
+ )
39
+
40
+
41
+ # Terminal job states, normalised.
42
+ JOB_DONE = {"succeeded", "success", "completed", "done"}
43
+ JOB_FAILED = {"failed", "error"}
44
+
45
+
46
+ @dataclass
47
+ class Job:
48
+ job_id: str
49
+ operation: Optional[str] = None
50
+ domain: Optional[str] = None
51
+ status: Optional[str] = None
52
+ error: Optional[str] = None
53
+ raw: Dict[str, Any] = field(default_factory=dict)
54
+
55
+ @classmethod
56
+ def from_dict(cls, d: Dict[str, Any]) -> "Job":
57
+ d = d or {}
58
+ return cls(
59
+ job_id=d.get("job_id") or d.get("id") or "",
60
+ operation=d.get("operation"),
61
+ domain=d.get("domain"),
62
+ status=(d.get("status") or d.get("state")),
63
+ error=d.get("error"),
64
+ raw=d,
65
+ )
66
+
67
+ @property
68
+ def is_terminal(self) -> bool:
69
+ s = (self.status or "").lower()
70
+ return s in JOB_DONE or s in JOB_FAILED
71
+
72
+ @property
73
+ def succeeded(self) -> bool:
74
+ return (self.status or "").lower() in JOB_DONE
75
+
76
+ @property
77
+ def failed(self) -> bool:
78
+ return (self.status or "").lower() in JOB_FAILED
@@ -0,0 +1,43 @@
1
+ Metadata-Version: 2.4
2
+ Name: certmate-sdk
3
+ Version: 0.1.0
4
+ Summary: Thin Python client for the CertMate REST API
5
+ Project-URL: Homepage, https://github.com/fabriziosalmi/certmate
6
+ Project-URL: Source, https://github.com/fabriziosalmi/certmate/tree/main/clients/certmate-sdk
7
+ Author-email: Fabrizio Salmi <fabrizio.salmi@gmail.com>
8
+ License: MIT
9
+ Keywords: acme,certificates,certmate,letsencrypt,sdk,tls
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Intended Audience :: System Administrators
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Internet :: WWW/HTTP
22
+ Classifier: Topic :: Security
23
+ Classifier: Topic :: System :: Systems Administration
24
+ Requires-Python: >=3.9
25
+ Requires-Dist: httpx>=0.24
26
+ Description-Content-Type: text/markdown
27
+
28
+ # certmate-sdk
29
+
30
+ A thin, dependency-light Python client for the [CertMate](https://github.com/fabriziosalmi/certmate) REST API.
31
+
32
+ ```python
33
+ from certmate import Client
34
+
35
+ with Client("https://certmate.example.com", token="...") as c:
36
+ job = c.create_certificate("app.example.com", dns_provider="cloudflare", wait=True)
37
+ for cert in c.list_certificates():
38
+ print(cert.domain, cert.days_until_expiry)
39
+ print(c.audit_verify()["ok"])
40
+ ```
41
+
42
+ `base_url`/`token` fall back to `CERTMATE_URL` / `CERTMATE_TOKEN`. The only
43
+ runtime dependency is `httpx` — no server code, no certbot.
@@ -0,0 +1,7 @@
1
+ certmate/__init__.py,sha256=gZmT-GjJ8uJ5H3CJkVkllk-5RWiAZRiGW312cmgzIO0,643
2
+ certmate/client.py,sha256=TftHq6ZBZDpjhMRMTBtmymOlvWrEOLYPBMeOIpi_eEI,9121
3
+ certmate/errors.py,sha256=EGIS7NAQX9mtcuP2vgFbKA7yBRqXKQYbkEesJFzsT10,1351
4
+ certmate/models.py,sha256=ezrtMA6SBx1IOUnN5Ws9kOpb4UJB8AfgJTpuqxmW_XE,2514
5
+ certmate_sdk-0.1.0.dist-info/METADATA,sha256=M3rLkQXg7kDRUigp2fWcFHspr4ysy3YkAowAaAmIwl4,1739
6
+ certmate_sdk-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
7
+ certmate_sdk-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any