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.
- cloudsmith_cli/__init__.py +10 -0
- cloudsmith_cli/__main__.py +8 -0
- cloudsmith_cli/cli/__init__.py +1 -0
- cloudsmith_cli/cli/command.py +160 -0
- cloudsmith_cli/cli/commands/__init__.py +33 -0
- cloudsmith_cli/cli/commands/auth.py +173 -0
- cloudsmith_cli/cli/commands/check.py +129 -0
- cloudsmith_cli/cli/commands/copy.py +98 -0
- cloudsmith_cli/cli/commands/credential_helper/__init__.py +39 -0
- cloudsmith_cli/cli/commands/credential_helper/docker.py +66 -0
- cloudsmith_cli/cli/commands/credential_helper/manage.py +299 -0
- cloudsmith_cli/cli/commands/delete.py +67 -0
- cloudsmith_cli/cli/commands/dependencies.py +108 -0
- cloudsmith_cli/cli/commands/docs.py +16 -0
- cloudsmith_cli/cli/commands/download.py +620 -0
- cloudsmith_cli/cli/commands/entitlements.py +819 -0
- cloudsmith_cli/cli/commands/help_.py +12 -0
- cloudsmith_cli/cli/commands/list_.py +317 -0
- cloudsmith_cli/cli/commands/login.py +99 -0
- cloudsmith_cli/cli/commands/logout.py +151 -0
- cloudsmith_cli/cli/commands/main.py +73 -0
- cloudsmith_cli/cli/commands/mcp.py +523 -0
- cloudsmith_cli/cli/commands/metadata.py +503 -0
- cloudsmith_cli/cli/commands/metrics/__init__.py +2 -0
- cloudsmith_cli/cli/commands/metrics/command.py +20 -0
- cloudsmith_cli/cli/commands/metrics/entitlements.py +148 -0
- cloudsmith_cli/cli/commands/metrics/packages.py +134 -0
- cloudsmith_cli/cli/commands/move.py +111 -0
- cloudsmith_cli/cli/commands/policy/__init__.py +3 -0
- cloudsmith_cli/cli/commands/policy/command.py +20 -0
- cloudsmith_cli/cli/commands/policy/deny.py +248 -0
- cloudsmith_cli/cli/commands/policy/license.py +335 -0
- cloudsmith_cli/cli/commands/policy/vulnerability.py +322 -0
- cloudsmith_cli/cli/commands/push.py +1323 -0
- cloudsmith_cli/cli/commands/quarantine.py +148 -0
- cloudsmith_cli/cli/commands/quota/__init__.py +2 -0
- cloudsmith_cli/cli/commands/quota/command.py +20 -0
- cloudsmith_cli/cli/commands/quota/history.py +122 -0
- cloudsmith_cli/cli/commands/quota/quota.py +107 -0
- cloudsmith_cli/cli/commands/repos.py +330 -0
- cloudsmith_cli/cli/commands/resync.py +90 -0
- cloudsmith_cli/cli/commands/status.py +92 -0
- cloudsmith_cli/cli/commands/tags.py +375 -0
- cloudsmith_cli/cli/commands/tokens.py +318 -0
- cloudsmith_cli/cli/commands/upstream.py +479 -0
- cloudsmith_cli/cli/commands/vulnerabilities.py +141 -0
- cloudsmith_cli/cli/commands/whoami.py +188 -0
- cloudsmith_cli/cli/config.py +635 -0
- cloudsmith_cli/cli/decorators.py +624 -0
- cloudsmith_cli/cli/exceptions.py +215 -0
- cloudsmith_cli/cli/metadata_common.py +146 -0
- cloudsmith_cli/cli/saml.py +109 -0
- cloudsmith_cli/cli/table.py +59 -0
- cloudsmith_cli/cli/types.py +14 -0
- cloudsmith_cli/cli/utils.py +267 -0
- cloudsmith_cli/cli/validators.py +378 -0
- cloudsmith_cli/cli/webserver.py +263 -0
- cloudsmith_cli/core/__init__.py +1 -0
- cloudsmith_cli/core/api/__init__.py +1 -0
- cloudsmith_cli/core/api/distros.py +31 -0
- cloudsmith_cli/core/api/entitlements.py +130 -0
- cloudsmith_cli/core/api/exceptions.py +57 -0
- cloudsmith_cli/core/api/files.py +131 -0
- cloudsmith_cli/core/api/init.py +109 -0
- cloudsmith_cli/core/api/metadata.py +217 -0
- cloudsmith_cli/core/api/metrics.py +78 -0
- cloudsmith_cli/core/api/orgs.py +201 -0
- cloudsmith_cli/core/api/packages.py +309 -0
- cloudsmith_cli/core/api/quota.py +64 -0
- cloudsmith_cli/core/api/rates.py +28 -0
- cloudsmith_cli/core/api/repos.py +81 -0
- cloudsmith_cli/core/api/status.py +27 -0
- cloudsmith_cli/core/api/upstreams.py +72 -0
- cloudsmith_cli/core/api/user.py +109 -0
- cloudsmith_cli/core/api/version.py +15 -0
- cloudsmith_cli/core/api/vulnerabilities.py +230 -0
- cloudsmith_cli/core/cache_utils.py +160 -0
- cloudsmith_cli/core/config.py +140 -0
- cloudsmith_cli/core/credentials/__init__.py +0 -0
- cloudsmith_cli/core/credentials/chain.py +69 -0
- cloudsmith_cli/core/credentials/models.py +44 -0
- cloudsmith_cli/core/credentials/oidc/__init__.py +6 -0
- cloudsmith_cli/core/credentials/oidc/cache.py +220 -0
- cloudsmith_cli/core/credentials/oidc/detectors/__init__.py +122 -0
- cloudsmith_cli/core/credentials/oidc/detectors/aws.py +85 -0
- cloudsmith_cli/core/credentials/oidc/detectors/azure_devops.py +70 -0
- cloudsmith_cli/core/credentials/oidc/detectors/base.py +26 -0
- cloudsmith_cli/core/credentials/oidc/detectors/bitbucket_pipelines.py +35 -0
- cloudsmith_cli/core/credentials/oidc/detectors/circleci.py +40 -0
- cloudsmith_cli/core/credentials/oidc/detectors/generic.py +42 -0
- cloudsmith_cli/core/credentials/oidc/detectors/github_actions.py +64 -0
- cloudsmith_cli/core/credentials/oidc/detectors/gitlab_ci.py +49 -0
- cloudsmith_cli/core/credentials/oidc/exchange.py +87 -0
- cloudsmith_cli/core/credentials/provider.py +17 -0
- cloudsmith_cli/core/credentials/providers/__init__.py +15 -0
- cloudsmith_cli/core/credentials/providers/cli_flag.py +24 -0
- cloudsmith_cli/core/credentials/providers/credentials_file.py +24 -0
- cloudsmith_cli/core/credentials/providers/env_var.py +24 -0
- cloudsmith_cli/core/credentials/providers/keyring_provider.py +59 -0
- cloudsmith_cli/core/credentials/providers/oidc_provider.py +115 -0
- cloudsmith_cli/core/download.py +594 -0
- cloudsmith_cli/core/keyring.py +171 -0
- cloudsmith_cli/core/mcp/__init__.py +0 -0
- cloudsmith_cli/core/mcp/data.py +17 -0
- cloudsmith_cli/core/mcp/server.py +786 -0
- cloudsmith_cli/core/pagination.py +131 -0
- cloudsmith_cli/core/ratelimits.py +88 -0
- cloudsmith_cli/core/rest.py +255 -0
- cloudsmith_cli/core/utils.py +95 -0
- cloudsmith_cli/core/version.py +20 -0
- cloudsmith_cli/credential_helpers/__init__.py +7 -0
- cloudsmith_cli/credential_helpers/backends.py +41 -0
- cloudsmith_cli/credential_helpers/common.py +111 -0
- cloudsmith_cli/credential_helpers/custom_domains.py +281 -0
- cloudsmith_cli/credential_helpers/docker/__init__.py +4 -0
- cloudsmith_cli/credential_helpers/docker/installer.py +349 -0
- cloudsmith_cli/credential_helpers/docker/runtime.py +117 -0
- cloudsmith_cli/credential_helpers/launchers.py +175 -0
- cloudsmith_cli/data/VERSION +1 -0
- cloudsmith_cli/data/config.ini +23 -0
- cloudsmith_cli/data/credentials.ini +14 -0
- cloudsmith_cli/templates/__init__.py +3 -0
- cloudsmith_cli/templates/auth_error.html +45 -0
- cloudsmith_cli/templates/auth_success.html +37 -0
- cloudsmith_cli-1.20.0.dist-info/METADATA +610 -0
- cloudsmith_cli-1.20.0.dist-info/RECORD +130 -0
- cloudsmith_cli-1.20.0.dist-info/WHEEL +5 -0
- cloudsmith_cli-1.20.0.dist-info/entry_points.txt +2 -0
- cloudsmith_cli-1.20.0.dist-info/licenses/LICENSE +201 -0
- 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
|