git-buckets 0.3.1__tar.gz → 0.4.0__tar.gz

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.
Files changed (21) hide show
  1. {git_buckets-0.3.1 → git_buckets-0.4.0}/PKG-INFO +14 -1
  2. {git_buckets-0.3.1 → git_buckets-0.4.0}/README.md +13 -0
  3. {git_buckets-0.3.1 → git_buckets-0.4.0}/pyproject.toml +1 -1
  4. {git_buckets-0.3.1 → git_buckets-0.4.0}/pyproject.toml.orig +1 -1
  5. {git_buckets-0.3.1 → git_buckets-0.4.0}/src/git_buckets/cli.py +119 -0
  6. git_buckets-0.4.0/src/git_buckets/package.py +74 -0
  7. {git_buckets-0.3.1 → git_buckets-0.4.0}/src/git_buckets/__init__.py +0 -0
  8. {git_buckets-0.3.1 → git_buckets-0.4.0}/src/git_buckets/credential.py +0 -0
  9. {git_buckets-0.3.1 → git_buckets-0.4.0}/src/git_buckets/hosts.py +0 -0
  10. {git_buckets-0.3.1 → git_buckets-0.4.0}/src/git_buckets/keyring/__init__.py +0 -0
  11. {git_buckets-0.3.1 → git_buckets-0.4.0}/src/git_buckets/keyring/backend.py +0 -0
  12. {git_buckets-0.3.1 → git_buckets-0.4.0}/src/git_buckets/keyring/proof.py +0 -0
  13. {git_buckets-0.3.1 → git_buckets-0.4.0}/src/git_buckets/lfs_agent.py +0 -0
  14. {git_buckets-0.3.1 → git_buckets-0.4.0}/src/git_buckets/login.py +0 -0
  15. {git_buckets-0.3.1 → git_buckets-0.4.0}/src/git_buckets/net.py +0 -0
  16. {git_buckets-0.3.1 → git_buckets-0.4.0}/src/git_buckets/oauth.py +0 -0
  17. {git_buckets-0.3.1 → git_buckets-0.4.0}/src/git_buckets/repo.py +0 -0
  18. {git_buckets-0.3.1 → git_buckets-0.4.0}/src/git_buckets/secretstore.py +0 -0
  19. {git_buckets-0.3.1 → git_buckets-0.4.0}/src/git_buckets/session.py +0 -0
  20. {git_buckets-0.3.1 → git_buckets-0.4.0}/src/git_buckets/termio.py +0 -0
  21. {git_buckets-0.3.1 → git_buckets-0.4.0}/src/git_buckets/wake.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: git-buckets
3
- Version: 0.3.1
3
+ Version: 0.4.0
4
4
  Summary: Git over S3: clone, fetch and push repositories backed by an S3 bucket.
5
5
  Author: Full Duplex Media
6
6
  Author-email: Full Duplex Media <contact@fullduplex.media>
@@ -123,6 +123,19 @@ gb repo delete my-bucket/tools/cli/my-project --yes # without --yes it only pri
123
123
  Delete takes the repo's LFS objects, locks, bundles, packages and rendered web tree with it. The undo path is S3
124
124
  versioning on the operator's side, not a `gb` command. There is no rename.
125
125
 
126
+ ## Package lifecycle
127
+
128
+ Published files are immutable, so a bad release is yanked rather than replaced: it stays downloadable for anything
129
+ that already pinned it, and every other resolve skips it.
130
+
131
+ ```sh
132
+ gb package list # every bucket, grouped by repo
133
+ gb package list -b my-bucket -r tools/cli/my-project # one repo; -v lists every file with size and sha256
134
+ gb package yank my-bucket/tools/cli/my-project my-project 1.2.0 --reason "bad wheel"
135
+ gb package unyank my-bucket/tools/cli/my-project my-project 1.2.0
136
+ gb package delete my-bucket/tools/cli/my-project my-project 1.2.0 --yes # the bytes go; lockfiles that pin it 404
137
+ ```
138
+
126
139
  ## Where the secrets live
127
140
 
128
141
  The refresh token (one year, fixed from sign-in) goes in your OS keyring, or in `~/.config/gb/hosts.yml` at 0600 when no
@@ -108,6 +108,19 @@ gb repo delete my-bucket/tools/cli/my-project --yes # without --yes it only pri
108
108
  Delete takes the repo's LFS objects, locks, bundles, packages and rendered web tree with it. The undo path is S3
109
109
  versioning on the operator's side, not a `gb` command. There is no rename.
110
110
 
111
+ ## Package lifecycle
112
+
113
+ Published files are immutable, so a bad release is yanked rather than replaced: it stays downloadable for anything
114
+ that already pinned it, and every other resolve skips it.
115
+
116
+ ```sh
117
+ gb package list # every bucket, grouped by repo
118
+ gb package list -b my-bucket -r tools/cli/my-project # one repo; -v lists every file with size and sha256
119
+ gb package yank my-bucket/tools/cli/my-project my-project 1.2.0 --reason "bad wheel"
120
+ gb package unyank my-bucket/tools/cli/my-project my-project 1.2.0
121
+ gb package delete my-bucket/tools/cli/my-project my-project 1.2.0 --yes # the bytes go; lockfiles that pin it 404
122
+ ```
123
+
111
124
  ## Where the secrets live
112
125
 
113
126
  The refresh token (one year, fixed from sign-in) goes in your OS keyring, or in `~/.config/gb/hosts.yml` at 0600 when no
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "git-buckets"
3
- version = "0.3.1"
3
+ version = "0.4.0"
4
4
  description = "Git over S3: clone, fetch and push repositories backed by an S3 bucket."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.14"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "git-buckets"
3
- version = "0.3.1"
3
+ version = "0.4.0"
4
4
  description = "Git over S3: clone, fetch and push repositories backed by an S3 bucket."
5
5
  authors = [
6
6
  { name = 'Full Duplex Media', email = 'contact@fullduplex.media' }
@@ -231,6 +231,125 @@ def repo_unprotect_command(name: str, ref: str, host: str | None) -> None:
231
231
  _protect('unprotect', name, ref, host)
232
232
 
233
233
 
234
+ @cli.group(name='package')
235
+ def package_group() -> None:
236
+ """Manage published packages: list them, yank a bad release, delete one."""
237
+
238
+
239
+ def _print_versions(versions: list[dict], verbose: bool) -> None:
240
+ for release in versions:
241
+ yanked = release.get('yanked')
242
+ suffix = f' (yanked: {yanked})' if isinstance(yanked, str) and yanked else ' (yanked)' if yanked else ''
243
+ # A file whose name declares no version still lists, grouped under a null version by the API.
244
+ version = release.get('version') or '(unversioned)'
245
+ click.echo(f' {version}{suffix}')
246
+ if verbose:
247
+ for entry in release.get('files', []):
248
+ click.echo(f' {entry["filename"]} {_human_bytes(entry["size"])} {entry["sha256"]}')
249
+
250
+
251
+ @package_group.command(name='list')
252
+ @_HOST_OPTION
253
+ @click.option('-b', '--bucket', default=None, help='One bucket; every bucket you can see if omitted.')
254
+ @click.option('-r', '--repo', 'repo_path', default=None, help='One repo inside the bucket; needs --bucket.')
255
+ @click.option('-v', '--verbose', is_flag=True, help='Also list every file with its size and sha256.')
256
+ @click.argument('name', required=False)
257
+ def package_list_command(
258
+ host: str | None, bucket: str | None, repo_path: str | None, verbose: bool, name: str | None
259
+ ) -> None:
260
+ """List published packages: gb package list [-b BUCKET] [-r REPO] [NAME]."""
261
+ from . import package as package_module, repo as repo_module
262
+
263
+ if repo_path and not bucket:
264
+ raise click.UsageError('--repo needs --bucket: packages live in a bucket, and repo paths are not unique.')
265
+ try:
266
+ domain, entry = repo_module.select_host(host)
267
+ labels = [bucket] if bucket else package_module.buckets(domain, entry)
268
+ listings = [(label, package_module.listing(domain, entry, label, repo_path, name)) for label in labels]
269
+ except Exception as x:
270
+ raise click.ClickException(str(x)) from None
271
+ if not listings:
272
+ click.echo('No buckets. Run: gb login <deployment-domain>')
273
+ return
274
+ for label, packages in listings:
275
+ click.echo(label)
276
+ if not packages:
277
+ click.echo(' (no packages)')
278
+ for project in sorted(packages, key=lambda item: (item['repo'], item['name'])):
279
+ click.echo(f' {project["repo"]} {project["name"]}')
280
+ _print_versions(project.get('versions', []), verbose)
281
+
282
+
283
+ @package_group.command(name='yank')
284
+ @_HOST_OPTION
285
+ @click.option('--reason', default=None, help='Why it was yanked; resolvers and gb package list show it.')
286
+ @click.argument('repo_name', metavar='BUCKET/REPO')
287
+ @click.argument('package', metavar='NAME')
288
+ @click.argument('version')
289
+ def package_yank_command(repo_name: str, package: str, version: str, host: str | None, reason: str | None) -> None:
290
+ """Yank a release.
291
+
292
+ The files stay downloadable, so anything that already pinned the version keeps resolving; every other
293
+ resolve skips it.
294
+ """
295
+ from . import package as package_module, repo as repo_module
296
+
297
+ try:
298
+ bucket, path = repo_module.parse_name(repo_name)
299
+ domain, entry = repo_module.select_host(host)
300
+ result = package_module.yank(domain, entry, bucket, path, package, version, reason)
301
+ except Exception as x:
302
+ raise click.ClickException(str(x)) from None
303
+ click.echo(f'Yanked {package} {version} in {repo_name}: {len(result.get("files", []))} file(s).')
304
+
305
+
306
+ @package_group.command(name='unyank')
307
+ @_HOST_OPTION
308
+ @click.argument('repo_name', metavar='BUCKET/REPO')
309
+ @click.argument('package', metavar='NAME')
310
+ @click.argument('version')
311
+ def package_unyank_command(repo_name: str, package: str, version: str, host: str | None) -> None:
312
+ """Put a yanked release back."""
313
+ from . import package as package_module, repo as repo_module
314
+
315
+ try:
316
+ bucket, path = repo_module.parse_name(repo_name)
317
+ domain, entry = repo_module.select_host(host)
318
+ result = package_module.unyank(domain, entry, bucket, path, package, version)
319
+ except Exception as x:
320
+ raise click.ClickException(str(x)) from None
321
+ click.echo(f'Unyanked {package} {version} in {repo_name}: {len(result.get("files", []))} file(s).')
322
+
323
+
324
+ @package_group.command(name='delete')
325
+ @_HOST_OPTION
326
+ @click.option('--yes', is_flag=True, help='Actually delete; without it, nothing is touched.')
327
+ @click.argument('repo_name', metavar='BUCKET/REPO')
328
+ @click.argument('package', metavar='NAME')
329
+ @click.argument('version')
330
+ def package_delete_command(repo_name: str, package: str, version: str, host: str | None, yes: bool) -> None:
331
+ """Delete a release's files."""
332
+ from . import package as package_module, repo as repo_module
333
+
334
+ try:
335
+ bucket, path = repo_module.parse_name(repo_name)
336
+ domain, entry = repo_module.select_host(host)
337
+ except Exception as x:
338
+ raise click.ClickException(str(x)) from None
339
+ if not yes:
340
+ click.echo(f'This deletes {package} {version} from {repo_name}. Lockfiles that pin it will 404.')
341
+ click.echo('Prefer `gb package yank` unless the bytes must not exist.')
342
+ raise click.ClickException('refusing to delete without confirmation; re-run with --yes')
343
+ try:
344
+ result = package_module.delete(domain, entry, bucket, path, package, version)
345
+ except Exception as x:
346
+ raise click.ClickException(str(x)) from None
347
+ deleted = result.get('deleted', [])
348
+ click.echo(f'Deleted {package} {version} from {repo_name}: {len(deleted)} file(s).')
349
+ for filename in deleted:
350
+ click.echo(f' {filename}')
351
+
352
+
234
353
  @cli.command(name='credential', hidden=True)
235
354
  @click.argument('operation')
236
355
  def credential_command(operation: str) -> None:
@@ -0,0 +1,74 @@
1
+ """``gb package``: list, yank/unyank and delete releases against a deployment's package API.
2
+
3
+ Click-free like :mod:`repo`, and deliberately built on its helpers: host selection, the session cookie and the
4
+ error mapping are the same ones ``/api/repo/*`` uses, so :class:`~.repo.RepoError` is the error type here too.
5
+ """
6
+
7
+ import re
8
+ from typing import Any
9
+ from urllib.parse import urlencode
10
+
11
+ from . import net
12
+ from .hosts import Entry
13
+ from .repo import RepoError, _headers, _raise_mapped, list_repos
14
+
15
+
16
+ def normalize(name: str) -> str:
17
+ """The PEP 503 normalized project name, which is what the index is keyed by."""
18
+ return re.sub(r'[-_.]+', '-', name).lower()
19
+
20
+
21
+ def buckets(domain: str, entry: Entry) -> list[str]:
22
+ """Every bucket label the token can see. ``/api/packages`` is per bucket, so listing them all starts here."""
23
+ listing = list_repos(domain, entry)
24
+ return sorted({bucket['bucket'] for bucket in listing.get('buckets', [])})
25
+
26
+
27
+ def listing(domain: str, entry: Entry, bucket: str, repo: str | None = None, name: str | None = None) -> list[Any]:
28
+ query: dict[str, str] = {'bucket': bucket}
29
+ if repo:
30
+ query['repo'] = repo
31
+ if name:
32
+ query['name'] = normalize(name)
33
+ url = f'https://{domain}/api/packages?{urlencode(query)}'
34
+ try:
35
+ result = net.get_json(url, headers=_headers(domain, entry))
36
+ except net.HttpError as x:
37
+ if x.status == 404:
38
+ raise RepoError(f'{bucket} is not a bucket on {domain}') from None
39
+ if x.status == 403:
40
+ raise RepoError(f'not entitled to list packages in {bucket}') from None
41
+ _raise_mapped(domain, x)
42
+ raise
43
+ return result.get('packages', [])
44
+
45
+
46
+ def _call(
47
+ operation: str, domain: str, entry: Entry, bucket: str, repo: str, name: str, version: str, **extra: str
48
+ ) -> dict[str, Any]:
49
+ payload: dict[str, Any] = {'bucket': bucket, 'repo': repo, 'name': normalize(name), 'version': version, **extra}
50
+ try:
51
+ return net.post_json(f'https://{domain}/api/package/{operation}', payload, headers=_headers(domain, entry))
52
+ except net.HttpError as x:
53
+ if x.status == 403:
54
+ raise RepoError(f'not entitled to {operation} packages in {bucket}/{repo}') from None
55
+ if x.status == 404:
56
+ state = 'is not yanked in' if operation == 'unyank' else 'not found in'
57
+ raise RepoError(f'{name} {version} {state} {bucket}/{repo} on {domain}') from None
58
+ _raise_mapped(domain, x)
59
+ raise
60
+
61
+
62
+ def yank(
63
+ domain: str, entry: Entry, bucket: str, repo: str, name: str, version: str, reason: str | None = None
64
+ ) -> dict[str, Any]:
65
+ extra = {'reason': reason} if reason else {}
66
+ return _call('yank', domain, entry, bucket, repo, name, version, **extra)
67
+
68
+
69
+ def unyank(domain: str, entry: Entry, bucket: str, repo: str, name: str, version: str) -> dict[str, Any]:
70
+ return _call('unyank', domain, entry, bucket, repo, name, version)
71
+
72
+
73
+ def delete(domain: str, entry: Entry, bucket: str, repo: str, name: str, version: str) -> dict[str, Any]:
74
+ return _call('delete', domain, entry, bucket, repo, name, version)