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