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.
- {git_buckets-0.3.1 → git_buckets-0.4.0}/PKG-INFO +14 -1
- {git_buckets-0.3.1 → git_buckets-0.4.0}/README.md +13 -0
- {git_buckets-0.3.1 → git_buckets-0.4.0}/pyproject.toml +1 -1
- {git_buckets-0.3.1 → git_buckets-0.4.0}/pyproject.toml.orig +1 -1
- {git_buckets-0.3.1 → git_buckets-0.4.0}/src/git_buckets/cli.py +119 -0
- git_buckets-0.4.0/src/git_buckets/package.py +74 -0
- {git_buckets-0.3.1 → git_buckets-0.4.0}/src/git_buckets/__init__.py +0 -0
- {git_buckets-0.3.1 → git_buckets-0.4.0}/src/git_buckets/credential.py +0 -0
- {git_buckets-0.3.1 → git_buckets-0.4.0}/src/git_buckets/hosts.py +0 -0
- {git_buckets-0.3.1 → git_buckets-0.4.0}/src/git_buckets/keyring/__init__.py +0 -0
- {git_buckets-0.3.1 → git_buckets-0.4.0}/src/git_buckets/keyring/backend.py +0 -0
- {git_buckets-0.3.1 → git_buckets-0.4.0}/src/git_buckets/keyring/proof.py +0 -0
- {git_buckets-0.3.1 → git_buckets-0.4.0}/src/git_buckets/lfs_agent.py +0 -0
- {git_buckets-0.3.1 → git_buckets-0.4.0}/src/git_buckets/login.py +0 -0
- {git_buckets-0.3.1 → git_buckets-0.4.0}/src/git_buckets/net.py +0 -0
- {git_buckets-0.3.1 → git_buckets-0.4.0}/src/git_buckets/oauth.py +0 -0
- {git_buckets-0.3.1 → git_buckets-0.4.0}/src/git_buckets/repo.py +0 -0
- {git_buckets-0.3.1 → git_buckets-0.4.0}/src/git_buckets/secretstore.py +0 -0
- {git_buckets-0.3.1 → git_buckets-0.4.0}/src/git_buckets/session.py +0 -0
- {git_buckets-0.3.1 → git_buckets-0.4.0}/src/git_buckets/termio.py +0 -0
- {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
|
+
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
|
|
@@ -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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|