cloudsmith-cli 1.20.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.
Files changed (130) hide show
  1. cloudsmith_cli/__init__.py +10 -0
  2. cloudsmith_cli/__main__.py +8 -0
  3. cloudsmith_cli/cli/__init__.py +1 -0
  4. cloudsmith_cli/cli/command.py +160 -0
  5. cloudsmith_cli/cli/commands/__init__.py +33 -0
  6. cloudsmith_cli/cli/commands/auth.py +173 -0
  7. cloudsmith_cli/cli/commands/check.py +129 -0
  8. cloudsmith_cli/cli/commands/copy.py +98 -0
  9. cloudsmith_cli/cli/commands/credential_helper/__init__.py +39 -0
  10. cloudsmith_cli/cli/commands/credential_helper/docker.py +66 -0
  11. cloudsmith_cli/cli/commands/credential_helper/manage.py +299 -0
  12. cloudsmith_cli/cli/commands/delete.py +67 -0
  13. cloudsmith_cli/cli/commands/dependencies.py +108 -0
  14. cloudsmith_cli/cli/commands/docs.py +16 -0
  15. cloudsmith_cli/cli/commands/download.py +620 -0
  16. cloudsmith_cli/cli/commands/entitlements.py +819 -0
  17. cloudsmith_cli/cli/commands/help_.py +12 -0
  18. cloudsmith_cli/cli/commands/list_.py +317 -0
  19. cloudsmith_cli/cli/commands/login.py +99 -0
  20. cloudsmith_cli/cli/commands/logout.py +151 -0
  21. cloudsmith_cli/cli/commands/main.py +73 -0
  22. cloudsmith_cli/cli/commands/mcp.py +523 -0
  23. cloudsmith_cli/cli/commands/metadata.py +503 -0
  24. cloudsmith_cli/cli/commands/metrics/__init__.py +2 -0
  25. cloudsmith_cli/cli/commands/metrics/command.py +20 -0
  26. cloudsmith_cli/cli/commands/metrics/entitlements.py +148 -0
  27. cloudsmith_cli/cli/commands/metrics/packages.py +134 -0
  28. cloudsmith_cli/cli/commands/move.py +111 -0
  29. cloudsmith_cli/cli/commands/policy/__init__.py +3 -0
  30. cloudsmith_cli/cli/commands/policy/command.py +20 -0
  31. cloudsmith_cli/cli/commands/policy/deny.py +248 -0
  32. cloudsmith_cli/cli/commands/policy/license.py +335 -0
  33. cloudsmith_cli/cli/commands/policy/vulnerability.py +322 -0
  34. cloudsmith_cli/cli/commands/push.py +1323 -0
  35. cloudsmith_cli/cli/commands/quarantine.py +148 -0
  36. cloudsmith_cli/cli/commands/quota/__init__.py +2 -0
  37. cloudsmith_cli/cli/commands/quota/command.py +20 -0
  38. cloudsmith_cli/cli/commands/quota/history.py +122 -0
  39. cloudsmith_cli/cli/commands/quota/quota.py +107 -0
  40. cloudsmith_cli/cli/commands/repos.py +330 -0
  41. cloudsmith_cli/cli/commands/resync.py +90 -0
  42. cloudsmith_cli/cli/commands/status.py +92 -0
  43. cloudsmith_cli/cli/commands/tags.py +375 -0
  44. cloudsmith_cli/cli/commands/tokens.py +318 -0
  45. cloudsmith_cli/cli/commands/upstream.py +479 -0
  46. cloudsmith_cli/cli/commands/vulnerabilities.py +141 -0
  47. cloudsmith_cli/cli/commands/whoami.py +188 -0
  48. cloudsmith_cli/cli/config.py +635 -0
  49. cloudsmith_cli/cli/decorators.py +624 -0
  50. cloudsmith_cli/cli/exceptions.py +215 -0
  51. cloudsmith_cli/cli/metadata_common.py +146 -0
  52. cloudsmith_cli/cli/saml.py +109 -0
  53. cloudsmith_cli/cli/table.py +59 -0
  54. cloudsmith_cli/cli/types.py +14 -0
  55. cloudsmith_cli/cli/utils.py +267 -0
  56. cloudsmith_cli/cli/validators.py +378 -0
  57. cloudsmith_cli/cli/webserver.py +263 -0
  58. cloudsmith_cli/core/__init__.py +1 -0
  59. cloudsmith_cli/core/api/__init__.py +1 -0
  60. cloudsmith_cli/core/api/distros.py +31 -0
  61. cloudsmith_cli/core/api/entitlements.py +130 -0
  62. cloudsmith_cli/core/api/exceptions.py +57 -0
  63. cloudsmith_cli/core/api/files.py +131 -0
  64. cloudsmith_cli/core/api/init.py +109 -0
  65. cloudsmith_cli/core/api/metadata.py +217 -0
  66. cloudsmith_cli/core/api/metrics.py +78 -0
  67. cloudsmith_cli/core/api/orgs.py +201 -0
  68. cloudsmith_cli/core/api/packages.py +309 -0
  69. cloudsmith_cli/core/api/quota.py +64 -0
  70. cloudsmith_cli/core/api/rates.py +28 -0
  71. cloudsmith_cli/core/api/repos.py +81 -0
  72. cloudsmith_cli/core/api/status.py +27 -0
  73. cloudsmith_cli/core/api/upstreams.py +72 -0
  74. cloudsmith_cli/core/api/user.py +109 -0
  75. cloudsmith_cli/core/api/version.py +15 -0
  76. cloudsmith_cli/core/api/vulnerabilities.py +230 -0
  77. cloudsmith_cli/core/cache_utils.py +160 -0
  78. cloudsmith_cli/core/config.py +140 -0
  79. cloudsmith_cli/core/credentials/__init__.py +0 -0
  80. cloudsmith_cli/core/credentials/chain.py +69 -0
  81. cloudsmith_cli/core/credentials/models.py +44 -0
  82. cloudsmith_cli/core/credentials/oidc/__init__.py +6 -0
  83. cloudsmith_cli/core/credentials/oidc/cache.py +220 -0
  84. cloudsmith_cli/core/credentials/oidc/detectors/__init__.py +122 -0
  85. cloudsmith_cli/core/credentials/oidc/detectors/aws.py +85 -0
  86. cloudsmith_cli/core/credentials/oidc/detectors/azure_devops.py +70 -0
  87. cloudsmith_cli/core/credentials/oidc/detectors/base.py +26 -0
  88. cloudsmith_cli/core/credentials/oidc/detectors/bitbucket_pipelines.py +35 -0
  89. cloudsmith_cli/core/credentials/oidc/detectors/circleci.py +40 -0
  90. cloudsmith_cli/core/credentials/oidc/detectors/generic.py +42 -0
  91. cloudsmith_cli/core/credentials/oidc/detectors/github_actions.py +64 -0
  92. cloudsmith_cli/core/credentials/oidc/detectors/gitlab_ci.py +49 -0
  93. cloudsmith_cli/core/credentials/oidc/exchange.py +87 -0
  94. cloudsmith_cli/core/credentials/provider.py +17 -0
  95. cloudsmith_cli/core/credentials/providers/__init__.py +15 -0
  96. cloudsmith_cli/core/credentials/providers/cli_flag.py +24 -0
  97. cloudsmith_cli/core/credentials/providers/credentials_file.py +24 -0
  98. cloudsmith_cli/core/credentials/providers/env_var.py +24 -0
  99. cloudsmith_cli/core/credentials/providers/keyring_provider.py +59 -0
  100. cloudsmith_cli/core/credentials/providers/oidc_provider.py +115 -0
  101. cloudsmith_cli/core/download.py +594 -0
  102. cloudsmith_cli/core/keyring.py +171 -0
  103. cloudsmith_cli/core/mcp/__init__.py +0 -0
  104. cloudsmith_cli/core/mcp/data.py +17 -0
  105. cloudsmith_cli/core/mcp/server.py +786 -0
  106. cloudsmith_cli/core/pagination.py +131 -0
  107. cloudsmith_cli/core/ratelimits.py +88 -0
  108. cloudsmith_cli/core/rest.py +255 -0
  109. cloudsmith_cli/core/utils.py +95 -0
  110. cloudsmith_cli/core/version.py +20 -0
  111. cloudsmith_cli/credential_helpers/__init__.py +7 -0
  112. cloudsmith_cli/credential_helpers/backends.py +41 -0
  113. cloudsmith_cli/credential_helpers/common.py +111 -0
  114. cloudsmith_cli/credential_helpers/custom_domains.py +281 -0
  115. cloudsmith_cli/credential_helpers/docker/__init__.py +4 -0
  116. cloudsmith_cli/credential_helpers/docker/installer.py +349 -0
  117. cloudsmith_cli/credential_helpers/docker/runtime.py +117 -0
  118. cloudsmith_cli/credential_helpers/launchers.py +175 -0
  119. cloudsmith_cli/data/VERSION +1 -0
  120. cloudsmith_cli/data/config.ini +23 -0
  121. cloudsmith_cli/data/credentials.ini +14 -0
  122. cloudsmith_cli/templates/__init__.py +3 -0
  123. cloudsmith_cli/templates/auth_error.html +45 -0
  124. cloudsmith_cli/templates/auth_success.html +37 -0
  125. cloudsmith_cli-1.20.0.dist-info/METADATA +610 -0
  126. cloudsmith_cli-1.20.0.dist-info/RECORD +130 -0
  127. cloudsmith_cli-1.20.0.dist-info/WHEEL +5 -0
  128. cloudsmith_cli-1.20.0.dist-info/entry_points.txt +2 -0
  129. cloudsmith_cli-1.20.0.dist-info/licenses/LICENSE +201 -0
  130. cloudsmith_cli-1.20.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1323 @@
1
+ """CLI/Commands - Push packages."""
2
+
3
+ # pylint: disable=too-many-lines
4
+
5
+ import math
6
+ import os
7
+ import shlex
8
+ import time
9
+ from datetime import datetime
10
+
11
+ import click
12
+
13
+ from ...core import utils as core_utils
14
+ from ...core.api.exceptions import ApiException
15
+ from ...core.api.files import (
16
+ CHUNK_SIZE,
17
+ multi_part_upload_file,
18
+ request_file_upload,
19
+ upload_file as api_upload_file,
20
+ validate_request_file_upload,
21
+ )
22
+ from ...core.api.metadata import (
23
+ create_metadata as api_create_metadata,
24
+ validate_metadata as api_validate_metadata,
25
+ )
26
+ from ...core.api.packages import (
27
+ create_package as api_create_package,
28
+ get_package_formats,
29
+ get_package_status,
30
+ validate_create_package as api_validate_create_package,
31
+ )
32
+ from .. import command, decorators, utils, validators
33
+ from ..exceptions import handle_api_exceptions
34
+ from ..metadata_common import (
35
+ MetadataContentError,
36
+ ResolvedMetadata,
37
+ attach_metadata_options,
38
+ default_metadata_source_identity,
39
+ require_metadata_content_type,
40
+ resolve_metadata_content,
41
+ source_label_for,
42
+ )
43
+ from ..types import ExpandPath
44
+ from ..utils import maybe_spinner
45
+ from .main import main
46
+
47
+ #: Env var that lets CI/CD wrappers (e.g. GHA) opt out of hard-failing the
48
+ #: push when push-time metadata attachment fails. Defaults to ``error`` so an
49
+ #: invalid metadata content aborts the upload (design requirement: metadata
50
+ #: pushes must surface failures by default). Set to ``0`` or ``warn`` to
51
+ #: downgrade failures to a warning and let the package upload regardless.
52
+ #: Equivalent settings exist as a CLI flag (``--on-metadata-failure``) and a
53
+ #: ``metadata_failure_mode`` key in ``config.ini``; precedence at resolution
54
+ #: time is flag > env > config > default.
55
+ METADATA_FAILURE_MODE_ENV = "CLOUDSMITH_METADATA_FAILURE_MODE"
56
+ METADATA_FAILURE_MODE_WARN = {"0", "warn"}
57
+ #: Click option dest names for the push-time metadata flags. Used by the
58
+ #: push handler to split metadata flags off from the package-create payload
59
+ #: kwargs (the API client would otherwise reject the unknown keys).
60
+ METADATA_KWARG_NAMES = (
61
+ "metadata_content_file",
62
+ "metadata_content",
63
+ "metadata_content_type",
64
+ "metadata_source_identity",
65
+ )
66
+ #: Click dest name for ``--on-metadata-failure``. Popped off the push kwargs
67
+ #: separately from the metadata payload kwargs so it does not leak into the
68
+ #: package-create API call.
69
+ METADATA_FAILURE_MODE_KWARG = "cli_metadata_failure_mode"
70
+
71
+
72
+ def _metadata_failure_is_warn(opts=None):
73
+ """Return True iff metadata failures should be downgraded to a warning.
74
+
75
+ Single source of truth for the failure-mode lookup so the validation and
76
+ attach paths cannot drift. Resolves in precedence order:
77
+
78
+ 1. ``--on-metadata-failure`` flag (``opts.cli_metadata_failure_mode``)
79
+ 2. ``$CLOUDSMITH_METADATA_FAILURE_MODE`` env var
80
+ 3. ``metadata_failure_mode`` config key (``opts.metadata_failure_mode``)
81
+ 4. Default — ``error``
82
+
83
+ ``opts`` is optional so direct callers without a CLI context (legacy
84
+ tests) keep working on the env-var path.
85
+ """
86
+ candidates = (
87
+ getattr(opts, "cli_metadata_failure_mode", None) if opts is not None else None,
88
+ os.environ.get(METADATA_FAILURE_MODE_ENV),
89
+ getattr(opts, "metadata_failure_mode", None) if opts is not None else None,
90
+ )
91
+ for value in candidates:
92
+ if value is None:
93
+ continue
94
+ return str(value).strip().lower() in METADATA_FAILURE_MODE_WARN
95
+ return False
96
+
97
+
98
+ def _metadata_content_failure_info(exc):
99
+ info = {
100
+ "status": "content_invalid",
101
+ "error": str(exc),
102
+ }
103
+ if getattr(exc, "source_label", None):
104
+ info["source"] = exc.source_label
105
+ return info
106
+
107
+
108
+ def _warn_metadata_failure(failure_info):
109
+ click.secho(
110
+ "Metadata content is invalid: %(error)s" % failure_info,
111
+ fg="yellow",
112
+ err=True,
113
+ )
114
+ click.secho(
115
+ "Package upload will continue without metadata. Pass "
116
+ "``--on-metadata-failure error`` (or set the "
117
+ f"``metadata_failure_mode`` config key / ``${METADATA_FAILURE_MODE_ENV}`` "
118
+ "env var to ``error``) to fail the push instead.",
119
+ fg="yellow",
120
+ err=True,
121
+ )
122
+
123
+
124
+ def resolve_push_metadata_options(
125
+ *,
126
+ metadata_content_file=None,
127
+ metadata_content=None,
128
+ metadata_content_type=None,
129
+ metadata_source_identity=None,
130
+ opts=None,
131
+ ):
132
+ """Resolve push-time metadata flags once before package upload loops."""
133
+ if metadata_content_file is not None and metadata_content is not None:
134
+ raise click.UsageError(
135
+ "--metadata-content-file and --metadata-content are mutually exclusive."
136
+ )
137
+
138
+ metadata_provided = (
139
+ metadata_content_file is not None or metadata_content is not None
140
+ )
141
+ if not metadata_provided:
142
+ if metadata_content_type or metadata_source_identity:
143
+ raise click.UsageError(
144
+ "Add --metadata-content-file or --metadata-content when using "
145
+ "--metadata-content-type or --metadata-source-identity."
146
+ )
147
+ return ResolvedMetadata(provided=False, content=None), None
148
+
149
+ require_metadata_content_type(
150
+ content_type=metadata_content_type,
151
+ content_provided=True,
152
+ option_name="--metadata-content-type",
153
+ )
154
+
155
+ try:
156
+ metadata = resolve_metadata_content(
157
+ content_file=metadata_content_file,
158
+ inline_content=metadata_content,
159
+ required=True,
160
+ file_option_name="--metadata-content-file",
161
+ content_option_name="--metadata-content",
162
+ )
163
+ except MetadataContentError as exc:
164
+ if not _metadata_failure_is_warn(opts):
165
+ raise
166
+
167
+ source_label = exc.source_label or source_label_for(metadata_content_file)
168
+ metadata = ResolvedMetadata(
169
+ provided=True,
170
+ content=None,
171
+ content_type=metadata_content_type,
172
+ source_identity=(
173
+ metadata_source_identity or default_metadata_source_identity()
174
+ ),
175
+ content_file=metadata_content_file,
176
+ source_label=source_label,
177
+ )
178
+ return metadata, _metadata_content_failure_info(exc)
179
+
180
+ return (
181
+ attach_metadata_options(
182
+ metadata,
183
+ content_type=metadata_content_type,
184
+ source_identity=metadata_source_identity,
185
+ ),
186
+ None,
187
+ )
188
+
189
+
190
+ def _handle_metadata_api_exception(ctx, opts, exc, context_msg, skip_errors=False):
191
+ """Route metadata API failures through the standard API exception handler."""
192
+ with handle_api_exceptions(
193
+ ctx,
194
+ opts=opts,
195
+ context_msg=context_msg,
196
+ reraise_on_error=skip_errors,
197
+ ):
198
+ raise exc
199
+
200
+
201
+ def _print_metadata_retry_hint(
202
+ opts,
203
+ owner,
204
+ repo,
205
+ slug,
206
+ metadata_content_file,
207
+ cli_content_type,
208
+ cli_source_identity,
209
+ reason="attach_failed",
210
+ ):
211
+ """Print a copy-paste ``cloudsmith metadata add`` line for failed attaches.
212
+
213
+ Skipped in JSON output mode — the envelope already carries slugs and
214
+ failure context, so CI can reconstruct the command without text parsing.
215
+ Skipped for inline ``--metadata-content`` payloads, since they are not
216
+ safely reproducible as a single shell line (multi-line / quoting / size).
217
+
218
+ ``reason`` distinguishes a transient/policy attach failure (``"attach_failed"``,
219
+ where retrying the same payload may succeed) from a pre-validation
220
+ failure (``"validation_failed"``, where the payload itself is broken and
221
+ must be fixed first). Wording changes accordingly.
222
+ """
223
+ if utils.should_use_stderr(opts):
224
+ return
225
+ # Skip when no file path (inline ``--metadata-content``) or stdin ("-"),
226
+ # since neither is reproducible as a single shell line.
227
+ if not metadata_content_file or metadata_content_file == "-":
228
+ return
229
+
230
+ parts = [
231
+ f"cloudsmith metadata add {shlex.quote(f'{owner}/{repo}/{slug}')}",
232
+ f" --file {shlex.quote(metadata_content_file)}",
233
+ ]
234
+ if cli_source_identity:
235
+ parts.append(f" --source-identity {shlex.quote(cli_source_identity)}")
236
+ if cli_content_type:
237
+ parts.append(f" --content-type {shlex.quote(cli_content_type)}")
238
+
239
+ if reason == "validation_failed":
240
+ heading = "Fix the metadata content, then run:"
241
+ else:
242
+ heading = "Run this command to attach metadata:"
243
+
244
+ click.echo(err=True)
245
+ click.secho(heading, fg="yellow", err=True)
246
+ click.secho(" \\\n".join(parts), fg="yellow", err=True)
247
+
248
+
249
+ def validate_metadata_payload(
250
+ ctx,
251
+ opts,
252
+ content,
253
+ content_type,
254
+ source=None,
255
+ skip_errors=False,
256
+ ):
257
+ """Validate metadata against ``POST /v2/metadata/validate/`` pre-upload.
258
+
259
+ Runs before any file upload so a malformed payload does not produce an
260
+ orphan package. Returns ``None`` on success. Routes validation failure
261
+ through ``handle_api_exceptions`` by default so the push aborts before
262
+ any S3 traffic.
263
+ When ``$CLOUDSMITH_METADATA_FAILURE_MODE`` is ``warn``/``0`` it returns a
264
+ metadata-info dict instead so the caller can skip attachment but continue
265
+ the push.
266
+
267
+ ``source`` is a human-readable label for the payload origin (file
268
+ basename, ``"stdin"``, ``"inline"``) — surfaced in the progress line so
269
+ users know which source is being validated.
270
+ """
271
+ # pylint: disable=too-many-arguments
272
+ use_stderr = utils.should_use_stderr(opts)
273
+
274
+ if source:
275
+ message = "Validating metadata content from {source} ... ".format(
276
+ source=click.style(source, bold=True),
277
+ )
278
+ else:
279
+ message = "Validating metadata content ... "
280
+
281
+ click.echo(
282
+ message,
283
+ nl=False,
284
+ err=use_stderr,
285
+ )
286
+
287
+ try:
288
+ with maybe_spinner(opts):
289
+ api_validate_metadata(content=content, content_type=content_type)
290
+ except ApiException as exc:
291
+ http_status = getattr(exc, "status", None)
292
+ detail = (
293
+ getattr(exc, "detail", None)
294
+ or getattr(exc, "status_description", None)
295
+ or str(exc)
296
+ or "unknown error"
297
+ )
298
+
299
+ click.secho("FAILED", fg="red", err=use_stderr)
300
+
301
+ if http_status is not None:
302
+ message = (
303
+ f"Metadata content failed validation (HTTP {http_status}): {detail}"
304
+ )
305
+ else:
306
+ message = f"Metadata content failed validation: {detail}"
307
+ failure_info = {
308
+ "status": "validation_failed",
309
+ "http_status": http_status,
310
+ "error": detail,
311
+ }
312
+
313
+ if not _metadata_failure_is_warn(opts):
314
+ opts.push_metadata_info = failure_info
315
+ _handle_metadata_api_exception(
316
+ ctx,
317
+ opts,
318
+ exc,
319
+ context_msg=message,
320
+ skip_errors=skip_errors,
321
+ )
322
+
323
+ click.secho(message, fg="yellow", err=True)
324
+ click.secho(
325
+ "Package upload will continue without metadata. Pass "
326
+ "``--on-metadata-failure error`` (or set the "
327
+ f"``metadata_failure_mode`` config key / ``${METADATA_FAILURE_MODE_ENV}`` "
328
+ "env var to ``error``) to fail the push instead.",
329
+ fg="yellow",
330
+ err=True,
331
+ )
332
+ return failure_info
333
+
334
+ click.secho("OK", fg="green", err=use_stderr)
335
+ return None
336
+
337
+
338
+ def attach_metadata_to_package(
339
+ ctx,
340
+ opts,
341
+ owner,
342
+ repo,
343
+ slug,
344
+ slug_perm,
345
+ content,
346
+ content_type,
347
+ source_identity,
348
+ skip_errors=False,
349
+ metadata_content_file=None,
350
+ cli_content_type=None,
351
+ cli_source_identity=None,
352
+ ):
353
+ """Attach a metadata entry to a freshly-created package.
354
+
355
+ Failure is fatal by default: the API error is reported and the push
356
+ exits non-zero so CI/CD pipelines surface broken SBOM/BuildInfo uploads
357
+ instead of silently shipping a package without metadata. Wrappers that
358
+ explicitly want the legacy non-fatal behaviour can set
359
+ ``$CLOUDSMITH_METADATA_FAILURE_MODE`` to ``warn`` (or ``0``).
360
+ """
361
+ # pylint: disable=too-many-arguments
362
+ use_stderr = utils.should_use_stderr(opts)
363
+
364
+ click.echo(
365
+ "Attaching metadata to package %(slug)s ... "
366
+ % {"slug": click.style(slug_perm, bold=True)},
367
+ nl=False,
368
+ err=use_stderr,
369
+ )
370
+
371
+ try:
372
+ with maybe_spinner(opts):
373
+ entry = api_create_metadata(
374
+ slug_perm,
375
+ content=content,
376
+ content_type=content_type,
377
+ source_identity=source_identity,
378
+ )
379
+ except ApiException as exc:
380
+ click.secho("FAILED", fg="red", err=use_stderr)
381
+
382
+ http_status = getattr(exc, "status", None)
383
+ detail = (
384
+ getattr(exc, "detail", None)
385
+ or getattr(exc, "status_description", None)
386
+ or str(exc)
387
+ or "unknown error"
388
+ )
389
+ if http_status is not None:
390
+ message = (
391
+ f"Could not attach metadata to package {slug_perm} "
392
+ f"(HTTP {http_status}): {detail}"
393
+ )
394
+ else:
395
+ message = f"Could not attach metadata to package {slug_perm}: {detail}"
396
+ failure_info = {
397
+ "status": "attach_failed",
398
+ "http_status": http_status,
399
+ "error": detail,
400
+ }
401
+
402
+ hint_kwargs = {
403
+ "opts": opts,
404
+ "owner": owner,
405
+ "repo": repo,
406
+ "slug": slug,
407
+ "metadata_content_file": metadata_content_file,
408
+ "cli_content_type": cli_content_type,
409
+ "cli_source_identity": cli_source_identity,
410
+ }
411
+
412
+ if not _metadata_failure_is_warn(opts):
413
+ opts.push_metadata_info = failure_info
414
+ _print_metadata_retry_hint(**hint_kwargs)
415
+ _handle_metadata_api_exception(
416
+ ctx,
417
+ opts,
418
+ exc,
419
+ context_msg=message,
420
+ skip_errors=skip_errors,
421
+ )
422
+
423
+ click.secho(message, fg="yellow", err=True)
424
+ click.secho(
425
+ "Package upload completed without metadata. Pass "
426
+ "``--on-metadata-failure error`` (or set the "
427
+ f"``metadata_failure_mode`` config key / ``${METADATA_FAILURE_MODE_ENV}`` "
428
+ "env var to ``error``) to fail the push instead. "
429
+ "Accepted warn-mode values are ``warn`` and ``0``.",
430
+ fg="yellow",
431
+ err=True,
432
+ )
433
+ _print_metadata_retry_hint(**hint_kwargs)
434
+ return failure_info
435
+
436
+ click.secho("OK", fg="green", err=use_stderr)
437
+
438
+ metadata_slug_perm = (entry or {}).get("slug_perm") or "?"
439
+ package_path = "{owner}/{repo}/{slug}".format(
440
+ owner=click.style(owner, fg="magenta"),
441
+ repo=click.style(repo, fg="magenta"),
442
+ slug=click.style(slug, fg="green"),
443
+ )
444
+ click.echo(
445
+ "Metadata attached: %(path)s/%(metadata)s"
446
+ % {
447
+ "path": package_path,
448
+ "metadata": click.style(metadata_slug_perm, bold=True),
449
+ },
450
+ err=use_stderr,
451
+ )
452
+
453
+ return {
454
+ "status": "attached",
455
+ "slug_perm": (entry or {}).get("slug_perm"),
456
+ "entry": entry or None,
457
+ }
458
+
459
+
460
+ def validate_upload_file(ctx, opts, owner, repo, filepath, skip_errors):
461
+ """Validate parameters for requesting a file upload."""
462
+ filename = click.format_filename(filepath)
463
+ basename = os.path.basename(filename)
464
+
465
+ use_stderr = utils.should_use_stderr(opts)
466
+
467
+ click.echo(
468
+ "Checking %(filename)s file upload parameters ... "
469
+ % {"filename": click.style(basename, bold=True)},
470
+ nl=False,
471
+ err=use_stderr,
472
+ )
473
+
474
+ context_msg = "Failed to validate upload parameters!"
475
+ with handle_api_exceptions(
476
+ ctx, opts=opts, context_msg=context_msg, reraise_on_error=skip_errors
477
+ ):
478
+ with maybe_spinner(opts):
479
+ md5_checksum = validate_request_file_upload(
480
+ owner=owner, repo=repo, filepath=filename
481
+ )
482
+
483
+ click.secho("OK", fg="green", err=use_stderr)
484
+
485
+ return md5_checksum
486
+
487
+
488
+ def upload_file(ctx, opts, owner, repo, filepath, skip_errors, md5_checksum):
489
+ """Upload a package file via the API."""
490
+ filename = click.format_filename(filepath)
491
+ basename = os.path.basename(filename)
492
+
493
+ filesize = core_utils.get_file_size(filepath=filename)
494
+ projected_chunks = math.floor(filesize / CHUNK_SIZE) + 1
495
+ is_multi_part_upload = projected_chunks > 1
496
+
497
+ use_stderr = utils.should_use_stderr(opts)
498
+
499
+ click.echo(
500
+ "Requesting file upload for %(filename)s ... "
501
+ % {"filename": click.style(basename, bold=True)},
502
+ nl=False,
503
+ err=use_stderr,
504
+ )
505
+
506
+ context_msg = "Failed to request file upload!"
507
+ with handle_api_exceptions(
508
+ ctx, opts=opts, context_msg=context_msg, reraise_on_error=skip_errors
509
+ ):
510
+ with maybe_spinner(opts):
511
+ identifier, upload_url, upload_fields = request_file_upload(
512
+ owner=owner,
513
+ repo=repo,
514
+ filepath=filename,
515
+ md5_checksum=md5_checksum,
516
+ is_multi_part_upload=is_multi_part_upload,
517
+ )
518
+
519
+ click.secho("OK", fg="green", err=use_stderr)
520
+
521
+ context_msg = "Failed to upload file!"
522
+ with handle_api_exceptions(ctx, opts=opts, context_msg=context_msg):
523
+ label = f"Uploading {click.style(basename, bold=True)}:"
524
+
525
+ if not is_multi_part_upload:
526
+ if use_stderr:
527
+ api_upload_file(
528
+ upload_url=upload_url,
529
+ upload_fields=upload_fields,
530
+ filepath=filename,
531
+ )
532
+ else:
533
+ # We can upload the whole file in one go.
534
+ with click.progressbar(
535
+ length=filesize,
536
+ label=label,
537
+ fill_char=click.style("#", fg="green"),
538
+ empty_char=click.style("-", fg="red"),
539
+ ) as pb:
540
+
541
+ def progress_callback(monitor):
542
+ pb.update(monitor.bytes_read)
543
+
544
+ api_upload_file(
545
+ upload_url=upload_url,
546
+ upload_fields=upload_fields,
547
+ filepath=filename,
548
+ callback=progress_callback,
549
+ )
550
+ else:
551
+ if use_stderr:
552
+ multi_part_upload_file(
553
+ opts=opts,
554
+ upload_url=upload_url,
555
+ owner=owner,
556
+ repo=repo,
557
+ filepath=filename,
558
+ upload_id=identifier,
559
+ callback=lambda: None,
560
+ )
561
+ else:
562
+ # The file is sufficiently large that we need to upload in chunks.
563
+ with click.progressbar(
564
+ length=projected_chunks,
565
+ label=label,
566
+ fill_char=click.style("#", fg="green"),
567
+ empty_char=click.style("-", fg="red"),
568
+ ) as pb:
569
+
570
+ def progress_callback():
571
+ pb.update(1)
572
+
573
+ multi_part_upload_file(
574
+ opts=opts,
575
+ upload_url=upload_url,
576
+ owner=owner,
577
+ repo=repo,
578
+ filepath=filename,
579
+ callback=progress_callback,
580
+ upload_id=identifier,
581
+ )
582
+
583
+ return identifier
584
+
585
+
586
+ def validate_create_package(
587
+ ctx, opts, owner, repo, package_type, skip_errors, **kwargs
588
+ ):
589
+ """Check new package parameters via the API."""
590
+ use_stderr = utils.should_use_stderr(opts)
591
+
592
+ click.echo(
593
+ "Checking %(package_type)s package upload parameters ... "
594
+ % {"package_type": click.style(package_type, bold=True)},
595
+ nl=False,
596
+ err=use_stderr,
597
+ )
598
+
599
+ context_msg = "Failed to validate upload parameters!"
600
+ with handle_api_exceptions(
601
+ ctx, opts=opts, context_msg=context_msg, reraise_on_error=skip_errors
602
+ ):
603
+ with maybe_spinner(opts):
604
+ api_validate_create_package(
605
+ package_format=package_type, owner=owner, repo=repo, **kwargs
606
+ )
607
+
608
+ click.secho("OK", fg="green", err=use_stderr)
609
+ return True
610
+
611
+
612
+ def create_package(ctx, opts, owner, repo, package_type, skip_errors, **kwargs):
613
+ """Create a new package via the API."""
614
+ use_stderr = utils.should_use_stderr(opts)
615
+
616
+ click.echo(
617
+ "Creating a new %(package_type)s package ... "
618
+ % {"package_type": click.style(package_type, bold=True)},
619
+ nl=False,
620
+ err=use_stderr,
621
+ )
622
+
623
+ context_msg = "Failed to create package!"
624
+ with handle_api_exceptions(
625
+ ctx, opts=opts, context_msg=context_msg, reraise_on_error=skip_errors
626
+ ):
627
+ with maybe_spinner(opts):
628
+ slug_perm, slug = api_create_package(
629
+ package_format=package_type, owner=owner, repo=repo, **kwargs
630
+ )
631
+
632
+ click.secho("OK", fg="green", err=use_stderr)
633
+
634
+ click.echo(
635
+ "Created: %(owner)s/%(repo)s/%(slug)s (%(slug_perm)s)"
636
+ % {
637
+ "owner": click.style(owner, fg="magenta"),
638
+ "repo": click.style(repo, fg="magenta"),
639
+ "slug": click.style(slug, fg="green"),
640
+ "slug_perm": click.style(slug_perm, bold=True),
641
+ },
642
+ err=use_stderr,
643
+ )
644
+
645
+ return slug_perm, slug
646
+
647
+
648
+ def wait_for_package_sync(
649
+ ctx, opts, owner, repo, slug, wait_interval, skip_errors, attempts=3
650
+ ):
651
+ """Wait for a package to synchronise (or fail)."""
652
+ # pylint: disable=too-many-locals
653
+ use_stderr = utils.should_use_stderr(opts)
654
+
655
+ attempts -= 1
656
+ click.echo(err=use_stderr)
657
+ label = f"Synchronising {click.style(slug, fg='green')}:"
658
+
659
+ status_str = "Waiting"
660
+ stage_str = None
661
+
662
+ def display_status(current):
663
+ """Display current sync status."""
664
+ # pylint: disable=unused-argument
665
+ if not stage_str or "Unknown" in stage_str:
666
+ return status_str
667
+ return click.style(
668
+ f"{status_str} / {stage_str}",
669
+ fg="cyan",
670
+ )
671
+
672
+ start = datetime.now()
673
+ context_msg = "Failed to synchronise file!"
674
+ with handle_api_exceptions(
675
+ ctx, opts=opts, context_msg=context_msg, reraise_on_error=skip_errors
676
+ ):
677
+ left = 100
678
+ last_progress = 0
679
+ total_wait_interval = max(1.0, wait_interval)
680
+ first = True
681
+
682
+ if use_stderr:
683
+ # When using stderr for logs, avoid an interactive progress bar and just poll for status.
684
+ while True:
685
+ res = get_package_status(owner, repo, slug)
686
+ ok, failed, _, _, _, _ = res
687
+ if ok or failed:
688
+ break
689
+
690
+ # Sleep if we are going to loop again
691
+ if not first:
692
+ time.sleep(total_wait_interval)
693
+ total_wait_interval = min(
694
+ 300.0, total_wait_interval + wait_interval
695
+ )
696
+ first = False
697
+
698
+ else:
699
+ with click.progressbar(
700
+ length=left,
701
+ label=label,
702
+ fill_char=click.style("#", fg="green"),
703
+ empty_char=click.style("-", fg="red"),
704
+ item_show_func=display_status,
705
+ ) as pb:
706
+ while True:
707
+ res = get_package_status(owner, repo, slug)
708
+ ok, failed, progress, status_str, stage_str, reason = res
709
+ progress = max(1, progress)
710
+ delta = progress - last_progress
711
+ pb.update(delta)
712
+ if delta > 0:
713
+ last_progress = progress
714
+ left -= delta
715
+ if ok or failed:
716
+ break
717
+ if first:
718
+ first = False
719
+ else:
720
+ # Sleep, but only after the first status call
721
+ time.sleep(total_wait_interval)
722
+ total_wait_interval = min(
723
+ 300.0, total_wait_interval + wait_interval
724
+ )
725
+
726
+ if left > 0:
727
+ pb.update(left)
728
+
729
+ end = datetime.now()
730
+ seconds = (end - start).total_seconds()
731
+
732
+ click.echo(err=use_stderr)
733
+
734
+ if ok:
735
+ click.secho(
736
+ "Package synchronised successfully in %(seconds)s second(s)!"
737
+ % {"seconds": click.style(str(seconds), bold=True)},
738
+ fg="green",
739
+ err=use_stderr,
740
+ )
741
+ return
742
+
743
+ click.secho(
744
+ "Package failed to synchronise in %(seconds)s during stage: %(stage)s"
745
+ % {
746
+ "seconds": click.style(str(seconds), bold=True),
747
+ "stage": click.style(stage_str or "Unknown", fg="yellow"),
748
+ },
749
+ fg="red",
750
+ err=use_stderr,
751
+ )
752
+
753
+ if reason:
754
+ click.secho(
755
+ f"Reason given: {click.style(reason, fg='yellow')}",
756
+ fg="red",
757
+ err=use_stderr,
758
+ )
759
+
760
+ # pylint: disable=fixme
761
+ # FIXME: The API should communicate "no retry" fails
762
+ if "package should be deleted" in reason and attempts > 1:
763
+ click.secho(
764
+ "This is not recoverable, so stopping further attempts!",
765
+ fg="red",
766
+ err=use_stderr,
767
+ )
768
+ click.echo(err=use_stderr)
769
+ attempts = 0
770
+
771
+ if attempts + 1 > 0:
772
+ # Show attempts upto and including zero attempts left
773
+ click.secho(
774
+ "Attempts left: %(left)s (%(action)s)"
775
+ % {
776
+ "left": click.style(str(attempts), bold=True),
777
+ "action": "trying again" if attempts > 0 else "giving up",
778
+ },
779
+ err=use_stderr,
780
+ )
781
+ click.echo(err=use_stderr)
782
+
783
+ if attempts > 0:
784
+ from .resync import resync_package
785
+
786
+ resync_package(
787
+ ctx=ctx,
788
+ opts=opts,
789
+ owner=owner,
790
+ repo=repo,
791
+ slug=slug,
792
+ skip_errors=skip_errors,
793
+ )
794
+
795
+ wait_for_package_sync(
796
+ ctx=ctx,
797
+ opts=opts,
798
+ owner=owner,
799
+ repo=repo,
800
+ slug=slug,
801
+ wait_interval=wait_interval,
802
+ skip_errors=skip_errors,
803
+ attempts=attempts,
804
+ )
805
+ else:
806
+ ctx.exit(1)
807
+
808
+
809
+ def upload_files_and_create_package(
810
+ ctx,
811
+ opts,
812
+ package_type,
813
+ owner_repo,
814
+ dry_run,
815
+ no_wait_for_sync,
816
+ wait_interval,
817
+ skip_errors,
818
+ sync_attempts,
819
+ metadata_content_file=None,
820
+ metadata_content=None,
821
+ metadata_content_type=None,
822
+ metadata_source_identity=None,
823
+ metadata=None,
824
+ metadata_failure_info=None,
825
+ **kwargs,
826
+ ):
827
+ """Upload package files and create a new package."""
828
+ # pylint: disable=unused-argument,too-many-arguments,too-many-locals
829
+ owner, repo = owner_repo
830
+
831
+ # Reset push-time metadata state for this call. ``handle_api_exceptions``
832
+ # consults this attribute to surface validation/attach context in the
833
+ # JSON error envelope; an unset value would leak prior state on retries.
834
+ opts.push_metadata_info = None
835
+
836
+ # 0. Resolve push-time metadata before package work. The dynamic command
837
+ # handler resolves once for multi-file pushes so stdin is consumed once;
838
+ # direct callers can still pass the metadata flags for test coverage.
839
+ if metadata is None:
840
+ metadata, metadata_failure_info = resolve_push_metadata_options(
841
+ metadata_content_file=metadata_content_file,
842
+ metadata_content=metadata_content,
843
+ metadata_content_type=metadata_content_type,
844
+ metadata_source_identity=metadata_source_identity,
845
+ opts=opts,
846
+ )
847
+
848
+ should_attach_metadata = metadata.provided and metadata_failure_info is None
849
+
850
+ # Publish a warn-mode resolve failure on ``opts`` BEFORE the package
851
+ # validation call so the JSON error envelope still carries the metadata
852
+ # context if ``validate_create_package`` aborts the push (e.g. typo in
853
+ # --name/--version, bad repo, auth failure).
854
+ if metadata_failure_info is not None:
855
+ opts.push_metadata_info = metadata_failure_info
856
+ _warn_metadata_failure(metadata_failure_info)
857
+
858
+ # 1. Validate package create parameters. This runs before the metadata
859
+ # pre-validation so a typo in --name/--version fails fast without
860
+ # burning a /v2/metadata/validate/ round-trip first.
861
+ validate_create_package(
862
+ ctx=ctx,
863
+ opts=opts,
864
+ owner=owner,
865
+ repo=repo,
866
+ package_type=package_type,
867
+ skip_errors=skip_errors,
868
+ **kwargs,
869
+ )
870
+
871
+ # 1b. Pre-validate metadata against the server-side schema endpoint so a
872
+ # malformed payload cannot produce an orphan package (the upload would
873
+ # succeed and only the attach would fail).
874
+ if metadata_failure_info is None and should_attach_metadata:
875
+ validation_failure = validate_metadata_payload(
876
+ ctx=ctx,
877
+ opts=opts,
878
+ content=metadata.content,
879
+ content_type=metadata.content_type,
880
+ source=metadata.source_label,
881
+ skip_errors=skip_errors,
882
+ )
883
+ if validation_failure is not None:
884
+ # Warn-mode validation failure: keep the push, drop the attach.
885
+ should_attach_metadata = False
886
+ opts.push_metadata_info = validation_failure
887
+
888
+ # 2. Validate file upload parameters
889
+ md5_checksums = {}
890
+ for k, v in kwargs.items():
891
+ if not v:
892
+ continue
893
+
894
+ # Handle a single file
895
+ if k.endswith("_file"):
896
+ md5_checksums[k] = validate_upload_file(
897
+ ctx=ctx,
898
+ opts=opts,
899
+ owner=owner,
900
+ repo=repo,
901
+ filepath=v,
902
+ skip_errors=skip_errors,
903
+ )
904
+
905
+ # Check if the key is "extra_files" (to handle multiple files)
906
+ if k == "extra_files" and isinstance(v, list):
907
+ md5_checksums[k] = [
908
+ validate_upload_file(
909
+ ctx=ctx,
910
+ opts=opts,
911
+ owner=owner,
912
+ repo=repo,
913
+ filepath=file,
914
+ skip_errors=skip_errors,
915
+ )
916
+ for file in v
917
+ ]
918
+
919
+ if dry_run:
920
+ click.echo()
921
+ click.secho("You requested a dry run so skipping upload.", fg="yellow")
922
+ return
923
+
924
+ # 3. Upload any arguments that look like files
925
+ for k, v in kwargs.items():
926
+ if not v:
927
+ continue
928
+
929
+ # Handle a single file
930
+ if k.endswith("_file"):
931
+ kwargs[k] = upload_file(
932
+ ctx=ctx,
933
+ opts=opts,
934
+ owner=owner,
935
+ repo=repo,
936
+ filepath=v,
937
+ skip_errors=skip_errors,
938
+ md5_checksum=md5_checksums[k],
939
+ )
940
+
941
+ # Check if the key is "extra_files" (to handle multiple files)
942
+ if k == "extra_files" and isinstance(v, list):
943
+ kwargs[k] = [
944
+ upload_file(
945
+ ctx=ctx,
946
+ opts=opts,
947
+ owner=owner,
948
+ repo=repo,
949
+ filepath=file,
950
+ skip_errors=skip_errors,
951
+ md5_checksum=md5_checksums[k][idx],
952
+ )
953
+ for idx, file in enumerate(v)
954
+ ]
955
+
956
+ # 4. Create the package with package files and additional arguments
957
+ slug_perm, slug = create_package(
958
+ ctx=ctx,
959
+ opts=opts,
960
+ owner=owner,
961
+ repo=repo,
962
+ package_type=package_type,
963
+ skip_errors=skip_errors,
964
+ **kwargs,
965
+ )
966
+
967
+ # 5. Attach push-time metadata, if provided AND it passed validation.
968
+ # Warn-mode metadata failures leave opts.push_metadata_info populated
969
+ # and should_attach_metadata=False; surface a retry hint now that we
970
+ # have the package slug. Skipped in JSON mode and for inline payloads.
971
+ if should_attach_metadata:
972
+ opts.push_metadata_info = attach_metadata_to_package(
973
+ ctx=ctx,
974
+ opts=opts,
975
+ owner=owner,
976
+ repo=repo,
977
+ slug=slug,
978
+ slug_perm=slug_perm,
979
+ content=metadata.content,
980
+ content_type=metadata.content_type,
981
+ source_identity=metadata.source_identity,
982
+ skip_errors=skip_errors,
983
+ metadata_content_file=metadata.content_file,
984
+ cli_content_type=metadata.content_type,
985
+ cli_source_identity=metadata_source_identity,
986
+ )
987
+ elif metadata.provided:
988
+ # Metadata resolution/validation already warned the user; the payload
989
+ # is broken so a straight retry would fail. Use the "fix first" hint.
990
+ _print_metadata_retry_hint(
991
+ opts=opts,
992
+ owner=owner,
993
+ repo=repo,
994
+ slug=slug,
995
+ metadata_content_file=metadata.content_file,
996
+ cli_content_type=metadata.content_type,
997
+ cli_source_identity=metadata_source_identity,
998
+ reason="validation_failed",
999
+ )
1000
+
1001
+ if no_wait_for_sync:
1002
+ return slug_perm, slug
1003
+
1004
+ # 6. (optionally) Wait for the package to synchronise
1005
+ wait_for_package_sync(
1006
+ ctx=ctx,
1007
+ opts=opts,
1008
+ owner=owner,
1009
+ repo=repo,
1010
+ slug=slug,
1011
+ wait_interval=wait_interval,
1012
+ skip_errors=skip_errors,
1013
+ attempts=sync_attempts,
1014
+ )
1015
+
1016
+ return slug_perm, slug
1017
+
1018
+
1019
+ def create_push_handlers(): # noqa: C901
1020
+ """Create a handler for upload per package format."""
1021
+ # pylint: disable=fixme
1022
+ # HACK: hacky territory - Dynamically generate a handler for each of the
1023
+ # package formats, until we have slightly more clever 'guess type'
1024
+ # handling. :-)
1025
+ handlers = create_push_handlers.handlers = {}
1026
+ context = create_push_handlers.context = get_package_formats()
1027
+
1028
+ for key, parameters in context.items():
1029
+ kwargs = parameters.copy()
1030
+
1031
+ # Remove standard arguments
1032
+ kwargs.pop("package_file")
1033
+ if "distribution" in parameters:
1034
+ has_distribution_param = True
1035
+ kwargs.pop("distribution")
1036
+ else:
1037
+ has_distribution_param = False
1038
+
1039
+ has_additional_params = len(kwargs) > 0
1040
+
1041
+ help_text = f"""
1042
+ Push/upload a new {key.capitalize()} package upstream.
1043
+ """
1044
+
1045
+ if has_additional_params:
1046
+ help_text += """
1047
+
1048
+ PACKAGE_FILE: The main file to create the package from.
1049
+ """
1050
+ else:
1051
+ help_text += """
1052
+
1053
+ PACKAGE_FILE: Any number of files to create packages from. Each
1054
+ file will result in a separate package.
1055
+ """
1056
+
1057
+ if has_distribution_param:
1058
+ target_metavar = "OWNER/REPO/DISTRO/RELEASE"
1059
+ target_callback = validators.validate_owner_repo_distro
1060
+ help_text += """
1061
+
1062
+ OWNER/REPO/DISTRO/RELEASE: Specify the OWNER namespace (i.e.
1063
+ user or org), the REPO name where the package file will be uploaded
1064
+ to, and the DISTRO and RELEASE the package is for. All separated by
1065
+ a slash.
1066
+
1067
+ Example: 'your-org/awesome-repo/ubuntu/xenial'.
1068
+ """
1069
+ else:
1070
+ target_metavar = "OWNER/REPO"
1071
+ target_callback = validators.validate_owner_repo
1072
+ help_text += """
1073
+
1074
+ OWNER/REPO: Specify the OWNER namespace (i.e. user or org), and the
1075
+ REPO name where the package file will be uploaded to. All separated
1076
+ by a slash.
1077
+
1078
+ Example: 'your-org/awesome-repo'.
1079
+ """
1080
+
1081
+ @push.command(name=key, help=help_text)
1082
+ @decorators.common_cli_config_options
1083
+ @decorators.common_cli_output_options
1084
+ @decorators.common_package_action_options
1085
+ @decorators.common_api_auth_options
1086
+ @decorators.initialise_api
1087
+ @click.argument("owner_repo", metavar=target_metavar, callback=target_callback)
1088
+ @click.argument(
1089
+ "package_file",
1090
+ nargs=1 if has_additional_params else -1,
1091
+ type=ExpandPath(
1092
+ dir_okay=False, exists=True, writable=False, resolve_path=True
1093
+ ),
1094
+ )
1095
+ @click.option(
1096
+ "-n",
1097
+ "--dry-run",
1098
+ default=False,
1099
+ is_flag=True,
1100
+ help="Execute in dry run mode (don't upload anything.)",
1101
+ )
1102
+ @click.option(
1103
+ "--metadata-content-file",
1104
+ "metadata_content_file",
1105
+ type=click.Path(
1106
+ exists=True,
1107
+ dir_okay=False,
1108
+ readable=True,
1109
+ resolve_path=True,
1110
+ allow_dash=True,
1111
+ ),
1112
+ default=None,
1113
+ help=(
1114
+ "Read metadata content from a JSON file "
1115
+ "(for example, SBOM or BuildInfo). Use '-' for stdin. "
1116
+ "Content must be a JSON object. "
1117
+ "Mutually exclusive with --metadata-content. "
1118
+ "Metadata failures abort the push by default; pass "
1119
+ "--on-metadata-failure warn (or set the "
1120
+ "metadata_failure_mode config key / "
1121
+ "$CLOUDSMITH_METADATA_FAILURE_MODE env var to warn) to "
1122
+ "downgrade to a warning and keep the package upload."
1123
+ ),
1124
+ )
1125
+ @click.option(
1126
+ "--metadata-content",
1127
+ "metadata_content",
1128
+ default=None,
1129
+ help=(
1130
+ "Set metadata content from inline JSON. Content must be a "
1131
+ "JSON object. "
1132
+ "Mutually exclusive with --metadata-content-file. "
1133
+ "Metadata failures abort the push by default; pass "
1134
+ "--on-metadata-failure warn (or set the "
1135
+ "metadata_failure_mode config key / "
1136
+ "$CLOUDSMITH_METADATA_FAILURE_MODE env var to warn) to "
1137
+ "downgrade to a warning and keep the package upload."
1138
+ ),
1139
+ )
1140
+ @click.option(
1141
+ "--metadata-content-type",
1142
+ "metadata_content_type",
1143
+ default=None,
1144
+ help=(
1145
+ "Content type for metadata content "
1146
+ "(for example, 'application/vnd.jfrog.buildinfo+json'). "
1147
+ "Required when metadata content is supplied and determines "
1148
+ "the schema used for validation."
1149
+ ),
1150
+ )
1151
+ @click.option(
1152
+ "--metadata-source-identity",
1153
+ "metadata_source_identity",
1154
+ default=None,
1155
+ help=(
1156
+ "Identifier for the metadata source. "
1157
+ "Defaults to 'cloudsmith-cli@<version>'."
1158
+ ),
1159
+ )
1160
+ @click.option(
1161
+ "--on-metadata-failure",
1162
+ METADATA_FAILURE_MODE_KWARG,
1163
+ type=click.Choice(["error", "warn"]),
1164
+ default=None,
1165
+ help=(
1166
+ "How to handle push-time metadata failures. 'error' "
1167
+ "(default) aborts the push so CI/CD surfaces broken "
1168
+ "SBOM/BuildInfo uploads; 'warn' downgrades to a warning "
1169
+ "and lets the package upload regardless. Overrides the "
1170
+ "$CLOUDSMITH_METADATA_FAILURE_MODE env var and the "
1171
+ "'metadata_failure_mode' config key for this push."
1172
+ ),
1173
+ )
1174
+ @click.pass_context
1175
+ def push_handler(ctx, *args, **kwargs):
1176
+ """Handle upload for a specific package format."""
1177
+ opts = kwargs.get("opts")
1178
+ parameters = context.get(ctx.info_name)
1179
+ kwargs["package_type"] = ctx.info_name
1180
+
1181
+ owner_repo = kwargs.pop("owner_repo")
1182
+ if "distribution" in parameters:
1183
+ kwargs["distribution"] = "/".join(owner_repo[2:])
1184
+ owner_repo = owner_repo[0:2]
1185
+ kwargs["owner_repo"] = owner_repo
1186
+
1187
+ # Metadata flags are not part of the package-create payload, so
1188
+ # pop them and forward them as explicit kwargs so they don't leak
1189
+ # into validate_create_package() / create_package().
1190
+ metadata_kwargs = {
1191
+ key: kwargs.pop(key, None) for key in METADATA_KWARG_NAMES
1192
+ }
1193
+
1194
+ # ``--on-metadata-failure`` is also not a package-create kwarg;
1195
+ # publish it onto opts so the failure-mode helper can prefer it
1196
+ # over env/config without an explicit thread through every call.
1197
+ cli_failure_mode = kwargs.pop(METADATA_FAILURE_MODE_KWARG, None)
1198
+ if cli_failure_mode is not None:
1199
+ opts.cli_metadata_failure_mode = cli_failure_mode
1200
+
1201
+ package_files = kwargs.pop("package_file")
1202
+ if not isinstance(package_files, tuple):
1203
+ package_files = (package_files,)
1204
+
1205
+ # Reject multi-file push combined with metadata flags. A single
1206
+ # metadata payload semantically belongs to one package; silently
1207
+ # fanning it out across N packages (and validating + attaching it
1208
+ # N times) is almost never what the user wants. Force them to
1209
+ # push files individually with metadata, or drop the flags.
1210
+ metadata_flags_set = any(
1211
+ metadata_kwargs.get(k) for k in METADATA_KWARG_NAMES
1212
+ )
1213
+ if len(package_files) > 1 and metadata_flags_set:
1214
+ raise click.UsageError(
1215
+ "Metadata flags (--metadata-content-file, --metadata-content, "
1216
+ "--metadata-content-type, --metadata-source-identity) cannot "
1217
+ "be combined with multiple package files. Push files "
1218
+ "individually when attaching metadata."
1219
+ )
1220
+
1221
+ metadata, metadata_failure_info = resolve_push_metadata_options(
1222
+ **metadata_kwargs, opts=opts
1223
+ )
1224
+
1225
+ results = []
1226
+ for package_file in package_files:
1227
+ kwargs["package_file"] = package_file
1228
+
1229
+ try:
1230
+ click.echo(err=utils.should_use_stderr(opts))
1231
+ res = upload_files_and_create_package(
1232
+ ctx,
1233
+ *args,
1234
+ **kwargs,
1235
+ **metadata_kwargs,
1236
+ metadata=metadata,
1237
+ metadata_failure_info=metadata_failure_info,
1238
+ )
1239
+ if res:
1240
+ # ``upload_files_and_create_package`` resets and then
1241
+ # populates ``opts.push_metadata_info`` on every call,
1242
+ # so reading it here always reflects this iteration.
1243
+ results.append((res, opts.push_metadata_info))
1244
+ except ApiException:
1245
+ click.secho(
1246
+ "Skipping error and moving on.",
1247
+ fg="yellow",
1248
+ err=utils.should_use_stderr(opts),
1249
+ )
1250
+
1251
+ click.echo(err=utils.should_use_stderr(opts))
1252
+
1253
+ if utils.should_use_stderr(opts):
1254
+ data = []
1255
+ for (slug_perm, slug), metadata_info in results:
1256
+ entry = {
1257
+ "slug_perm": slug_perm,
1258
+ "slug": slug,
1259
+ "status": "OK", # Assuming success if we got here
1260
+ }
1261
+ if metadata_info is not None:
1262
+ entry["metadata_attachment"] = metadata_info
1263
+ data.append(entry)
1264
+
1265
+ if len(data) == 1:
1266
+ utils.maybe_print_as_json(opts, data[0])
1267
+ else:
1268
+ utils.maybe_print_as_json(opts, data)
1269
+
1270
+ # Add any additional arguments
1271
+ for k, info in kwargs.items():
1272
+ option_kwargs = {}
1273
+ option_name_fmt = "--%(key)s"
1274
+
1275
+ if k.endswith("_file"):
1276
+ # Treat parameters that end with _file as uploadable filepaths.
1277
+ option_kwargs["type"] = ExpandPath(
1278
+ dir_okay=False, exists=True, writable=False, resolve_path=True
1279
+ )
1280
+ elif k == "extra_files":
1281
+ # Handle multiple files for extra_files parameter.
1282
+ option_kwargs["type"] = str
1283
+ option_kwargs["multiple"] = True
1284
+ option_kwargs["callback"] = validators.validate_extra_files_parameter
1285
+ info["help"] = (
1286
+ info["help"] + " Accepts a comma-separated list of values."
1287
+ )
1288
+ elif info["type"] == "bool":
1289
+ option_name_fmt = "--%(key)s/--no-%(key)s"
1290
+ option_kwargs["is_flag"] = True
1291
+ else:
1292
+ option_kwargs["type"] = str
1293
+
1294
+ if k == "republish":
1295
+ # None is required to default upload republish settings to the repo republish settings
1296
+ option_kwargs["default"] = None
1297
+
1298
+ option_name = option_name_fmt % {"key": k.replace("_", "-")}
1299
+ decorator = click.option(
1300
+ option_name,
1301
+ required=info["required"],
1302
+ help=info["help"],
1303
+ **option_kwargs,
1304
+ )
1305
+ push_handler = decorator(push_handler)
1306
+
1307
+ handlers[key] = push_handler
1308
+
1309
+
1310
+ @main.group(cls=command.AliasGroup, aliases=["upload", "deploy"])
1311
+ @click.pass_context
1312
+ def push(ctx): # pylint: disable=unused-argument
1313
+ """
1314
+ Push (upload) a new package to a repository.
1315
+
1316
+ At the moment you need to specify the package format (see below) of
1317
+ the package you're uploading. Each package format may have additional
1318
+ options/parameters that are specific to that package format (e.g. the
1319
+ Maven backend has the concepts of artifact and group IDs).
1320
+ """
1321
+
1322
+
1323
+ create_push_handlers()