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,594 @@
1
+ """Core download functionality for Cloudsmith packages."""
2
+
3
+ import fnmatch
4
+ import hashlib
5
+ import os
6
+
7
+ import click
8
+ import cloudsmith_api
9
+ import requests
10
+ from rich.console import Console
11
+ from rich.table import Table
12
+
13
+ from . import keyring, ratelimits, utils
14
+ from .api.exceptions import catch_raise_api_exception
15
+ from .api.packages import get_packages_api, list_packages
16
+ from .rest import create_requests_session
17
+
18
+
19
+ def resolve_auth(
20
+ opts, api_key_opt: str | None = None
21
+ ) -> tuple[requests.Session, dict[str, str], str]:
22
+ """
23
+ Resolve authentication method and create session with appropriate headers.
24
+
25
+ Args:
26
+ opts: CLI options object containing existing auth config
27
+ api_key_opt: Optional API key override from --api-key
28
+
29
+ Returns:
30
+ (session, headers, auth_source) where auth_source is 'api-key', 'sso', or 'none'
31
+ """
32
+ session = create_requests_session(
33
+ error_retry_cb=getattr(opts, "error_retry_cb", None),
34
+ respect_retry_after_header=getattr(opts, "rate_limit", True),
35
+ )
36
+ headers = {}
37
+ auth_source = "none"
38
+
39
+ # Follow the same authentication logic as the API initialization
40
+ # Priority: explicit --api-key > SSO token > configured API key
41
+
42
+ # Only attempt keyring operations if keyring is enabled
43
+ config = cloudsmith_api.Configuration()
44
+ access_token = keyring.get_access_token(config.host)
45
+ api_key = api_key_opt or getattr(opts, "api_key", None)
46
+
47
+ if api_key:
48
+ # Prioritize API key (from --api-key option or CLOUDSMITH_API_KEY env var) over SSO
49
+ headers["X-Api-Key"] = api_key
50
+ auth_source = "api-key"
51
+ elif access_token:
52
+ headers["Authorization"] = f"Bearer {access_token}"
53
+ auth_source = "sso"
54
+
55
+ return session, headers, auth_source
56
+
57
+
58
+ def _matches_tag_filter(pkg: dict, tag_filter: str) -> bool:
59
+ """
60
+ Check if a package matches the tag filter.
61
+
62
+ Only matches against actual package tags (the 'tags' field),
63
+ not metadata fields like format, architecture, or distro.
64
+ Use --format, --arch, and --os for filtering by those fields.
65
+
66
+ Args:
67
+ pkg: Package dictionary
68
+ tag_filter: Tag to match against
69
+
70
+ Returns:
71
+ True if package matches the tag filter
72
+ """
73
+ pkg_tags = pkg.get("tags", {})
74
+ for tag_category in pkg_tags.values():
75
+ if isinstance(tag_category, list) and tag_filter in tag_category:
76
+ return True
77
+
78
+ return False
79
+
80
+
81
+ def _search_packages(
82
+ owner: str,
83
+ repo: str,
84
+ name: str,
85
+ *,
86
+ version: str | None = None,
87
+ format_filter: str | None = None,
88
+ os_filter: str | None = None,
89
+ arch_filter: str | None = None,
90
+ tag_filter: str | None = None,
91
+ filename_filter: str | None = None,
92
+ ) -> list[dict]:
93
+ """
94
+ Search for packages matching criteria, returning all matches.
95
+
96
+ Uses server-side filtering where possible, then applies client-side
97
+ filters for fields not supported by the API query language.
98
+
99
+ Args:
100
+ owner: Repository owner
101
+ repo: Repository name
102
+ name: Package name to search for
103
+ version: Optional version filter
104
+ format_filter: Optional format filter
105
+ os_filter: Optional OS filter
106
+ arch_filter: Optional architecture filter
107
+ tag_filter: Optional tag filter
108
+ filename_filter: Optional filename filter (supports glob patterns)
109
+
110
+ Returns:
111
+ List of matching package dicts
112
+ """
113
+ # Build search query - use server-side filtering where possible
114
+ query_parts = [f"name:{name}"]
115
+ if version:
116
+ query_parts.append(f"version:{version}")
117
+ if format_filter:
118
+ query_parts.append(f"format:{format_filter}")
119
+ # Use server-side filename filtering for exact matches (no wildcards)
120
+ if filename_filter and not any(c in filename_filter for c in "*?["):
121
+ query_parts.append(f"filename:{filename_filter}")
122
+
123
+ query = " AND ".join(query_parts)
124
+
125
+ # Search for packages
126
+ packages = []
127
+ page = 1
128
+ page_size = 100
129
+
130
+ while True:
131
+ page_packages, page_info = list_packages(
132
+ owner=owner, repo=repo, query=query, page=page, page_size=page_size
133
+ )
134
+
135
+ if not page_packages:
136
+ break
137
+
138
+ packages.extend(page_packages)
139
+
140
+ if not (page_info.is_valid and page_info.page < page_info.page_total):
141
+ break
142
+
143
+ page += 1
144
+
145
+ # Apply client-side filters for fields not supported server-side
146
+ filtered_packages = []
147
+ for pkg in packages:
148
+ # Exact name match (case-insensitive, API does partial matching)
149
+ if pkg.get("name", "").lower() != name.lower():
150
+ continue
151
+ # Apply OS filter
152
+ if os_filter and pkg.get("distro_os") != os_filter:
153
+ continue
154
+ # Apply architecture filter
155
+ if arch_filter and pkg.get("architecture") != arch_filter:
156
+ continue
157
+ # Apply tag filter
158
+ if tag_filter and not _matches_tag_filter(pkg, tag_filter):
159
+ continue
160
+ # Apply filename filter (glob patterns are client-side only)
161
+ if filename_filter and any(c in filename_filter for c in "*?["):
162
+ if not fnmatch.fnmatch(pkg.get("filename", ""), filename_filter):
163
+ continue
164
+ filtered_packages.append(pkg)
165
+
166
+ return filtered_packages
167
+
168
+
169
+ def resolve_all_packages(
170
+ owner: str,
171
+ repo: str,
172
+ name: str,
173
+ *,
174
+ version: str | None = None,
175
+ format_filter: str | None = None,
176
+ os_filter: str | None = None,
177
+ arch_filter: str | None = None,
178
+ tag_filter: str | None = None,
179
+ filename_filter: str | None = None,
180
+ ) -> list[dict]:
181
+ """
182
+ Find all packages matching the criteria.
183
+
184
+ Args:
185
+ owner: Repository owner
186
+ repo: Repository name
187
+ name: Package name to search for
188
+ version: Optional version filter
189
+ format_filter: Optional format filter
190
+ os_filter: Optional OS filter
191
+ arch_filter: Optional architecture filter
192
+ tag_filter: Optional tag filter
193
+ filename_filter: Optional filename filter (supports glob patterns)
194
+
195
+ Returns:
196
+ List of matching package dicts
197
+
198
+ Raises:
199
+ click.ClickException: If no packages found (exit code 2)
200
+ """
201
+ packages = _search_packages(
202
+ owner=owner,
203
+ repo=repo,
204
+ name=name,
205
+ version=version,
206
+ format_filter=format_filter,
207
+ os_filter=os_filter,
208
+ arch_filter=arch_filter,
209
+ tag_filter=tag_filter,
210
+ filename_filter=filename_filter,
211
+ )
212
+
213
+ if not packages:
214
+ exc = click.ClickException("No packages found matching the specified criteria.")
215
+ exc.exit_code = 2
216
+ raise exc
217
+
218
+ return packages
219
+
220
+
221
+ def resolve_package(
222
+ owner: str,
223
+ repo: str,
224
+ name: str,
225
+ *,
226
+ version: str | None = None,
227
+ format_filter: str | None = None,
228
+ os_filter: str | None = None,
229
+ arch_filter: str | None = None,
230
+ tag_filter: str | None = None,
231
+ filename_filter: str | None = None,
232
+ yes: bool = False,
233
+ ) -> dict:
234
+ """
235
+ Find a single package matching the criteria, handling multiple matches.
236
+
237
+ Args:
238
+ owner: Repository owner
239
+ repo: Repository name
240
+ name: Package name to search for
241
+ version: Optional version filter
242
+ format_filter: Optional format filter
243
+ os_filter: Optional OS filter
244
+ arch_filter: Optional architecture filter
245
+ tag_filter: Optional tag filter
246
+ filename_filter: Optional filename filter (supports glob patterns)
247
+ yes: If True, automatically select best match when multiple found
248
+
249
+ Returns:
250
+ The package dict
251
+
252
+ Raises:
253
+ click.ClickException: If 0 packages found (exit code 2) or >1 found without --yes (exit code 3)
254
+ """
255
+ packages = _search_packages(
256
+ owner=owner,
257
+ repo=repo,
258
+ name=name,
259
+ version=version,
260
+ format_filter=format_filter,
261
+ os_filter=os_filter,
262
+ arch_filter=arch_filter,
263
+ tag_filter=tag_filter,
264
+ filename_filter=filename_filter,
265
+ )
266
+
267
+ # Handle results
268
+ if not packages:
269
+ exc = click.ClickException("No packages found matching the specified criteria.")
270
+ exc.exit_code = 2
271
+ raise exc
272
+
273
+ if len(packages) == 1:
274
+ return packages[0]
275
+
276
+ # Multiple packages found
277
+ if not yes:
278
+ _display_multiple_packages(packages)
279
+ exc = click.ClickException(
280
+ "Multiple packages found. Use --yes to auto-select the best match, "
281
+ "--download-all to download all matches, or add more specific filters "
282
+ "(e.g., --filename '*.nupkg')."
283
+ )
284
+ exc.exit_code = 3
285
+ raise exc
286
+
287
+ # Auto-select best match: highest version, then newest created_at
288
+ best_package = _select_best_package(packages)
289
+
290
+ click.echo(
291
+ f"Auto-selected: {best_package.get('name')} v{best_package.get('version')} ({best_package.get('format')})"
292
+ )
293
+
294
+ return best_package
295
+
296
+
297
+ def _display_multiple_packages(packages: list[dict]) -> None:
298
+ """Display a table of multiple matching packages."""
299
+ click.echo("Multiple packages found:")
300
+ click.echo()
301
+
302
+ table = Table(title=None, show_lines=False)
303
+ for header in ["#", "Name", "Version", "Format", "Filename", "Size", "Created"]:
304
+ table.add_column(header)
305
+
306
+ for i, pkg in enumerate(packages, 1):
307
+ table.add_row(
308
+ str(i),
309
+ pkg.get("name", ""),
310
+ pkg.get("version", ""),
311
+ pkg.get("format", ""),
312
+ pkg.get("filename", ""),
313
+ _format_size(pkg.get("size", 0)),
314
+ _format_date(pkg.get("uploaded_at", "")),
315
+ )
316
+
317
+ Console().print(table)
318
+ click.echo()
319
+
320
+
321
+ def get_download_url(package: dict) -> str:
322
+ """
323
+ Get the download URL for a package.
324
+
325
+ Args:
326
+ package: Package dictionary from API
327
+
328
+ Returns:
329
+ Download URL string
330
+
331
+ Raises:
332
+ click.ClickException: If no download URL is available
333
+ """
334
+ # Check for common download URL fields
335
+ download_url = (
336
+ package.get("cdn_url")
337
+ or package.get("download_url")
338
+ or package.get("file_url")
339
+ or package.get("url")
340
+ )
341
+
342
+ if not download_url:
343
+ raise click.ClickException("Package does not have a download URL available.")
344
+
345
+ return download_url
346
+
347
+
348
+ def get_package_files(package: dict) -> list[dict]:
349
+ """
350
+ Get all downloadable files associated with a package.
351
+
352
+ Args:
353
+ package: Package dictionary from API
354
+
355
+ Returns:
356
+ List of file dictionaries, each containing:
357
+ - filename: The file name
358
+ - cdn_url: Download URL
359
+ - size: File size in bytes
360
+ - tag: File type (pkg, pom, sources, javadoc, etc.)
361
+ - is_primary: Whether this is the primary package file
362
+ - checksum_md5, checksum_sha1, checksum_sha256, checksum_sha512: Checksums
363
+ """
364
+ files = package.get("files", [])
365
+
366
+ if not files:
367
+ # If no files array, return the main package as a single file
368
+ return [
369
+ {
370
+ "filename": package.get("filename", "package"),
371
+ "cdn_url": get_download_url(package),
372
+ "size": package.get("size", 0),
373
+ "tag": "pkg",
374
+ "is_primary": True,
375
+ "checksum_md5": package.get("checksum_md5"),
376
+ "checksum_sha1": package.get("checksum_sha1"),
377
+ "checksum_sha256": package.get("checksum_sha256"),
378
+ "checksum_sha512": package.get("checksum_sha512"),
379
+ }
380
+ ]
381
+
382
+ # Filter to only downloadable files with CDN URLs
383
+ downloadable_files = []
384
+ for file_info in files:
385
+ if file_info.get("is_downloadable") and file_info.get("cdn_url"):
386
+ downloadable_files.append(file_info)
387
+
388
+ return downloadable_files
389
+
390
+
391
+ def get_package_detail(owner: str, repo: str, identifier: str) -> dict:
392
+ """
393
+ Get detailed package information including download URLs.
394
+
395
+ Args:
396
+ owner: Repository owner
397
+ repo: Repository name
398
+ identifier: Package identifier/slug
399
+
400
+ Returns:
401
+ Detailed package dictionary
402
+ """
403
+ client = get_packages_api()
404
+
405
+ with catch_raise_api_exception():
406
+ data, _, headers = client.packages_read_with_http_info(
407
+ owner=owner, repo=repo, identifier=identifier
408
+ )
409
+
410
+ ratelimits.maybe_rate_limit(client, headers)
411
+ return data.to_dict()
412
+
413
+
414
+ def stream_download( # noqa: C901
415
+ url: str,
416
+ outfile: str,
417
+ session: requests.Session,
418
+ *,
419
+ headers: dict[str, str] | None = None,
420
+ overwrite: bool = False,
421
+ quiet: bool = False,
422
+ ) -> None:
423
+ """
424
+ Stream download a file with progress bar and checksum verification.
425
+
426
+ Args:
427
+ url: Download URL
428
+ outfile: Output file path
429
+ session: Requests session to use
430
+ headers: Additional headers for the request
431
+ overwrite: Whether to overwrite existing files
432
+ quiet: Whether to suppress progress output
433
+ """
434
+ # Check if file exists
435
+ if os.path.exists(outfile) and not overwrite:
436
+ raise click.ClickException(
437
+ f"File '{outfile}' already exists. Use --overwrite to replace it."
438
+ )
439
+
440
+ # Prepare headers
441
+ request_headers = headers.copy() if headers else {}
442
+
443
+ # For Cloudsmith downloads, we need to check what type of auth we have
444
+ auth = None
445
+
446
+ # Check if this is a /basic/ endpoint that requires Basic Auth
447
+ is_basic_endpoint = "/basic/" in url
448
+
449
+ if is_basic_endpoint:
450
+ # /basic/ endpoints require Basic Auth with API keys
451
+ # SSO Bearer tokens cannot be used directly with Basic Auth
452
+ if "Authorization" in request_headers and request_headers[
453
+ "Authorization"
454
+ ].startswith("Bearer "):
455
+ bearer_token = request_headers["Authorization"].split("Bearer ", 1)[1]
456
+ auth = ("token", bearer_token)
457
+ request_headers = {
458
+ k: v for k, v in request_headers.items() if k != "Authorization"
459
+ }
460
+ elif "X-Api-Key" in request_headers:
461
+ api_key = request_headers["X-Api-Key"]
462
+ auth = (
463
+ "token",
464
+ api_key,
465
+ ) # Basic auth: (username='token', password=api_key)
466
+ # Remove X-Api-Key header since we're using Basic Auth instead
467
+ request_headers = {
468
+ k: v for k, v in request_headers.items() if k != "X-Api-Key"
469
+ }
470
+ # For public endpoints (like /public/), keep headers as-is
471
+
472
+ # Attempt download with configured auth
473
+ try:
474
+ response = session.get(url, headers=request_headers, auth=auth, stream=True)
475
+ response.raise_for_status()
476
+ except requests.exceptions.HTTPError as e:
477
+ raise click.ClickException(
478
+ f"Failed to download package: HTTP {e.response.status_code}"
479
+ )
480
+ except requests.exceptions.RequestException as e:
481
+ raise click.ClickException(f"Failed to download package: {str(e)}")
482
+
483
+ # Get content length for progress bar
484
+ total_size = int(response.headers.get("content-length", 0))
485
+
486
+ # Create output directory if needed
487
+ os.makedirs(os.path.dirname(outfile), exist_ok=True)
488
+
489
+ # Download with progress bar
490
+ downloaded = 0
491
+ chunk_size = 8192
492
+
493
+ with click.open_file(outfile, "wb") as f:
494
+ if not quiet and total_size > 0:
495
+ with click.progressbar(
496
+ length=total_size, label="Downloading"
497
+ ) as progress_bar:
498
+ for chunk in response.iter_content(chunk_size=chunk_size):
499
+ if chunk:
500
+ f.write(chunk)
501
+ downloaded += len(chunk)
502
+ progress_bar.update(len(chunk))
503
+ else:
504
+ # No progress bar for unknown size or quiet mode
505
+ if not quiet:
506
+ click.echo(f"Downloading to {outfile}...")
507
+ for chunk in response.iter_content(chunk_size=chunk_size):
508
+ if chunk:
509
+ f.write(chunk)
510
+ downloaded += len(chunk)
511
+
512
+ if not quiet:
513
+ click.secho(f"✓ Downloaded {_format_size(downloaded)} to {outfile}", fg="green")
514
+
515
+ # Verify checksum if available in response headers
516
+ expected_checksum = response.headers.get("etag", "").strip('"')
517
+ if expected_checksum and not quiet:
518
+ if _verify_checksum(outfile, expected_checksum):
519
+ click.secho("✓ Checksum verified", fg="green")
520
+ else:
521
+ click.secho("⚠ Checksum mismatch", fg="yellow", err=True)
522
+
523
+
524
+ def _select_best_package(packages: list[dict]) -> dict:
525
+ """Select the best package from multiple matches."""
526
+
527
+ # Sort by version (desc) then by upload date (desc)
528
+ def sort_key(pkg):
529
+ version = pkg.get("version", "0")
530
+ uploaded_at = pkg.get("uploaded_at", "")
531
+
532
+ # Simple version comparison - split by dots and pad
533
+ version_parts = []
534
+ for part in version.split("."):
535
+ # Extract numeric part, fallback to 0
536
+ try:
537
+ num = int("".join(filter(str.isdigit, part)) or "0")
538
+ except ValueError:
539
+ num = 0
540
+ version_parts.append(num)
541
+
542
+ # Pad to 4 parts for consistent comparison
543
+ while len(version_parts) < 4:
544
+ version_parts.append(0)
545
+
546
+ return (tuple(version_parts), uploaded_at)
547
+
548
+ return sorted(packages, key=sort_key, reverse=True)[0]
549
+
550
+
551
+ def _format_size(size_bytes: int) -> str:
552
+ """Format file size in human-readable format."""
553
+ if size_bytes == 0:
554
+ return "0 B"
555
+
556
+ for unit in ["B", "KB", "MB", "GB", "TB"]:
557
+ if size_bytes < 1024.0:
558
+ return f"{size_bytes:.1f} {unit}"
559
+ size_bytes /= 1024.0
560
+
561
+ return f"{size_bytes:.1f} PB"
562
+
563
+
564
+ def _format_date(date_str):
565
+ """Format date string for display."""
566
+ if not date_str:
567
+ return ""
568
+
569
+ # Handle datetime objects
570
+ if hasattr(date_str, "strftime"):
571
+ return date_str.strftime("%Y-%m-%d")
572
+
573
+ # Handle string dates - just return first 10 chars (YYYY-MM-DD) for now
574
+ return date_str[:10] if len(date_str) >= 10 else date_str
575
+
576
+
577
+ def _verify_checksum(filepath: str, expected: str) -> bool:
578
+ """Verify file checksum."""
579
+ try:
580
+ # Try MD5 first (most common)
581
+ if len(expected) == 32:
582
+ return utils.calculate_file_md5(filepath) == expected
583
+
584
+ # Try SHA1
585
+ if len(expected) == 40:
586
+ sha1_hash = hashlib.sha1(usedforsecurity=False)
587
+ with open(filepath, "rb") as f:
588
+ for chunk in iter(lambda: f.read(4096), b""):
589
+ sha1_hash.update(chunk)
590
+ return sha1_hash.hexdigest() == expected
591
+
592
+ return False
593
+ except (OSError, ValueError): # File I/O or hash computation errors
594
+ return False