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.
- dockerhub_api/__init__.py +80 -0
- dockerhub_api/__main__.py +4 -0
- dockerhub_api/agent_server.py +92 -0
- dockerhub_api/api/__init__.py +1 -0
- dockerhub_api/api/api_client_access_tokens.py +77 -0
- dockerhub_api/api/api_client_audit_logs.py +56 -0
- dockerhub_api/api/api_client_auth.py +80 -0
- dockerhub_api/api/api_client_base.py +338 -0
- dockerhub_api/api/api_client_groups.py +158 -0
- dockerhub_api/api/api_client_org_access_tokens.py +106 -0
- dockerhub_api/api/api_client_orgs.py +149 -0
- dockerhub_api/api/api_client_repositories.py +217 -0
- dockerhub_api/api/api_client_scim.py +153 -0
- dockerhub_api/api_client.py +35 -0
- dockerhub_api/auth.py +252 -0
- dockerhub_api/dockerhub_input_models.py +756 -0
- dockerhub_api/dockerhub_response_models.py +344 -0
- dockerhub_api/main_agent.json +14 -0
- dockerhub_api/mcp/__init__.py +120 -0
- dockerhub_api/mcp/mcp_admin.py +45 -0
- dockerhub_api/mcp/mcp_audit.py +44 -0
- dockerhub_api/mcp/mcp_auth.py +66 -0
- dockerhub_api/mcp/mcp_org.py +58 -0
- dockerhub_api/mcp/mcp_repos.py +58 -0
- dockerhub_api/mcp/mcp_scim.py +56 -0
- dockerhub_api/mcp/mcp_teams.py +55 -0
- dockerhub_api/mcp_config.json +3 -0
- dockerhub_api/mcp_server.py +109 -0
- dockerhub_api-0.1.0.dist-info/METADATA +230 -0
- dockerhub_api-0.1.0.dist-info/RECORD +34 -0
- dockerhub_api-0.1.0.dist-info/WHEEL +5 -0
- dockerhub_api-0.1.0.dist-info/entry_points.txt +3 -0
- dockerhub_api-0.1.0.dist-info/licenses/LICENSE +21 -0
- dockerhub_api-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,756 @@
|
|
|
1
|
+
"""Pydantic input models for the Docker Hub API client.
|
|
2
|
+
|
|
3
|
+
CONCEPT:HUB-1.0 — core wrapper.
|
|
4
|
+
|
|
5
|
+
Each model validates the caller-supplied arguments for one endpoint family
|
|
6
|
+
and builds the query parameters (``api_parameters``) and/or request body
|
|
7
|
+
(``payload``) in ``model_post_init``, mirroring the gitlab-api convention.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
11
|
+
|
|
12
|
+
PAT_SCOPES = {"repo:admin", "repo:write", "repo:read", "repo:public_read"}
|
|
13
|
+
OAT_RESOURCE_TYPES = {"TYPE_REPO", "TYPE_ORG"}
|
|
14
|
+
REPOSITORY_ORDERING = {
|
|
15
|
+
"name",
|
|
16
|
+
"-name",
|
|
17
|
+
"last_updated",
|
|
18
|
+
"-last_updated",
|
|
19
|
+
"pull_count",
|
|
20
|
+
"-pull_count",
|
|
21
|
+
}
|
|
22
|
+
ORG_ROLES = {"owner", "editor", "member"}
|
|
23
|
+
REPOSITORY_PERMISSIONS = {"read", "write", "admin"}
|
|
24
|
+
SCIM_SORT_ORDERS = {"ascending", "descending"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _PaginatedModel(BaseModel):
|
|
28
|
+
"""Shared ``page`` / ``page_size`` query-parameter handling."""
|
|
29
|
+
|
|
30
|
+
page: int | None = Field(default=None, description="Pagination page", ge=1)
|
|
31
|
+
page_size: int | None = Field(
|
|
32
|
+
default=None, description="Results per page", ge=1, le=100
|
|
33
|
+
)
|
|
34
|
+
api_parameters: dict | None = Field(description="API parameters", default=None)
|
|
35
|
+
|
|
36
|
+
def model_post_init(self, _context):
|
|
37
|
+
self.api_parameters = {}
|
|
38
|
+
if self.page is not None:
|
|
39
|
+
self.api_parameters["page"] = self.page
|
|
40
|
+
if self.page_size is not None:
|
|
41
|
+
self.api_parameters["page_size"] = self.page_size
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# --------------------------------------------------------------------------- #
|
|
45
|
+
# Auth
|
|
46
|
+
# --------------------------------------------------------------------------- #
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AuthTokenModel(BaseModel):
|
|
50
|
+
"""``POST /v2/auth/token`` — mint a short-lived JWT bearer."""
|
|
51
|
+
|
|
52
|
+
identifier: str = Field(description="Username or organization name")
|
|
53
|
+
secret: str = Field(description="Password, PAT (dckr_pat_*), or org token")
|
|
54
|
+
payload: dict | None = Field(description="Request body", default=None)
|
|
55
|
+
|
|
56
|
+
def model_post_init(self, _context):
|
|
57
|
+
self.payload = {"identifier": self.identifier, "secret": self.secret}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class LoginModel(BaseModel):
|
|
61
|
+
"""``POST /v2/users/login`` (deprecated first-factor login)."""
|
|
62
|
+
|
|
63
|
+
username: str
|
|
64
|
+
password: str
|
|
65
|
+
payload: dict | None = Field(description="Request body", default=None)
|
|
66
|
+
|
|
67
|
+
def model_post_init(self, _context):
|
|
68
|
+
self.payload = {"username": self.username, "password": self.password}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class TwoFactorLoginModel(BaseModel):
|
|
72
|
+
"""``POST /v2/users/2fa-login`` (TOTP second factor)."""
|
|
73
|
+
|
|
74
|
+
login_2fa_token: str = Field(description="Token returned by the login call")
|
|
75
|
+
code: str = Field(description="TOTP code from the authenticator app")
|
|
76
|
+
payload: dict | None = Field(description="Request body", default=None)
|
|
77
|
+
|
|
78
|
+
def model_post_init(self, _context):
|
|
79
|
+
self.payload = {"login_2fa_token": self.login_2fa_token, "code": self.code}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# --------------------------------------------------------------------------- #
|
|
83
|
+
# Personal access tokens
|
|
84
|
+
# --------------------------------------------------------------------------- #
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class AccessTokenListModel(_PaginatedModel):
|
|
88
|
+
"""``GET /v2/access-tokens``."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class AccessTokenCreateModel(BaseModel):
|
|
92
|
+
"""``POST /v2/access-tokens``."""
|
|
93
|
+
|
|
94
|
+
token_label: str = Field(description="Human-readable token label")
|
|
95
|
+
scopes: list[str] = Field(description="Token scopes")
|
|
96
|
+
payload: dict | None = Field(description="Request body", default=None)
|
|
97
|
+
|
|
98
|
+
@field_validator("scopes")
|
|
99
|
+
@classmethod
|
|
100
|
+
def validate_scopes(cls, value: list[str]) -> list[str]:
|
|
101
|
+
invalid = sorted(set(value) - PAT_SCOPES)
|
|
102
|
+
if invalid:
|
|
103
|
+
raise ValueError(
|
|
104
|
+
f"Invalid PAT scopes {invalid}; valid scopes: {sorted(PAT_SCOPES)}"
|
|
105
|
+
)
|
|
106
|
+
if not value:
|
|
107
|
+
raise ValueError("At least one scope is required")
|
|
108
|
+
return value
|
|
109
|
+
|
|
110
|
+
def model_post_init(self, _context):
|
|
111
|
+
self.payload = {"token_label": self.token_label, "scopes": self.scopes}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class AccessTokenModel(BaseModel):
|
|
115
|
+
"""``GET|DELETE /v2/access-tokens/{uuid}``."""
|
|
116
|
+
|
|
117
|
+
uuid: str = Field(description="Access token UUID")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class AccessTokenPatchModel(BaseModel):
|
|
121
|
+
"""``PATCH /v2/access-tokens/{uuid}``."""
|
|
122
|
+
|
|
123
|
+
uuid: str = Field(description="Access token UUID")
|
|
124
|
+
token_label: str | None = None
|
|
125
|
+
is_active: bool | None = None
|
|
126
|
+
payload: dict | None = Field(description="Request body", default=None)
|
|
127
|
+
|
|
128
|
+
@model_validator(mode="after")
|
|
129
|
+
def validate_any_change(self):
|
|
130
|
+
if self.token_label is None and self.is_active is None:
|
|
131
|
+
raise ValueError("Provide token_label and/or is_active to update")
|
|
132
|
+
return self
|
|
133
|
+
|
|
134
|
+
def model_post_init(self, _context):
|
|
135
|
+
self.payload = {}
|
|
136
|
+
if self.token_label is not None:
|
|
137
|
+
self.payload["token_label"] = self.token_label
|
|
138
|
+
if self.is_active is not None:
|
|
139
|
+
self.payload["is_active"] = self.is_active
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# --------------------------------------------------------------------------- #
|
|
143
|
+
# Organization access tokens
|
|
144
|
+
# --------------------------------------------------------------------------- #
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class OrgAccessTokenListModel(_PaginatedModel):
|
|
148
|
+
"""``GET /v2/orgs/{org}/access-tokens``."""
|
|
149
|
+
|
|
150
|
+
org: str = Field(description="Organization name")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class OrgAccessTokenCreateModel(BaseModel):
|
|
154
|
+
"""``POST /v2/orgs/{org}/access-tokens``."""
|
|
155
|
+
|
|
156
|
+
org: str = Field(description="Organization name")
|
|
157
|
+
label: str = Field(description="Token label")
|
|
158
|
+
description: str | None = None
|
|
159
|
+
expires_at: str | None = Field(
|
|
160
|
+
default=None, description="RFC 3339 expiry timestamp"
|
|
161
|
+
)
|
|
162
|
+
scopes: list[str] | None = Field(
|
|
163
|
+
default=None, description="Org-wide scopes for the token"
|
|
164
|
+
)
|
|
165
|
+
resources: list[dict] | None = Field(
|
|
166
|
+
default=None,
|
|
167
|
+
description=(
|
|
168
|
+
"Scoped resources: [{'type': 'TYPE_REPO'|'TYPE_ORG', "
|
|
169
|
+
"'name': '<path glob>', 'scopes': [...]}]"
|
|
170
|
+
),
|
|
171
|
+
)
|
|
172
|
+
payload: dict | None = Field(description="Request body", default=None)
|
|
173
|
+
|
|
174
|
+
@field_validator("resources")
|
|
175
|
+
@classmethod
|
|
176
|
+
def validate_resources(cls, value: list[dict] | None) -> list[dict] | None:
|
|
177
|
+
if value is None:
|
|
178
|
+
return value
|
|
179
|
+
for resource in value:
|
|
180
|
+
resource_type = resource.get("type")
|
|
181
|
+
if resource_type not in OAT_RESOURCE_TYPES:
|
|
182
|
+
raise ValueError(
|
|
183
|
+
f"Invalid resource type {resource_type!r}; "
|
|
184
|
+
f"valid types: {sorted(OAT_RESOURCE_TYPES)}"
|
|
185
|
+
)
|
|
186
|
+
return value
|
|
187
|
+
|
|
188
|
+
def model_post_init(self, _context):
|
|
189
|
+
self.payload = {"label": self.label}
|
|
190
|
+
if self.description is not None:
|
|
191
|
+
self.payload["description"] = self.description
|
|
192
|
+
if self.expires_at is not None:
|
|
193
|
+
self.payload["expires_at"] = self.expires_at
|
|
194
|
+
if self.scopes is not None:
|
|
195
|
+
self.payload["scopes"] = self.scopes
|
|
196
|
+
if self.resources is not None:
|
|
197
|
+
self.payload["resources"] = self.resources
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class OrgAccessTokenModel(BaseModel):
|
|
201
|
+
"""``GET|DELETE /v2/orgs/{org}/access-tokens/{id}``."""
|
|
202
|
+
|
|
203
|
+
org: str = Field(description="Organization name")
|
|
204
|
+
token_id: str = Field(description="Org access token identifier")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class OrgAccessTokenPatchModel(BaseModel):
|
|
208
|
+
"""``PATCH /v2/orgs/{org}/access-tokens/{id}``."""
|
|
209
|
+
|
|
210
|
+
org: str = Field(description="Organization name")
|
|
211
|
+
token_id: str = Field(description="Org access token identifier")
|
|
212
|
+
label: str | None = None
|
|
213
|
+
description: str | None = None
|
|
214
|
+
is_active: bool | None = None
|
|
215
|
+
payload: dict | None = Field(description="Request body", default=None)
|
|
216
|
+
|
|
217
|
+
@model_validator(mode="after")
|
|
218
|
+
def validate_any_change(self):
|
|
219
|
+
if self.label is None and self.description is None and self.is_active is None:
|
|
220
|
+
raise ValueError("Provide label, description, and/or is_active to update")
|
|
221
|
+
return self
|
|
222
|
+
|
|
223
|
+
def model_post_init(self, _context):
|
|
224
|
+
self.payload = {}
|
|
225
|
+
if self.label is not None:
|
|
226
|
+
self.payload["label"] = self.label
|
|
227
|
+
if self.description is not None:
|
|
228
|
+
self.payload["description"] = self.description
|
|
229
|
+
if self.is_active is not None:
|
|
230
|
+
self.payload["is_active"] = self.is_active
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# --------------------------------------------------------------------------- #
|
|
234
|
+
# Audit logs
|
|
235
|
+
# --------------------------------------------------------------------------- #
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class AuditLogModel(_PaginatedModel):
|
|
239
|
+
"""``GET /v2/auditlogs/{account}``."""
|
|
240
|
+
|
|
241
|
+
account: str = Field(description="Namespace (user or organization)")
|
|
242
|
+
action: str | None = Field(default=None, description="Filter by action name")
|
|
243
|
+
name: str | None = Field(default=None, description="Filter by object name")
|
|
244
|
+
actor: str | None = Field(default=None, description="Filter by actor username")
|
|
245
|
+
from_date: str | None = Field(
|
|
246
|
+
default=None, description="Window start (RFC 3339), sent as 'from'"
|
|
247
|
+
)
|
|
248
|
+
to_date: str | None = Field(
|
|
249
|
+
default=None, description="Window end (RFC 3339), sent as 'to'"
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
def model_post_init(self, _context):
|
|
253
|
+
super().model_post_init(_context)
|
|
254
|
+
assert self.api_parameters is not None
|
|
255
|
+
if self.action:
|
|
256
|
+
self.api_parameters["action"] = self.action
|
|
257
|
+
if self.name:
|
|
258
|
+
self.api_parameters["name"] = self.name
|
|
259
|
+
if self.actor:
|
|
260
|
+
self.api_parameters["actor"] = self.actor
|
|
261
|
+
if self.from_date:
|
|
262
|
+
self.api_parameters["from"] = self.from_date
|
|
263
|
+
if self.to_date:
|
|
264
|
+
self.api_parameters["to"] = self.to_date
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# --------------------------------------------------------------------------- #
|
|
268
|
+
# Organization settings, members, and invites
|
|
269
|
+
# --------------------------------------------------------------------------- #
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class OrgSettingsModel(BaseModel):
|
|
273
|
+
"""``PUT /v2/orgs/{name}/settings``."""
|
|
274
|
+
|
|
275
|
+
org: str = Field(description="Organization name")
|
|
276
|
+
restricted_images_enabled: bool = Field(
|
|
277
|
+
description="Enable image-pull restrictions for the organization"
|
|
278
|
+
)
|
|
279
|
+
allow_official_images: bool = Field(
|
|
280
|
+
default=True, description="Allow Docker Official Images when restricted"
|
|
281
|
+
)
|
|
282
|
+
allow_verified_publishers: bool = Field(
|
|
283
|
+
default=True, description="Allow Verified Publisher images when restricted"
|
|
284
|
+
)
|
|
285
|
+
payload: dict | None = Field(description="Request body", default=None)
|
|
286
|
+
|
|
287
|
+
def model_post_init(self, _context):
|
|
288
|
+
self.payload = {
|
|
289
|
+
"restricted_images": {
|
|
290
|
+
"enabled": self.restricted_images_enabled,
|
|
291
|
+
"allow_official_images": self.allow_official_images,
|
|
292
|
+
"allow_verified_publishers": self.allow_verified_publishers,
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
class OrgMemberListModel(_PaginatedModel):
|
|
298
|
+
"""``GET /v2/orgs/{org}/members``."""
|
|
299
|
+
|
|
300
|
+
org: str = Field(description="Organization name")
|
|
301
|
+
search: str | None = Field(default=None, description="Search by username/email")
|
|
302
|
+
member_type: str | None = Field(
|
|
303
|
+
default=None, description="Filter by membership type, sent as 'type'"
|
|
304
|
+
)
|
|
305
|
+
role: str | None = Field(default=None, description="Filter by role")
|
|
306
|
+
|
|
307
|
+
@field_validator("role")
|
|
308
|
+
@classmethod
|
|
309
|
+
def validate_role(cls, value: str | None) -> str | None:
|
|
310
|
+
if value is not None and value not in ORG_ROLES:
|
|
311
|
+
raise ValueError(
|
|
312
|
+
f"Invalid role {value!r}; valid roles: {sorted(ORG_ROLES)}"
|
|
313
|
+
)
|
|
314
|
+
return value
|
|
315
|
+
|
|
316
|
+
def model_post_init(self, _context):
|
|
317
|
+
super().model_post_init(_context)
|
|
318
|
+
assert self.api_parameters is not None
|
|
319
|
+
if self.search:
|
|
320
|
+
self.api_parameters["search"] = self.search
|
|
321
|
+
if self.member_type:
|
|
322
|
+
self.api_parameters["type"] = self.member_type
|
|
323
|
+
if self.role:
|
|
324
|
+
self.api_parameters["role"] = self.role
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
class OrgMemberUpdateModel(BaseModel):
|
|
328
|
+
"""``PUT /v2/orgs/{org}/members/{username}``."""
|
|
329
|
+
|
|
330
|
+
org: str
|
|
331
|
+
username: str
|
|
332
|
+
role: str = Field(description="New role: owner, editor, or member")
|
|
333
|
+
payload: dict | None = Field(description="Request body", default=None)
|
|
334
|
+
|
|
335
|
+
@field_validator("role")
|
|
336
|
+
@classmethod
|
|
337
|
+
def validate_role(cls, value: str) -> str:
|
|
338
|
+
if value not in ORG_ROLES:
|
|
339
|
+
raise ValueError(
|
|
340
|
+
f"Invalid role {value!r}; valid roles: {sorted(ORG_ROLES)}"
|
|
341
|
+
)
|
|
342
|
+
return value
|
|
343
|
+
|
|
344
|
+
def model_post_init(self, _context):
|
|
345
|
+
self.payload = {"role": self.role}
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
class OrgMemberModel(BaseModel):
|
|
349
|
+
"""``DELETE /v2/orgs/{org}/members/{username}``."""
|
|
350
|
+
|
|
351
|
+
org: str
|
|
352
|
+
username: str
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
class InviteModel(BaseModel):
|
|
356
|
+
"""``DELETE /v2/invites/{id}`` and ``PATCH /v2/invites/{id}/resend``."""
|
|
357
|
+
|
|
358
|
+
invite_id: str = Field(description="Invite identifier")
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
class BulkInviteModel(BaseModel):
|
|
362
|
+
"""``POST /v2/invites/bulk``."""
|
|
363
|
+
|
|
364
|
+
org: str = Field(description="Organization name")
|
|
365
|
+
invitees: list[str] = Field(description="Usernames or email addresses to invite")
|
|
366
|
+
team: str | None = Field(default=None, description="Team (group) to invite into")
|
|
367
|
+
role: str = Field(default="member", description="Org role for the invitees")
|
|
368
|
+
dry_run: bool = Field(default=False, description="Validate without sending")
|
|
369
|
+
payload: dict | None = Field(description="Request body", default=None)
|
|
370
|
+
|
|
371
|
+
@field_validator("invitees")
|
|
372
|
+
@classmethod
|
|
373
|
+
def validate_invitees(cls, value: list[str]) -> list[str]:
|
|
374
|
+
if not value:
|
|
375
|
+
raise ValueError("At least one invitee is required")
|
|
376
|
+
return value
|
|
377
|
+
|
|
378
|
+
@field_validator("role")
|
|
379
|
+
@classmethod
|
|
380
|
+
def validate_role(cls, value: str) -> str:
|
|
381
|
+
if value not in ORG_ROLES:
|
|
382
|
+
raise ValueError(
|
|
383
|
+
f"Invalid role {value!r}; valid roles: {sorted(ORG_ROLES)}"
|
|
384
|
+
)
|
|
385
|
+
return value
|
|
386
|
+
|
|
387
|
+
def model_post_init(self, _context):
|
|
388
|
+
self.payload = {
|
|
389
|
+
"org": self.org,
|
|
390
|
+
"invitees": self.invitees,
|
|
391
|
+
"role": self.role,
|
|
392
|
+
"dry_run": self.dry_run,
|
|
393
|
+
}
|
|
394
|
+
if self.team is not None:
|
|
395
|
+
self.payload["team"] = self.team
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
# --------------------------------------------------------------------------- #
|
|
399
|
+
# Repositories & tags
|
|
400
|
+
# --------------------------------------------------------------------------- #
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
class RepositoryListModel(_PaginatedModel):
|
|
404
|
+
"""``GET /v2/namespaces/{namespace}/repositories``."""
|
|
405
|
+
|
|
406
|
+
namespace: str = Field(description="User or organization namespace")
|
|
407
|
+
name: str | None = Field(default=None, description="Filter by repository name")
|
|
408
|
+
ordering: str | None = Field(default=None, description="Result ordering")
|
|
409
|
+
|
|
410
|
+
@field_validator("ordering")
|
|
411
|
+
@classmethod
|
|
412
|
+
def validate_ordering(cls, value: str | None) -> str | None:
|
|
413
|
+
if value is not None and value not in REPOSITORY_ORDERING:
|
|
414
|
+
raise ValueError(
|
|
415
|
+
f"Invalid ordering {value!r}; valid: {sorted(REPOSITORY_ORDERING)}"
|
|
416
|
+
)
|
|
417
|
+
return value
|
|
418
|
+
|
|
419
|
+
def model_post_init(self, _context):
|
|
420
|
+
super().model_post_init(_context)
|
|
421
|
+
assert self.api_parameters is not None
|
|
422
|
+
if self.name:
|
|
423
|
+
self.api_parameters["name"] = self.name
|
|
424
|
+
if self.ordering:
|
|
425
|
+
self.api_parameters["ordering"] = self.ordering
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
class RepositoryCreateModel(BaseModel):
|
|
429
|
+
"""``POST /v2/namespaces/{namespace}/repositories``."""
|
|
430
|
+
|
|
431
|
+
namespace: str = Field(description="User or organization namespace")
|
|
432
|
+
name: str = Field(description="Repository name (lowercase)")
|
|
433
|
+
description: str | None = Field(default=None, description="Short description")
|
|
434
|
+
full_description: str | None = Field(
|
|
435
|
+
default=None, description="Long-form (Markdown) description"
|
|
436
|
+
)
|
|
437
|
+
registry: str = Field(default="docker", description="Target registry")
|
|
438
|
+
is_private: bool = Field(default=False, description="Create as private")
|
|
439
|
+
payload: dict | None = Field(description="Request body", default=None)
|
|
440
|
+
|
|
441
|
+
@field_validator("name")
|
|
442
|
+
@classmethod
|
|
443
|
+
def validate_name(cls, value: str) -> str:
|
|
444
|
+
import re
|
|
445
|
+
|
|
446
|
+
if not re.fullmatch(r"[a-z0-9]+(?:[._-][a-z0-9]+)*", value):
|
|
447
|
+
raise ValueError(
|
|
448
|
+
"Repository names must be lowercase alphanumerics separated by "
|
|
449
|
+
"'.', '_', or '-'"
|
|
450
|
+
)
|
|
451
|
+
return value
|
|
452
|
+
|
|
453
|
+
def model_post_init(self, _context):
|
|
454
|
+
self.payload = {
|
|
455
|
+
"name": self.name,
|
|
456
|
+
"namespace": self.namespace,
|
|
457
|
+
"registry": self.registry,
|
|
458
|
+
"is_private": self.is_private,
|
|
459
|
+
}
|
|
460
|
+
if self.description is not None:
|
|
461
|
+
self.payload["description"] = self.description
|
|
462
|
+
if self.full_description is not None:
|
|
463
|
+
self.payload["full_description"] = self.full_description
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
class RepositoryModel(BaseModel):
|
|
467
|
+
"""``GET|HEAD /v2/namespaces/{namespace}/repositories/{repository}``."""
|
|
468
|
+
|
|
469
|
+
namespace: str
|
|
470
|
+
repository: str
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
class TagListModel(_PaginatedModel):
|
|
474
|
+
"""``GET|HEAD .../repositories/{repository}/tags``."""
|
|
475
|
+
|
|
476
|
+
namespace: str
|
|
477
|
+
repository: str
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
class TagModel(BaseModel):
|
|
481
|
+
"""``GET|HEAD .../repositories/{repository}/tags/{tag}``."""
|
|
482
|
+
|
|
483
|
+
namespace: str
|
|
484
|
+
repository: str
|
|
485
|
+
tag: str
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
class ImmutableTagsPatchModel(BaseModel):
|
|
489
|
+
"""``PATCH .../repositories/{repository}/immutabletags``."""
|
|
490
|
+
|
|
491
|
+
namespace: str
|
|
492
|
+
repository: str
|
|
493
|
+
enabled: bool = Field(description="Enable or disable tag immutability")
|
|
494
|
+
rules: list[str] | None = Field(
|
|
495
|
+
default=None, description="Tag patterns the immutability applies to"
|
|
496
|
+
)
|
|
497
|
+
payload: dict | None = Field(description="Request body", default=None)
|
|
498
|
+
|
|
499
|
+
def model_post_init(self, _context):
|
|
500
|
+
self.payload = {"enabled": self.enabled}
|
|
501
|
+
if self.rules is not None:
|
|
502
|
+
self.payload["rules"] = self.rules
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
class ImmutableTagsVerifyModel(BaseModel):
|
|
506
|
+
"""``POST .../repositories/{repository}/immutabletags/verify``."""
|
|
507
|
+
|
|
508
|
+
namespace: str
|
|
509
|
+
repository: str
|
|
510
|
+
rules: list[str] | None = Field(
|
|
511
|
+
default=None, description="Candidate immutability rules to verify"
|
|
512
|
+
)
|
|
513
|
+
tags: list[str] | None = Field(
|
|
514
|
+
default=None, description="Tags to verify against the rules"
|
|
515
|
+
)
|
|
516
|
+
payload: dict | None = Field(description="Request body", default=None)
|
|
517
|
+
|
|
518
|
+
@model_validator(mode="after")
|
|
519
|
+
def validate_any_input(self):
|
|
520
|
+
if self.rules is None and self.tags is None:
|
|
521
|
+
raise ValueError("Provide rules and/or tags to verify")
|
|
522
|
+
return self
|
|
523
|
+
|
|
524
|
+
def model_post_init(self, _context):
|
|
525
|
+
self.payload = {}
|
|
526
|
+
if self.rules is not None:
|
|
527
|
+
self.payload["rules"] = self.rules
|
|
528
|
+
if self.tags is not None:
|
|
529
|
+
self.payload["tags"] = self.tags
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
class RepositoryGroupModel(BaseModel):
|
|
533
|
+
"""``POST /v2/repositories/{namespace}/{repository}/groups``."""
|
|
534
|
+
|
|
535
|
+
namespace: str
|
|
536
|
+
repository: str
|
|
537
|
+
group_id: int | str = Field(description="Team (group) identifier")
|
|
538
|
+
permission: str = Field(description="Permission: read, write, or admin")
|
|
539
|
+
payload: dict | None = Field(description="Request body", default=None)
|
|
540
|
+
|
|
541
|
+
@field_validator("permission")
|
|
542
|
+
@classmethod
|
|
543
|
+
def validate_permission(cls, value: str) -> str:
|
|
544
|
+
if value not in REPOSITORY_PERMISSIONS:
|
|
545
|
+
raise ValueError(
|
|
546
|
+
f"Invalid permission {value!r}; valid: {sorted(REPOSITORY_PERMISSIONS)}"
|
|
547
|
+
)
|
|
548
|
+
return value
|
|
549
|
+
|
|
550
|
+
def model_post_init(self, _context):
|
|
551
|
+
self.payload = {"group_id": self.group_id, "permission": self.permission}
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
# --------------------------------------------------------------------------- #
|
|
555
|
+
# Groups (teams)
|
|
556
|
+
# --------------------------------------------------------------------------- #
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
class GroupListModel(_PaginatedModel):
|
|
560
|
+
"""``GET /v2/orgs/{org}/groups``."""
|
|
561
|
+
|
|
562
|
+
org: str
|
|
563
|
+
search: str | None = Field(default=None, description="Search by group name")
|
|
564
|
+
|
|
565
|
+
def model_post_init(self, _context):
|
|
566
|
+
super().model_post_init(_context)
|
|
567
|
+
assert self.api_parameters is not None
|
|
568
|
+
if self.search:
|
|
569
|
+
self.api_parameters["search"] = self.search
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
class GroupCreateModel(BaseModel):
|
|
573
|
+
"""``POST /v2/orgs/{org}/groups``."""
|
|
574
|
+
|
|
575
|
+
org: str
|
|
576
|
+
name: str = Field(description="Group (team) name")
|
|
577
|
+
description: str | None = None
|
|
578
|
+
payload: dict | None = Field(description="Request body", default=None)
|
|
579
|
+
|
|
580
|
+
def model_post_init(self, _context):
|
|
581
|
+
self.payload = {"name": self.name}
|
|
582
|
+
if self.description is not None:
|
|
583
|
+
self.payload["description"] = self.description
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
class GroupModel(BaseModel):
|
|
587
|
+
"""``GET|DELETE /v2/orgs/{org}/groups/{group}``."""
|
|
588
|
+
|
|
589
|
+
org: str
|
|
590
|
+
group_name: str
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
class GroupUpdateModel(BaseModel):
|
|
594
|
+
"""``PUT|PATCH /v2/orgs/{org}/groups/{group}``."""
|
|
595
|
+
|
|
596
|
+
org: str
|
|
597
|
+
group_name: str
|
|
598
|
+
name: str | None = None
|
|
599
|
+
description: str | None = None
|
|
600
|
+
payload: dict | None = Field(description="Request body", default=None)
|
|
601
|
+
|
|
602
|
+
@model_validator(mode="after")
|
|
603
|
+
def validate_any_change(self):
|
|
604
|
+
if self.name is None and self.description is None:
|
|
605
|
+
raise ValueError("Provide name and/or description to update")
|
|
606
|
+
return self
|
|
607
|
+
|
|
608
|
+
def model_post_init(self, _context):
|
|
609
|
+
self.payload = {}
|
|
610
|
+
if self.name is not None:
|
|
611
|
+
self.payload["name"] = self.name
|
|
612
|
+
if self.description is not None:
|
|
613
|
+
self.payload["description"] = self.description
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
class GroupMemberListModel(_PaginatedModel):
|
|
617
|
+
"""``GET /v2/orgs/{org}/groups/{group}/members``."""
|
|
618
|
+
|
|
619
|
+
org: str
|
|
620
|
+
group_name: str
|
|
621
|
+
search: str | None = Field(default=None, description="Search by username")
|
|
622
|
+
|
|
623
|
+
def model_post_init(self, _context):
|
|
624
|
+
super().model_post_init(_context)
|
|
625
|
+
assert self.api_parameters is not None
|
|
626
|
+
if self.search:
|
|
627
|
+
self.api_parameters["search"] = self.search
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
class GroupMemberAddModel(BaseModel):
|
|
631
|
+
"""``POST /v2/orgs/{org}/groups/{group}/members``."""
|
|
632
|
+
|
|
633
|
+
org: str
|
|
634
|
+
group_name: str
|
|
635
|
+
member: str = Field(description="Username to add to the group")
|
|
636
|
+
payload: dict | None = Field(description="Request body", default=None)
|
|
637
|
+
|
|
638
|
+
def model_post_init(self, _context):
|
|
639
|
+
self.payload = {"member": self.member}
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
class GroupMemberModel(BaseModel):
|
|
643
|
+
"""``DELETE /v2/orgs/{org}/groups/{group}/members/{username}``."""
|
|
644
|
+
|
|
645
|
+
org: str
|
|
646
|
+
group_name: str
|
|
647
|
+
username: str
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
# --------------------------------------------------------------------------- #
|
|
651
|
+
# SCIM 2.0
|
|
652
|
+
# --------------------------------------------------------------------------- #
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
class ScimUserListModel(BaseModel):
|
|
656
|
+
"""``GET /v2/scim/2.0/Users`` (SCIM-style 1-based pagination)."""
|
|
657
|
+
|
|
658
|
+
start_index: int | None = Field(
|
|
659
|
+
default=None, ge=1, description="1-based index of the first result"
|
|
660
|
+
)
|
|
661
|
+
count: int | None = Field(default=None, ge=0, description="Max results to return")
|
|
662
|
+
filter: str | None = Field(
|
|
663
|
+
default=None, description='SCIM filter, e.g. userName eq "jane"'
|
|
664
|
+
)
|
|
665
|
+
sort_by: str | None = Field(default=None, description="Attribute to sort by")
|
|
666
|
+
sort_order: str | None = Field(default=None, description="ascending or descending")
|
|
667
|
+
api_parameters: dict | None = Field(description="API parameters", default=None)
|
|
668
|
+
|
|
669
|
+
@field_validator("sort_order")
|
|
670
|
+
@classmethod
|
|
671
|
+
def validate_sort_order(cls, value: str | None) -> str | None:
|
|
672
|
+
if value is not None and value not in SCIM_SORT_ORDERS:
|
|
673
|
+
raise ValueError(
|
|
674
|
+
f"Invalid sort order {value!r}; valid: {sorted(SCIM_SORT_ORDERS)}"
|
|
675
|
+
)
|
|
676
|
+
return value
|
|
677
|
+
|
|
678
|
+
def model_post_init(self, _context):
|
|
679
|
+
self.api_parameters = {}
|
|
680
|
+
if self.start_index is not None:
|
|
681
|
+
self.api_parameters["startIndex"] = self.start_index
|
|
682
|
+
if self.count is not None:
|
|
683
|
+
self.api_parameters["count"] = self.count
|
|
684
|
+
if self.filter:
|
|
685
|
+
self.api_parameters["filter"] = self.filter
|
|
686
|
+
if self.sort_by:
|
|
687
|
+
self.api_parameters["sortBy"] = self.sort_by
|
|
688
|
+
if self.sort_order:
|
|
689
|
+
self.api_parameters["sortOrder"] = self.sort_order
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
SCIM_USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User"
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
class ScimUserCreateModel(BaseModel):
|
|
696
|
+
"""``POST /v2/scim/2.0/Users``."""
|
|
697
|
+
|
|
698
|
+
user_name: str = Field(description="SCIM userName (email address)")
|
|
699
|
+
given_name: str | None = None
|
|
700
|
+
family_name: str | None = None
|
|
701
|
+
email: str | None = Field(
|
|
702
|
+
default=None, description="Primary email (defaults to user_name)"
|
|
703
|
+
)
|
|
704
|
+
active: bool = Field(default=True)
|
|
705
|
+
payload: dict | None = Field(description="Request body", default=None)
|
|
706
|
+
|
|
707
|
+
def model_post_init(self, _context):
|
|
708
|
+
self.payload = {
|
|
709
|
+
"schemas": [SCIM_USER_SCHEMA],
|
|
710
|
+
"userName": self.user_name,
|
|
711
|
+
"active": self.active,
|
|
712
|
+
}
|
|
713
|
+
name: dict = {}
|
|
714
|
+
if self.given_name is not None:
|
|
715
|
+
name["givenName"] = self.given_name
|
|
716
|
+
if self.family_name is not None:
|
|
717
|
+
name["familyName"] = self.family_name
|
|
718
|
+
if name:
|
|
719
|
+
self.payload["name"] = name
|
|
720
|
+
email = self.email or self.user_name
|
|
721
|
+
self.payload["emails"] = [{"value": email, "primary": True}]
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
class ScimUserModel(BaseModel):
|
|
725
|
+
"""``GET /v2/scim/2.0/Users/{id}``."""
|
|
726
|
+
|
|
727
|
+
user_id: str = Field(description="SCIM user identifier")
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
class ScimUserReplaceModel(BaseModel):
|
|
731
|
+
"""``PUT /v2/scim/2.0/Users/{id}`` — full resource replacement."""
|
|
732
|
+
|
|
733
|
+
user_id: str = Field(description="SCIM user identifier")
|
|
734
|
+
user_name: str = Field(description="SCIM userName (email address)")
|
|
735
|
+
given_name: str | None = None
|
|
736
|
+
family_name: str | None = None
|
|
737
|
+
email: str | None = None
|
|
738
|
+
active: bool = Field(default=True)
|
|
739
|
+
payload: dict | None = Field(description="Request body", default=None)
|
|
740
|
+
|
|
741
|
+
def model_post_init(self, _context):
|
|
742
|
+
self.payload = {
|
|
743
|
+
"schemas": [SCIM_USER_SCHEMA],
|
|
744
|
+
"id": self.user_id,
|
|
745
|
+
"userName": self.user_name,
|
|
746
|
+
"active": self.active,
|
|
747
|
+
}
|
|
748
|
+
name: dict = {}
|
|
749
|
+
if self.given_name is not None:
|
|
750
|
+
name["givenName"] = self.given_name
|
|
751
|
+
if self.family_name is not None:
|
|
752
|
+
name["familyName"] = self.family_name
|
|
753
|
+
if name:
|
|
754
|
+
self.payload["name"] = name
|
|
755
|
+
email = self.email or self.user_name
|
|
756
|
+
self.payload["emails"] = [{"value": email, "primary": True}]
|