dockerhub-api 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.
@@ -0,0 +1,338 @@
1
+ """Base HTTP plumbing for the Docker Hub API client.
2
+
3
+ CONCEPT:HUB-1.0 — core wrapper. Raw ``httpx`` client against
4
+ ``https://hub.docker.com`` with a uniform response envelope.
5
+
6
+ CONCEPT:HUB-1.2 — rate-limit telemetry. Every response's
7
+ ``X-RateLimit-Limit`` / ``X-RateLimit-Remaining`` / ``X-RateLimit-Reset``
8
+ headers are captured into the result envelope (and kept on the client as
9
+ ``rate_limit``), and HTTP 429 responses are retried with a bounded
10
+ ``Retry-After`` backoff.
11
+
12
+ CONCEPT:HUB-1.3 — destructive-action gating. Deletes and org-settings writes
13
+ raise :class:`DestructiveOperationError` unless the client was built with
14
+ ``allow_destructive=True``.
15
+ """
16
+
17
+ import logging
18
+ import time
19
+ from typing import Any
20
+
21
+ import httpx
22
+ from agent_utilities.base_utilities import get_logger
23
+ from agent_utilities.core.exceptions import (
24
+ ApiError,
25
+ AuthError,
26
+ ParameterError,
27
+ UnauthorizedError,
28
+ )
29
+
30
+ logger = get_logger(__name__)
31
+
32
+ JSON_CONTENT_TYPE = "application/json"
33
+ SCIM_CONTENT_TYPE = "application/scim+json"
34
+
35
+ #: Hard ceiling (seconds) on a single Retry-After sleep so a hostile or
36
+ #: misconfigured server can never stall a caller indefinitely.
37
+ DEFAULT_RETRY_AFTER_CAP = 15.0
38
+ DEFAULT_MAX_RETRIES = 3
39
+ DEFAULT_TIMEOUT = 30.0
40
+
41
+
42
+ class DestructiveOperationError(PermissionError):
43
+ """Raised when a destructive operation is attempted while gated off."""
44
+
45
+
46
+ class DockerHubApiBase:
47
+ """Shared transport, auth header, retry, and envelope logic."""
48
+
49
+ def __init__(
50
+ self,
51
+ url: str | None = None,
52
+ username: str | None = None,
53
+ password: str | None = None,
54
+ token: str | None = None,
55
+ token_manager: Any | None = None,
56
+ verify: bool = True,
57
+ timeout: float = DEFAULT_TIMEOUT,
58
+ max_retries: int = DEFAULT_MAX_RETRIES,
59
+ retry_after_cap: float = DEFAULT_RETRY_AFTER_CAP,
60
+ allow_destructive: bool = False,
61
+ debug: bool = False,
62
+ transport: httpx.BaseTransport | None = None,
63
+ ):
64
+ if debug:
65
+ logger.setLevel(logging.DEBUG)
66
+ logger.debug("Debug mode enabled")
67
+ else:
68
+ logger.setLevel(logging.ERROR)
69
+
70
+ from dockerhub_api.auth import DEFAULT_DOCKERHUB_URL, TokenManager
71
+
72
+ self.url = (url or DEFAULT_DOCKERHUB_URL).rstrip("/")
73
+ self.verify = verify
74
+ self.timeout = timeout
75
+ self.max_retries = max_retries
76
+ self.retry_after_cap = retry_after_cap
77
+ self.allow_destructive = allow_destructive
78
+ self.debug = debug
79
+ #: Last rate-limit snapshot seen on any response.
80
+ self.rate_limit: dict[str, Any] = {}
81
+
82
+ self._static_token: str | None = None
83
+ self._token_manager = None
84
+ if token_manager is not None:
85
+ self._token_manager = token_manager
86
+ elif token:
87
+ self._static_token = token
88
+ elif username and password:
89
+ self._token_manager = TokenManager(
90
+ identifier=username,
91
+ secret=password,
92
+ url=self.url,
93
+ verify=verify,
94
+ timeout=timeout,
95
+ transport=transport,
96
+ )
97
+
98
+ self._client = httpx.Client(
99
+ base_url=self.url,
100
+ verify=verify,
101
+ timeout=timeout,
102
+ transport=transport,
103
+ )
104
+
105
+ # ------------------------------------------------------------------ #
106
+ # Auth & headers
107
+ # ------------------------------------------------------------------ #
108
+
109
+ def _bearer_token(self) -> str | None:
110
+ if self._token_manager is not None:
111
+ return self._token_manager.get_token()
112
+ return self._static_token
113
+
114
+ def _build_headers(
115
+ self, content_type: str | None = None, accept: str | None = None
116
+ ) -> dict[str, str]:
117
+ headers: dict[str, str] = {
118
+ "Accept": accept or JSON_CONTENT_TYPE,
119
+ "Content-Type": content_type or JSON_CONTENT_TYPE,
120
+ }
121
+ token = self._bearer_token()
122
+ if token:
123
+ headers["Authorization"] = f"Bearer {token}"
124
+ return headers
125
+
126
+ # ------------------------------------------------------------------ #
127
+ # Rate-limit telemetry
128
+ # ------------------------------------------------------------------ #
129
+
130
+ def _capture_rate_limit(self, response: httpx.Response) -> dict[str, Any]:
131
+ snapshot: dict[str, Any] = {}
132
+ for header, key in (
133
+ ("X-RateLimit-Limit", "limit"),
134
+ ("X-RateLimit-Remaining", "remaining"),
135
+ ("X-RateLimit-Reset", "reset"),
136
+ ):
137
+ value = response.headers.get(header)
138
+ if value is not None:
139
+ try:
140
+ snapshot[key] = int(value)
141
+ except ValueError:
142
+ snapshot[key] = value
143
+ if snapshot:
144
+ self.rate_limit = snapshot
145
+ return snapshot
146
+
147
+ def get_rate_limit(self) -> dict[str, Any]:
148
+ """Return the most recent rate-limit snapshot observed by this client."""
149
+ return {
150
+ "status_code": 200,
151
+ "data": dict(self.rate_limit) if self.rate_limit else {},
152
+ "rate_limit": dict(self.rate_limit) if self.rate_limit else {},
153
+ }
154
+
155
+ def whoami(self) -> dict[str, Any]:
156
+ """Introspect the active credential locally (decoded JWT claims).
157
+
158
+ No network call is made: the cached JWT payload is decoded. For a
159
+ static token the claims are decoded from the supplied bearer.
160
+ """
161
+ from dockerhub_api.auth import decode_jwt_claims
162
+
163
+ if self._token_manager is not None:
164
+ claims = self._token_manager.claims()
165
+ identity = getattr(self._token_manager, "identifier", None)
166
+ elif self._static_token:
167
+ claims = decode_jwt_claims(self._static_token)
168
+ identity = claims.get("username") or claims.get("sub")
169
+ else:
170
+ return {
171
+ "status_code": 200,
172
+ "data": {"authenticated": False, "identity": None, "claims": {}},
173
+ "rate_limit": dict(self.rate_limit) if self.rate_limit else {},
174
+ }
175
+ public_claims = {
176
+ key: value
177
+ for key, value in claims.items()
178
+ if key in ("sub", "username", "exp", "iat", "iss", "aud", "scope", "uuid")
179
+ }
180
+ return {
181
+ "status_code": 200,
182
+ "data": {
183
+ "authenticated": True,
184
+ "identity": identity or public_claims.get("username"),
185
+ "claims": public_claims,
186
+ },
187
+ "rate_limit": dict(self.rate_limit) if self.rate_limit else {},
188
+ }
189
+
190
+ # ------------------------------------------------------------------ #
191
+ # Destructive gating
192
+ # ------------------------------------------------------------------ #
193
+
194
+ def _guard_destructive(self, operation: str) -> None:
195
+ if not self.allow_destructive:
196
+ raise DestructiveOperationError(
197
+ f"Destructive operation '{operation}' is disabled. "
198
+ "Build the client with allow_destructive=True or set "
199
+ "DOCKERHUB_ALLOW_DESTRUCTIVE=True to enable it."
200
+ )
201
+
202
+ # ------------------------------------------------------------------ #
203
+ # Request engine
204
+ # ------------------------------------------------------------------ #
205
+
206
+ def _request(
207
+ self,
208
+ method: str,
209
+ endpoint: str,
210
+ params: dict | None = None,
211
+ json: Any | None = None,
212
+ content_type: str | None = None,
213
+ accept: str | None = None,
214
+ raise_for_status: bool = True,
215
+ ) -> dict[str, Any]:
216
+ """Send one API request and return the uniform response envelope.
217
+
218
+ Envelope: ``{"status_code": int, "data": Any, "rate_limit": dict}``.
219
+ Handles 429 with bounded Retry-After backoff and one transparent
220
+ token refresh on 401 when a token manager is present.
221
+ """
222
+ if params:
223
+ params = {k: v for k, v in params.items() if v is not None}
224
+
225
+ attempts = 0
226
+ refreshed_token = False
227
+ while True:
228
+ response = self._client.request(
229
+ method=method,
230
+ url=endpoint,
231
+ params=params or None,
232
+ json=json,
233
+ headers=self._build_headers(content_type=content_type, accept=accept),
234
+ )
235
+ rate_limit = self._capture_rate_limit(response)
236
+
237
+ if response.status_code == 429 and attempts < self.max_retries:
238
+ attempts += 1
239
+ delay = self._retry_after_seconds(response)
240
+ logger.debug(
241
+ "Rate limited; retrying",
242
+ extra={"attempt": attempts, "delay": delay},
243
+ )
244
+ if delay > 0:
245
+ time.sleep(delay)
246
+ continue
247
+
248
+ if (
249
+ response.status_code == 401
250
+ and self._token_manager is not None
251
+ and not refreshed_token
252
+ ):
253
+ refreshed_token = True
254
+ self._token_manager.invalidate()
255
+ continue
256
+
257
+ break
258
+
259
+ data = self._parse_body(response)
260
+ if raise_for_status and response.status_code >= 400:
261
+ self._raise_for_status(response, data)
262
+
263
+ return {
264
+ "status_code": response.status_code,
265
+ "data": data,
266
+ "rate_limit": rate_limit or dict(self.rate_limit),
267
+ }
268
+
269
+ def _retry_after_seconds(self, response: httpx.Response) -> float:
270
+ raw = response.headers.get("Retry-After", "1")
271
+ try:
272
+ delay = float(raw)
273
+ except ValueError:
274
+ delay = 1.0
275
+ return max(0.0, min(delay, self.retry_after_cap))
276
+
277
+ @staticmethod
278
+ def _parse_body(response: httpx.Response) -> Any:
279
+ if response.status_code == 204 or not response.content:
280
+ return None
281
+ content_type = response.headers.get("Content-Type", "")
282
+ if "json" in content_type:
283
+ try:
284
+ return response.json()
285
+ except ValueError:
286
+ return response.text
287
+ return response.text
288
+
289
+ def _raise_for_status(self, response: httpx.Response, data: Any) -> None:
290
+ detail = ""
291
+ if isinstance(data, dict):
292
+ detail = str(
293
+ data.get("detail") or data.get("message") or data.get("errinfo") or ""
294
+ )
295
+ message = f"HTTP {response.status_code} for {response.request.method} {response.request.url.path}"
296
+ if detail:
297
+ message = f"{message}: {detail}"
298
+
299
+ if response.status_code == 400:
300
+ raise ParameterError(message)
301
+ if response.status_code == 401:
302
+ raise AuthError(message)
303
+ if response.status_code == 403:
304
+ raise UnauthorizedError(message)
305
+ if response.status_code == 404:
306
+ raise ParameterError(message)
307
+ if response.status_code == 429:
308
+ raise ApiError(
309
+ f"{message} — rate limited after {self.max_retries} retries "
310
+ f"(remaining={self.rate_limit.get('remaining')}, "
311
+ f"reset={self.rate_limit.get('reset')})"
312
+ )
313
+ raise ApiError(message)
314
+
315
+ def _exists(self, endpoint: str, params: dict | None = None) -> dict[str, Any]:
316
+ """HEAD helper: existence check without raising on 404."""
317
+ envelope = self._request(
318
+ "HEAD", endpoint, params=params, raise_for_status=False
319
+ )
320
+ status_code = envelope["status_code"]
321
+ if status_code not in (200, 404) and status_code >= 400:
322
+ # Map real errors (401/403/5xx); 404 simply means "does not exist".
323
+ if status_code == 401:
324
+ raise AuthError(f"HTTP {status_code} for HEAD {endpoint}")
325
+ if status_code == 403:
326
+ raise UnauthorizedError(f"HTTP {status_code} for HEAD {endpoint}")
327
+ raise ApiError(f"HTTP {status_code} for HEAD {endpoint}")
328
+ envelope["exists"] = 200 <= status_code < 300
329
+ return envelope
330
+
331
+ def close(self) -> None:
332
+ self._client.close()
333
+
334
+ def __enter__(self) -> "DockerHubApiBase":
335
+ return self
336
+
337
+ def __exit__(self, *exc_info: Any) -> None:
338
+ self.close()
@@ -0,0 +1,158 @@
1
+ """Group (team) endpoints (``/v2/orgs/{org}/groups``).
2
+
3
+ CONCEPT:HUB-1.0 — core wrapper.
4
+ CONCEPT:HUB-1.3 — destructive-action gating (group/member deletion).
5
+ """
6
+
7
+ from typing import Any
8
+
9
+ from dockerhub_api.api.api_client_base import DockerHubApiBase
10
+ from dockerhub_api.dockerhub_input_models import (
11
+ GroupCreateModel,
12
+ GroupListModel,
13
+ GroupMemberAddModel,
14
+ GroupMemberListModel,
15
+ GroupMemberModel,
16
+ GroupModel,
17
+ GroupUpdateModel,
18
+ )
19
+ from dockerhub_api.dockerhub_response_models import (
20
+ Group,
21
+ GroupPage,
22
+ OrgMember,
23
+ OrgMemberPage,
24
+ validate_lenient,
25
+ )
26
+
27
+
28
+ class DockerHubApiGroups(DockerHubApiBase):
29
+ """Org teams and their membership."""
30
+
31
+ def get_groups(
32
+ self,
33
+ org: str,
34
+ search: str | None = None,
35
+ page: int | None = None,
36
+ page_size: int | None = None,
37
+ ) -> dict[str, Any]:
38
+ """List an organization's groups (teams)."""
39
+ model = GroupListModel(org=org, search=search, page=page, page_size=page_size)
40
+ envelope = self._request(
41
+ "GET", f"/v2/orgs/{model.org}/groups", params=model.api_parameters
42
+ )
43
+ envelope["data"] = validate_lenient(GroupPage, envelope["data"])
44
+ return envelope
45
+
46
+ def create_group(
47
+ self, org: str, name: str, description: str | None = None
48
+ ) -> dict[str, Any]:
49
+ """Create a group (team) in an organization."""
50
+ model = GroupCreateModel(org=org, name=name, description=description)
51
+ envelope = self._request(
52
+ "POST", f"/v2/orgs/{model.org}/groups", json=model.payload
53
+ )
54
+ envelope["data"] = validate_lenient(Group, envelope["data"])
55
+ return envelope
56
+
57
+ def get_group(self, org: str, group_name: str) -> dict[str, Any]:
58
+ """Get one group."""
59
+ model = GroupModel(org=org, group_name=group_name)
60
+ envelope = self._request(
61
+ "GET", f"/v2/orgs/{model.org}/groups/{model.group_name}"
62
+ )
63
+ envelope["data"] = validate_lenient(Group, envelope["data"])
64
+ return envelope
65
+
66
+ def update_group(
67
+ self,
68
+ org: str,
69
+ group_name: str,
70
+ name: str | None = None,
71
+ description: str | None = None,
72
+ ) -> dict[str, Any]:
73
+ """Replace a group's details (``PUT``)."""
74
+ model = GroupUpdateModel(
75
+ org=org, group_name=group_name, name=name, description=description
76
+ )
77
+ envelope = self._request(
78
+ "PUT", f"/v2/orgs/{model.org}/groups/{model.group_name}", json=model.payload
79
+ )
80
+ envelope["data"] = validate_lenient(Group, envelope["data"])
81
+ return envelope
82
+
83
+ def patch_group(
84
+ self,
85
+ org: str,
86
+ group_name: str,
87
+ name: str | None = None,
88
+ description: str | None = None,
89
+ ) -> dict[str, Any]:
90
+ """Partially update a group (``PATCH``)."""
91
+ model = GroupUpdateModel(
92
+ org=org, group_name=group_name, name=name, description=description
93
+ )
94
+ envelope = self._request(
95
+ "PATCH",
96
+ f"/v2/orgs/{model.org}/groups/{model.group_name}",
97
+ json=model.payload,
98
+ )
99
+ envelope["data"] = validate_lenient(Group, envelope["data"])
100
+ return envelope
101
+
102
+ def delete_group(self, org: str, group_name: str) -> dict[str, Any]:
103
+ """Delete a group. Destructive — gated."""
104
+ self._guard_destructive("delete_group")
105
+ model = GroupModel(org=org, group_name=group_name)
106
+ return self._request(
107
+ "DELETE", f"/v2/orgs/{model.org}/groups/{model.group_name}"
108
+ )
109
+
110
+ # ------------------------------ members ------------------------------ #
111
+
112
+ def get_group_members(
113
+ self,
114
+ org: str,
115
+ group_name: str,
116
+ search: str | None = None,
117
+ page: int | None = None,
118
+ page_size: int | None = None,
119
+ ) -> dict[str, Any]:
120
+ """List a group's members."""
121
+ model = GroupMemberListModel(
122
+ org=org,
123
+ group_name=group_name,
124
+ search=search,
125
+ page=page,
126
+ page_size=page_size,
127
+ )
128
+ envelope = self._request(
129
+ "GET",
130
+ f"/v2/orgs/{model.org}/groups/{model.group_name}/members",
131
+ params=model.api_parameters,
132
+ )
133
+ envelope["data"] = validate_lenient(OrgMemberPage, envelope["data"])
134
+ return envelope
135
+
136
+ def add_group_member(
137
+ self, org: str, group_name: str, member: str
138
+ ) -> dict[str, Any]:
139
+ """Add a username to a group."""
140
+ model = GroupMemberAddModel(org=org, group_name=group_name, member=member)
141
+ envelope = self._request(
142
+ "POST",
143
+ f"/v2/orgs/{model.org}/groups/{model.group_name}/members",
144
+ json=model.payload,
145
+ )
146
+ envelope["data"] = validate_lenient(OrgMember, envelope["data"])
147
+ return envelope
148
+
149
+ def remove_group_member(
150
+ self, org: str, group_name: str, username: str
151
+ ) -> dict[str, Any]:
152
+ """Remove a username from a group. Destructive — gated."""
153
+ self._guard_destructive("remove_group_member")
154
+ model = GroupMemberModel(org=org, group_name=group_name, username=username)
155
+ return self._request(
156
+ "DELETE",
157
+ f"/v2/orgs/{model.org}/groups/{model.group_name}/members/{model.username}",
158
+ )
@@ -0,0 +1,106 @@
1
+ """Organization access token (OAT) endpoints (``/v2/orgs/{org}/access-tokens``).
2
+
3
+ CONCEPT:HUB-1.1 — JWT auth lifecycle (OAT management).
4
+ """
5
+
6
+ from typing import Any
7
+
8
+ from dockerhub_api.api.api_client_base import DockerHubApiBase
9
+ from dockerhub_api.dockerhub_input_models import (
10
+ OrgAccessTokenCreateModel,
11
+ OrgAccessTokenListModel,
12
+ OrgAccessTokenModel,
13
+ OrgAccessTokenPatchModel,
14
+ )
15
+ from dockerhub_api.dockerhub_response_models import (
16
+ OrgAccessToken,
17
+ OrgAccessTokenPage,
18
+ validate_lenient,
19
+ )
20
+
21
+
22
+ class DockerHubApiOrgAccessTokens(DockerHubApiBase):
23
+ """CRUD for organization access tokens."""
24
+
25
+ def get_org_access_tokens(
26
+ self, org: str, page: int | None = None, page_size: int | None = None
27
+ ) -> dict[str, Any]:
28
+ """List an organization's access tokens."""
29
+ model = OrgAccessTokenListModel(org=org, page=page, page_size=page_size)
30
+ envelope = self._request(
31
+ "GET",
32
+ f"/v2/orgs/{model.org}/access-tokens",
33
+ params=model.api_parameters,
34
+ )
35
+ envelope["data"] = validate_lenient(OrgAccessTokenPage, envelope["data"])
36
+ return envelope
37
+
38
+ def create_org_access_token(
39
+ self,
40
+ org: str,
41
+ label: str,
42
+ description: str | None = None,
43
+ expires_at: str | None = None,
44
+ scopes: list[str] | None = None,
45
+ resources: list[dict] | None = None,
46
+ ) -> dict[str, Any]:
47
+ """Create an organization access token.
48
+
49
+ ``resources`` entries scope the token to ``TYPE_REPO`` / ``TYPE_ORG``
50
+ resources with path globs and per-resource scopes. The plaintext token
51
+ is only returned once, in this response.
52
+ """
53
+ model = OrgAccessTokenCreateModel(
54
+ org=org,
55
+ label=label,
56
+ description=description,
57
+ expires_at=expires_at,
58
+ scopes=scopes,
59
+ resources=resources,
60
+ )
61
+ envelope = self._request(
62
+ "POST", f"/v2/orgs/{model.org}/access-tokens", json=model.payload
63
+ )
64
+ envelope["data"] = validate_lenient(OrgAccessToken, envelope["data"])
65
+ return envelope
66
+
67
+ def get_org_access_token(self, org: str, token_id: str) -> dict[str, Any]:
68
+ """Get one organization access token by id."""
69
+ model = OrgAccessTokenModel(org=org, token_id=token_id)
70
+ envelope = self._request(
71
+ "GET", f"/v2/orgs/{model.org}/access-tokens/{model.token_id}"
72
+ )
73
+ envelope["data"] = validate_lenient(OrgAccessToken, envelope["data"])
74
+ return envelope
75
+
76
+ def update_org_access_token(
77
+ self,
78
+ org: str,
79
+ token_id: str,
80
+ label: str | None = None,
81
+ description: str | None = None,
82
+ is_active: bool | None = None,
83
+ ) -> dict[str, Any]:
84
+ """Patch an organization access token."""
85
+ model = OrgAccessTokenPatchModel(
86
+ org=org,
87
+ token_id=token_id,
88
+ label=label,
89
+ description=description,
90
+ is_active=is_active,
91
+ )
92
+ envelope = self._request(
93
+ "PATCH",
94
+ f"/v2/orgs/{model.org}/access-tokens/{model.token_id}",
95
+ json=model.payload,
96
+ )
97
+ envelope["data"] = validate_lenient(OrgAccessToken, envelope["data"])
98
+ return envelope
99
+
100
+ def delete_org_access_token(self, org: str, token_id: str) -> dict[str, Any]:
101
+ """Delete an organization access token. Destructive — gated."""
102
+ self._guard_destructive("delete_org_access_token")
103
+ model = OrgAccessTokenModel(org=org, token_id=token_id)
104
+ return self._request(
105
+ "DELETE", f"/v2/orgs/{model.org}/access-tokens/{model.token_id}"
106
+ )