certinext 0.1.2a2__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.
- certinext/__init__.py +88 -0
- certinext/_keyring.py +37 -0
- certinext/auth.py +84 -0
- certinext/client.py +162 -0
- certinext/domains.py +450 -0
- certinext/exceptions.py +55 -0
- certinext/py.typed +0 -0
- certinext/session.py +49 -0
- certinext-0.1.2a2.dist-info/METADATA +546 -0
- certinext-0.1.2a2.dist-info/RECORD +13 -0
- certinext-0.1.2a2.dist-info/WHEEL +5 -0
- certinext-0.1.2a2.dist-info/licenses/LICENSE +181 -0
- certinext-0.1.2a2.dist-info/top_level.txt +1 -0
certinext/__init__.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Copyright 2026 University of Maine System
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""CertiNext API client library.
|
|
16
|
+
|
|
17
|
+
Typical usage::
|
|
18
|
+
|
|
19
|
+
import certinext
|
|
20
|
+
|
|
21
|
+
sess = certinext.session(
|
|
22
|
+
client_id="YOUR_ACCOUNT_NUMBER",
|
|
23
|
+
client_secret="YOUR_CLIENT_SECRET",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
for domain in sess.domain.get_list():
|
|
27
|
+
print(domain)
|
|
28
|
+
|
|
29
|
+
Known API limitations (vendor bugs, pending fix):
|
|
30
|
+
- The ``search`` parameter to :meth:`~certinext.domains.DomainAccessor.get_list`
|
|
31
|
+
is ignored — all domains are returned regardless. Use ``pattern`` for
|
|
32
|
+
client-side filtering.
|
|
33
|
+
- Passing both ``domain_status`` and ``dcv_status`` to
|
|
34
|
+
:meth:`~certinext.domains.DomainAccessor.get_list` returns a 400 error.
|
|
35
|
+
:meth:`~certinext.domains.DomainAccessor.list_pending_dcv` works around this
|
|
36
|
+
by fetching all domains and filtering client-side.
|
|
37
|
+
|
|
38
|
+
Errors:
|
|
39
|
+
All API errors raise :class:`CertiNextAPIError` (a subclass of
|
|
40
|
+
:class:`requests.HTTPError`) with ``.status_code`` (int) and ``.body``
|
|
41
|
+
(dict or str) attributes for inspection.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from .client import CertiNextClient
|
|
45
|
+
from .domains import VALID_DCV_METHODS, DcvInfo, DcvMethod, DcvStatus, Domain, DomainAccessor, DomainStatus
|
|
46
|
+
from .exceptions import CertiNextAPIError
|
|
47
|
+
from .session import CertiNextSession
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def session(
|
|
51
|
+
base_url: str = "https://us-api.certinext.io",
|
|
52
|
+
token_url: str = "https://us-api.certinext.io/oauth/token",
|
|
53
|
+
client_id: str = "",
|
|
54
|
+
client_secret: str = "",
|
|
55
|
+
scope: str = "",
|
|
56
|
+
) -> CertiNextSession:
|
|
57
|
+
"""Create and return a new `CertiNextSession`.
|
|
58
|
+
|
|
59
|
+
This is the recommended entry point for the library. The session obtains
|
|
60
|
+
and caches an OAuth 2.0 bearer token automatically.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
base_url: CertiNext API base URL. Defaults to the US production endpoint.
|
|
64
|
+
token_url: OAuth 2.0 token endpoint URL.
|
|
65
|
+
client_id: Your CertiNext account number (used as the OAuth client ID).
|
|
66
|
+
client_secret: OAuth client secret generated in the CertiNext portal
|
|
67
|
+
under Integrations → APIs → OAuth mode.
|
|
68
|
+
scope: Optional OAuth scope string. Leave empty if not required.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
A configured `CertiNextSession` ready to make API calls.
|
|
72
|
+
"""
|
|
73
|
+
return CertiNextSession(base_url, token_url, client_id, client_secret, scope)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
__all__ = [
|
|
77
|
+
"session",
|
|
78
|
+
"CertiNextAPIError",
|
|
79
|
+
"CertiNextClient",
|
|
80
|
+
"CertiNextSession",
|
|
81
|
+
"Domain",
|
|
82
|
+
"DomainAccessor",
|
|
83
|
+
"DcvInfo",
|
|
84
|
+
"DcvMethod",
|
|
85
|
+
"DcvStatus",
|
|
86
|
+
"DomainStatus",
|
|
87
|
+
"VALID_DCV_METHODS",
|
|
88
|
+
]
|
certinext/_keyring.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Keyring helpers for CertiNext credential resolution.
|
|
2
|
+
|
|
3
|
+
Provides lightweight wrappers around the optional ``keyring`` package.
|
|
4
|
+
All functions silently degrade when keyring is not installed rather than
|
|
5
|
+
raising an ImportError, so callers do not need to guard the import.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def keyring_service(base: str, profile: str | None) -> str:
|
|
10
|
+
"""Return the keyring service name for a given base and optional profile.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
base: Base service name (e.g. ``'certinext'``).
|
|
14
|
+
profile: Profile suffix, or None for the default profile.
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
``'base-profile'`` when profile is set, otherwise ``'base'``.
|
|
18
|
+
"""
|
|
19
|
+
return f"{base}-{profile}" if profile else base
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def keyring_get(service: str, key: str) -> str | None:
|
|
23
|
+
"""Return a stored keyring value, or None if keyring is unavailable or unset.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
service: Keyring service name.
|
|
27
|
+
key: Keyring key (username field).
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
The stored string, or None on any failure.
|
|
31
|
+
"""
|
|
32
|
+
try:
|
|
33
|
+
import keyring
|
|
34
|
+
value = keyring.get_password(service, key)
|
|
35
|
+
return value if isinstance(value, str) else None
|
|
36
|
+
except Exception:
|
|
37
|
+
return None
|
certinext/auth.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Copyright 2026 University of Maine System
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
import time
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
import requests
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class OAuth2ClientCredentials:
|
|
22
|
+
"""Manages an OAuth 2.0 Client Credentials bearer token.
|
|
23
|
+
|
|
24
|
+
Fetches a token on first use and caches it, automatically refreshing it
|
|
25
|
+
60 seconds before expiry so callers always receive a valid token.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, token_url: str, client_id: str, client_secret: str, scope: str = "") -> None:
|
|
29
|
+
"""
|
|
30
|
+
Args:
|
|
31
|
+
token_url: Full URL of the OAuth 2.0 token endpoint.
|
|
32
|
+
client_id: OAuth client ID (your CertiNext account number).
|
|
33
|
+
client_secret: OAuth client secret.
|
|
34
|
+
scope: Optional space-separated OAuth scopes.
|
|
35
|
+
"""
|
|
36
|
+
self.token_url = token_url
|
|
37
|
+
self.client_id = client_id
|
|
38
|
+
self.client_secret = client_secret
|
|
39
|
+
self.scope = scope
|
|
40
|
+
self._access_token: Optional[str] = None
|
|
41
|
+
self._expires_at: float = 0.0
|
|
42
|
+
|
|
43
|
+
def get_token(self) -> str:
|
|
44
|
+
"""Return a valid bearer token, fetching a new one if necessary.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
A current OAuth access token string.
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
RuntimeError: If the token endpoint returns an error or non-JSON response.
|
|
51
|
+
"""
|
|
52
|
+
if self._access_token and time.time() < self._expires_at - 60:
|
|
53
|
+
return self._access_token
|
|
54
|
+
self._fetch_token()
|
|
55
|
+
assert self._access_token is not None
|
|
56
|
+
return self._access_token
|
|
57
|
+
|
|
58
|
+
def _fetch_token(self) -> None:
|
|
59
|
+
"""Request a new token from the token endpoint and cache it."""
|
|
60
|
+
data = {
|
|
61
|
+
"grant_type": "client_credentials",
|
|
62
|
+
"client_id": self.client_id,
|
|
63
|
+
"client_secret": self.client_secret,
|
|
64
|
+
}
|
|
65
|
+
if self.scope:
|
|
66
|
+
data["scope"] = self.scope
|
|
67
|
+
|
|
68
|
+
resp = requests.post(self.token_url, data=data)
|
|
69
|
+
if not resp.ok:
|
|
70
|
+
raise RuntimeError(
|
|
71
|
+
f"Token request failed: {resp.status_code} {resp.reason}\n"
|
|
72
|
+
f"URL: {resp.url}\n"
|
|
73
|
+
f"Body: {resp.text!r}"
|
|
74
|
+
)
|
|
75
|
+
try:
|
|
76
|
+
payload = resp.json()
|
|
77
|
+
except Exception as exc:
|
|
78
|
+
raise RuntimeError(
|
|
79
|
+
f"Token endpoint returned non-JSON (status {resp.status_code})\n"
|
|
80
|
+
f"URL: {resp.url}\n"
|
|
81
|
+
f"Body: {resp.text!r}"
|
|
82
|
+
) from exc
|
|
83
|
+
self._access_token = payload["access_token"]
|
|
84
|
+
self._expires_at = time.time() + payload.get("expires_in", 3600)
|
certinext/client.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# Copyright 2026 University of Maine System
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
from typing import Any, cast
|
|
16
|
+
|
|
17
|
+
import requests
|
|
18
|
+
|
|
19
|
+
from .auth import OAuth2ClientCredentials
|
|
20
|
+
from .exceptions import CertiNextAPIError
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CertiNextClient:
|
|
24
|
+
"""Low-level HTTP client for the CertiNext REST API.
|
|
25
|
+
|
|
26
|
+
Handles authentication automatically by delegating to
|
|
27
|
+
`OAuth2ClientCredentials`. All requests include a Bearer token and
|
|
28
|
+
JSON content-type headers. HTTP errors raise `CertiNextAPIError`.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
base_url: str,
|
|
34
|
+
token_url: str,
|
|
35
|
+
client_id: str,
|
|
36
|
+
client_secret: str,
|
|
37
|
+
scope: str = "",
|
|
38
|
+
) -> None:
|
|
39
|
+
"""
|
|
40
|
+
Args:
|
|
41
|
+
base_url: CertiNext API base URL (e.g. ``https://us-api.certinext.io``).
|
|
42
|
+
token_url: OAuth 2.0 token endpoint URL.
|
|
43
|
+
client_id: OAuth client ID (your CertiNext account number).
|
|
44
|
+
client_secret: OAuth client secret.
|
|
45
|
+
scope: Optional OAuth scope string.
|
|
46
|
+
"""
|
|
47
|
+
self.base_url = base_url.rstrip("/")
|
|
48
|
+
self._auth = OAuth2ClientCredentials(token_url, client_id, client_secret, scope)
|
|
49
|
+
self._session = requests.Session()
|
|
50
|
+
|
|
51
|
+
def _headers(self) -> dict[str, str]:
|
|
52
|
+
"""Build request headers with a fresh bearer token."""
|
|
53
|
+
return {
|
|
54
|
+
"Authorization": f"Bearer {self._auth.get_token()}",
|
|
55
|
+
"Content-Type": "application/json",
|
|
56
|
+
"Accept": "application/json",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
def _raise_api_error(self, resp: requests.Response) -> None:
|
|
60
|
+
"""Raise CertiNextAPIError if the response has a non-2xx status.
|
|
61
|
+
|
|
62
|
+
Preserves the API response body (parsed JSON or raw text) so callers
|
|
63
|
+
can inspect what the server actually returned.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
resp: The HTTP response to check.
|
|
67
|
+
|
|
68
|
+
Raises:
|
|
69
|
+
CertiNextAPIError: On a non-2xx response. Provides ``.status_code`` and ``.body``.
|
|
70
|
+
"""
|
|
71
|
+
try:
|
|
72
|
+
resp.raise_for_status()
|
|
73
|
+
except requests.HTTPError as exc:
|
|
74
|
+
try:
|
|
75
|
+
body: dict[str, Any] | str = resp.json()
|
|
76
|
+
except Exception:
|
|
77
|
+
body = resp.text
|
|
78
|
+
raise CertiNextAPIError(resp.status_code, body, response=resp) from exc
|
|
79
|
+
|
|
80
|
+
def get(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any] | list[Any]:
|
|
81
|
+
"""Send a GET request and return the parsed JSON response.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
path: API path relative to ``base_url`` (e.g. ``/api/certinext/v2/domains``).
|
|
85
|
+
params: Optional query-string parameters.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Parsed JSON response as a dict or list.
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
CertiNextAPIError: On a non-2xx response. Provides ``.status_code`` and ``.body``.
|
|
92
|
+
"""
|
|
93
|
+
resp = self._session.get(f"{self.base_url}{path}", headers=self._headers(), params=params)
|
|
94
|
+
self._raise_api_error(resp)
|
|
95
|
+
return cast(dict[str, Any], resp.json())
|
|
96
|
+
|
|
97
|
+
def post(self, path: str, json: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
98
|
+
"""Send a POST request with an optional JSON body and return the parsed response.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
path: API path relative to ``base_url``.
|
|
102
|
+
json: Optional request body to serialize as JSON.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
Parsed JSON response as a dict.
|
|
106
|
+
|
|
107
|
+
Raises:
|
|
108
|
+
CertiNextAPIError: On a non-2xx response. Provides ``.status_code`` and ``.body``.
|
|
109
|
+
"""
|
|
110
|
+
resp = self._session.post(f"{self.base_url}{path}", headers=self._headers(), json=json)
|
|
111
|
+
self._raise_api_error(resp)
|
|
112
|
+
return cast(dict[str, Any], resp.json())
|
|
113
|
+
|
|
114
|
+
def put(self, path: str, json: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
115
|
+
"""Send a PUT request with an optional JSON body and return the parsed response.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
path: API path relative to ``base_url``.
|
|
119
|
+
json: Optional request body to serialize as JSON.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
Parsed JSON response as a dict.
|
|
123
|
+
|
|
124
|
+
Raises:
|
|
125
|
+
CertiNextAPIError: On a non-2xx response. Provides ``.status_code`` and ``.body``.
|
|
126
|
+
"""
|
|
127
|
+
resp = self._session.put(f"{self.base_url}{path}", headers=self._headers(), json=json)
|
|
128
|
+
self._raise_api_error(resp)
|
|
129
|
+
return cast(dict[str, Any], resp.json())
|
|
130
|
+
|
|
131
|
+
def patch(self, path: str, json: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
132
|
+
"""Send a PATCH request with an optional JSON body and return the parsed response.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
path: API path relative to ``base_url``.
|
|
136
|
+
json: Optional request body to serialize as JSON.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
Parsed JSON response as a dict.
|
|
140
|
+
|
|
141
|
+
Raises:
|
|
142
|
+
CertiNextAPIError: On a non-2xx response. Provides ``.status_code`` and ``.body``.
|
|
143
|
+
"""
|
|
144
|
+
resp = self._session.patch(f"{self.base_url}{path}", headers=self._headers(), json=json)
|
|
145
|
+
self._raise_api_error(resp)
|
|
146
|
+
return cast(dict[str, Any], resp.json())
|
|
147
|
+
|
|
148
|
+
def delete(self, path: str) -> dict[str, Any] | None:
|
|
149
|
+
"""Send a DELETE request and return the parsed response body if present.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
path: API path relative to ``base_url``.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
Parsed JSON response as a dict, or ``None`` if the response has no body.
|
|
156
|
+
|
|
157
|
+
Raises:
|
|
158
|
+
CertiNextAPIError: On a non-2xx response. Provides ``.status_code`` and ``.body``.
|
|
159
|
+
"""
|
|
160
|
+
resp = self._session.delete(f"{self.base_url}{path}", headers=self._headers())
|
|
161
|
+
self._raise_api_error(resp)
|
|
162
|
+
return resp.json() if resp.content else None
|