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,267 @@
|
|
|
1
|
+
"""CLI - Utilities."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import platform
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from datetime import date, datetime
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from click_spinner import spinner
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
from ..core.api.version import get_version as get_api_version
|
|
14
|
+
from ..core.version import get_version as get_cli_version
|
|
15
|
+
from .table import make_table
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def make_user_agent(prefix=None):
|
|
19
|
+
"""Get a suitable user agent for identifying the CLI process."""
|
|
20
|
+
prefix = (prefix or platform.platform(terse=1)).strip().lower()
|
|
21
|
+
return f"cloudsmith-cli/{prefix} cli:{get_cli_version()} api:{get_api_version()}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def pretty_print_list_info(num_results, page_info=None, suffix="", page_all=False):
|
|
25
|
+
"""Print information about list results."""
|
|
26
|
+
if page_all:
|
|
27
|
+
click.echo(
|
|
28
|
+
"Results: %(num_results)d %(suffix)s"
|
|
29
|
+
% {
|
|
30
|
+
"num_results": num_results,
|
|
31
|
+
"suffix": suffix,
|
|
32
|
+
}
|
|
33
|
+
)
|
|
34
|
+
elif page_info and page_info.page is not None and page_info.page_size is not None:
|
|
35
|
+
start = (page_info.page - 1) * page_info.page_size + 1
|
|
36
|
+
end = min(start + num_results - 1, page_info.count or 0)
|
|
37
|
+
click.echo(
|
|
38
|
+
"Results: %(start)d-%(end)d (%(count)d) of %(total)d %(suffix)s "
|
|
39
|
+
"(page: %(page)d/%(pages)d, page size: %(page_size)d)"
|
|
40
|
+
% {
|
|
41
|
+
"start": start,
|
|
42
|
+
"end": end,
|
|
43
|
+
"count": num_results,
|
|
44
|
+
"total": page_info.count or 0,
|
|
45
|
+
"suffix": suffix,
|
|
46
|
+
"page": page_info.page,
|
|
47
|
+
"pages": page_info.page_total or 1,
|
|
48
|
+
"page_size": page_info.page_size,
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
else:
|
|
52
|
+
click.echo(
|
|
53
|
+
"Results: %(num_results)d %(suffix)s"
|
|
54
|
+
% {"num_results": num_results, "suffix": suffix}
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def fmt_datetime(value):
|
|
59
|
+
"""Convert a datetime value to string."""
|
|
60
|
+
if isinstance(value, (date, datetime)):
|
|
61
|
+
return value.isoformat().replace("+00:00", "Z")
|
|
62
|
+
return value
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def fmt_bool(value):
|
|
66
|
+
"""Convert a boolean value to string."""
|
|
67
|
+
if isinstance(value, bool):
|
|
68
|
+
return str(value).lower()
|
|
69
|
+
return value
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def pretty_print_table(headers, rows, title=None):
|
|
73
|
+
"""Pretty print a table from headers and rows."""
|
|
74
|
+
table = make_table(headers=headers, rows=rows)
|
|
75
|
+
|
|
76
|
+
def pretty_print_row(styled, plain):
|
|
77
|
+
"""Pretty print a row."""
|
|
78
|
+
click.secho(
|
|
79
|
+
" | ".join(
|
|
80
|
+
v + " " * (table.column_widths[k] - len(plain[k]))
|
|
81
|
+
for k, v in enumerate(styled)
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
if title:
|
|
86
|
+
click.secho(title, fg="white", bold=True)
|
|
87
|
+
click.secho("-" * 80, fg="yellow")
|
|
88
|
+
|
|
89
|
+
pretty_print_row(table.headers, table.plain_headers)
|
|
90
|
+
for k, row in enumerate(table.rows):
|
|
91
|
+
pretty_print_row(row, table.plain_rows[k])
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def rich_print_table(headers, rows, title=None, show_lines=False):
|
|
95
|
+
"""Rich table from headers and rows."""
|
|
96
|
+
console = Console()
|
|
97
|
+
table = Table(title=title, show_lines=show_lines)
|
|
98
|
+
|
|
99
|
+
for header in headers:
|
|
100
|
+
if isinstance(header, dict):
|
|
101
|
+
table.add_column(
|
|
102
|
+
header.get("header", ""),
|
|
103
|
+
justify=header.get("justify", "left"),
|
|
104
|
+
style=header.get("style", "none"),
|
|
105
|
+
no_wrap=header.get("no_wrap", False),
|
|
106
|
+
)
|
|
107
|
+
else:
|
|
108
|
+
table.add_column(str(header))
|
|
109
|
+
|
|
110
|
+
for row in rows:
|
|
111
|
+
table.add_row(*row)
|
|
112
|
+
|
|
113
|
+
console.print(table)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def print_rate_limit_info(opts, rate_info):
|
|
117
|
+
"""Tell the user when we're being rate limited."""
|
|
118
|
+
if not rate_info:
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
show_info = (
|
|
122
|
+
opts.always_show_rate_limit or rate_info.interval > opts.rate_limit_warning
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
if not show_info:
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
click.echo(err=True)
|
|
129
|
+
click.secho(
|
|
130
|
+
"Throttling (rate limited) for: %(throttle)s seconds ... "
|
|
131
|
+
% {"throttle": click.style(str(rate_info.interval), reverse=True)},
|
|
132
|
+
err=True,
|
|
133
|
+
reset=False,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def json_serializer(obj):
|
|
138
|
+
"""JSON serializer for objects not serializable by default."""
|
|
139
|
+
|
|
140
|
+
# convert date/datetime objects to strings
|
|
141
|
+
if isinstance(obj, (datetime, date)):
|
|
142
|
+
return fmt_datetime(obj)
|
|
143
|
+
raise TypeError("Type %s not serializable." % type(obj))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def maybe_print_as_json(opts, data, page_info=None):
|
|
147
|
+
"""Maybe print data as JSON."""
|
|
148
|
+
if opts.output not in ("json", "pretty_json"):
|
|
149
|
+
return False
|
|
150
|
+
|
|
151
|
+
# Attempt to convert the data to dicts (usually from API objects)
|
|
152
|
+
try:
|
|
153
|
+
data = data.to_dict()
|
|
154
|
+
except AttributeError:
|
|
155
|
+
pass
|
|
156
|
+
|
|
157
|
+
if isinstance(data, list):
|
|
158
|
+
for k, item in enumerate(data):
|
|
159
|
+
try:
|
|
160
|
+
data[k] = item.to_dict()
|
|
161
|
+
except AttributeError:
|
|
162
|
+
pass
|
|
163
|
+
|
|
164
|
+
root = {"data": data}
|
|
165
|
+
|
|
166
|
+
if page_info is not None and page_info.is_valid:
|
|
167
|
+
meta = root["meta"] = {}
|
|
168
|
+
meta["pagination"] = page_info.as_dict(num_results=len(data))
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
if opts.output == "pretty_json":
|
|
172
|
+
dump = json.dumps(root, indent=4, sort_keys=True, default=json_serializer)
|
|
173
|
+
else:
|
|
174
|
+
dump = json.dumps(root, sort_keys=True, default=json_serializer)
|
|
175
|
+
except (TypeError, ValueError) as e:
|
|
176
|
+
click.secho(f"Failed to convert to JSON: {str(e)}", fg="red", err=True)
|
|
177
|
+
return True
|
|
178
|
+
|
|
179
|
+
click.echo(dump)
|
|
180
|
+
return True
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def maybe_truncate_string(data, max_len=50):
|
|
184
|
+
"""Maybe truncate a string"""
|
|
185
|
+
if data is not None and len(data) > max_len:
|
|
186
|
+
return data[: max_len - 3] + "..."
|
|
187
|
+
return data
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def maybe_truncate_list(data, max_len=5):
|
|
191
|
+
"""Maybe truncate list"""
|
|
192
|
+
if data is not None and len(data) > max_len:
|
|
193
|
+
return data[:max_len] + ["..."]
|
|
194
|
+
return data
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def maybe_unstyle_prompt(prompt, err=False):
|
|
198
|
+
"""Strip ANSI styling from a prompt when the target stream is not a TTY.
|
|
199
|
+
|
|
200
|
+
As of click 8.4, ``click.prompt``/``click.confirm`` pass the prompt text
|
|
201
|
+
straight to the (readline-backed) prompt function instead of routing it
|
|
202
|
+
through ``echo()``. This means click's ``should_strip_ansi`` logic no
|
|
203
|
+
longer fires for prompt text, so any ANSI codes baked into the prompt via
|
|
204
|
+
``click.style(..., bold=True)`` leak raw into non-TTY output (piped
|
|
205
|
+
output, CI logs, captured streams). Restore the pre-8.4 behaviour by
|
|
206
|
+
unstyling the prompt ourselves when the destination stream isn't a TTY.
|
|
207
|
+
|
|
208
|
+
Applying ``click.unstyle`` to plain text is a harmless no-op.
|
|
209
|
+
"""
|
|
210
|
+
stream = click.get_text_stream("stderr" if err else "stdout")
|
|
211
|
+
if not stream.isatty():
|
|
212
|
+
prompt = click.unstyle(prompt)
|
|
213
|
+
return prompt
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def confirm_operation(prompt, prefix=None, assume_yes=False, err=False):
|
|
217
|
+
"""Prompt the user for confirmation for dangerous actions."""
|
|
218
|
+
if assume_yes:
|
|
219
|
+
return True
|
|
220
|
+
|
|
221
|
+
prefix = prefix or click.style(
|
|
222
|
+
"Are you %s certain you want to" % (click.style("absolutely", bold=True))
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
prompt = maybe_unstyle_prompt(f"{prefix} {prompt}?", err=err)
|
|
226
|
+
|
|
227
|
+
answered = click.confirm(prompt, err=err)
|
|
228
|
+
|
|
229
|
+
# click.confirm reads input via input() which relies on terminal line
|
|
230
|
+
# discipline to echo typed characters. In non-TTY contexts (CI logs,
|
|
231
|
+
# piped stdin, captured output) the answer is invisible next to the
|
|
232
|
+
# prompt, so echo the resolved value explicitly.
|
|
233
|
+
if not click.get_text_stream("stdin").isatty():
|
|
234
|
+
click.echo("y" if answered else "N", err=err)
|
|
235
|
+
|
|
236
|
+
if answered:
|
|
237
|
+
return True
|
|
238
|
+
|
|
239
|
+
click.echo(err=err)
|
|
240
|
+
click.secho("OK, phew! Close call. :-)", fg="green", err=err)
|
|
241
|
+
return False
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@contextmanager
|
|
245
|
+
def maybe_spinner(opts):
|
|
246
|
+
"""Only activate the spinner if not in debug mode or using json output."""
|
|
247
|
+
if should_use_stderr(opts) or get_output_format(opts) in ("json", "pretty_json"):
|
|
248
|
+
# No spinner
|
|
249
|
+
yield
|
|
250
|
+
else:
|
|
251
|
+
with spinner() as spin:
|
|
252
|
+
yield spin
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def get_output_format(opts):
|
|
256
|
+
"""Get the output format from opts."""
|
|
257
|
+
return getattr(opts, "output", None)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def should_use_stderr(opts):
|
|
261
|
+
"""Check if stdout should be avoided for informational messages."""
|
|
262
|
+
return get_output_format(opts) in ("json", "pretty_json")
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def maybe_print_status_json(opts, status_dict):
|
|
266
|
+
"""Maybe print a status dict as JSON."""
|
|
267
|
+
return maybe_print_as_json(opts, status_dict)
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"""CLI - Validators."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from urllib.parse import urlsplit
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
from click.core import ParameterSource
|
|
9
|
+
|
|
10
|
+
from .types import ExpandPath
|
|
11
|
+
|
|
12
|
+
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
|
|
13
|
+
BAD_API_HEADERS = ("user-agent", "host")
|
|
14
|
+
API_HEADER_TRANSFORMS = {}
|
|
15
|
+
PUBLIC_API_HOST_SUFFIXES = ("cloudsmith.io", "cloudsmith.com")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class IntOrWildcard(click.ParamType):
|
|
19
|
+
"""Custom Click type that accepts integers or '*' wildcard (converted to -1)."""
|
|
20
|
+
|
|
21
|
+
name = "integer or *"
|
|
22
|
+
|
|
23
|
+
def convert(self, value, param, ctx):
|
|
24
|
+
# Already converted
|
|
25
|
+
if isinstance(value, int):
|
|
26
|
+
return value
|
|
27
|
+
|
|
28
|
+
# Handle wildcard
|
|
29
|
+
if value == "*":
|
|
30
|
+
return -1
|
|
31
|
+
|
|
32
|
+
# Try to convert to integer
|
|
33
|
+
try:
|
|
34
|
+
return int(value)
|
|
35
|
+
except ValueError:
|
|
36
|
+
self.fail(f"{value!r} is not a valid integer or '*'", param, ctx)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def transform_api_header_authorization(param, value):
|
|
40
|
+
"""Transform a username:password value into a base64 string."""
|
|
41
|
+
try:
|
|
42
|
+
username, password = value.split(":", 1)
|
|
43
|
+
except ValueError:
|
|
44
|
+
raise click.BadParameter(
|
|
45
|
+
"Authorization header needs to be Authorization=username:password",
|
|
46
|
+
param=param,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
value = f"{username.strip()}:{password}"
|
|
50
|
+
value = base64.b64encode(bytes(value.encode()))
|
|
51
|
+
return "Basic %s" % value.decode("utf-8")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
API_HEADER_TRANSFORMS["Authorization"] = transform_api_header_authorization
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def validate_api_headers(param, value):
|
|
58
|
+
"""Validate that API headers is a CSV of k=v pairs."""
|
|
59
|
+
# pylint: disable=unused-argument
|
|
60
|
+
if not value:
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
headers = {}
|
|
64
|
+
for kv in value.split(","):
|
|
65
|
+
try:
|
|
66
|
+
k, v = kv.split("=", 1)
|
|
67
|
+
k = k.strip()
|
|
68
|
+
|
|
69
|
+
for bad_header in BAD_API_HEADERS:
|
|
70
|
+
if bad_header == k:
|
|
71
|
+
raise click.BadParameter(
|
|
72
|
+
f"{bad_header} is not an allowed header",
|
|
73
|
+
param=param,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
if k in API_HEADER_TRANSFORMS:
|
|
77
|
+
transform_func = API_HEADER_TRANSFORMS[k]
|
|
78
|
+
v = transform_func(param, v)
|
|
79
|
+
except ValueError:
|
|
80
|
+
raise click.BadParameter(
|
|
81
|
+
"Values need to be a CSV of key=value pairs", param=param
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
headers[k] = v
|
|
85
|
+
|
|
86
|
+
return headers
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def host_matches_suffixes(url, suffixes):
|
|
90
|
+
"""True if url's hostname equals or is a subdomain of one of the suffixes.
|
|
91
|
+
|
|
92
|
+
Parsing the hostname (rather than substring-matching the URL) ensures
|
|
93
|
+
userinfo, port, and path components cannot smuggle a host past the check.
|
|
94
|
+
"""
|
|
95
|
+
hostname = (urlsplit(url).hostname or "").lower()
|
|
96
|
+
return bool(hostname) and any(
|
|
97
|
+
hostname == suffix or hostname.endswith(f".{suffix}") for suffix in suffixes
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def is_trusted_api_host(host, extra_suffixes=()):
|
|
102
|
+
"""True if host is an allowed Cloudsmith host (or matches an extra suffix)."""
|
|
103
|
+
return host_matches_suffixes(host, PUBLIC_API_HOST_SUFFIXES + tuple(extra_suffixes))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def validate_untrusted_api_host(host, extra_suffixes=()):
|
|
107
|
+
"""Raise if an api_host from an untrusted config is not an allowed host."""
|
|
108
|
+
if is_trusted_api_host(host, extra_suffixes):
|
|
109
|
+
return
|
|
110
|
+
raise click.UsageError(
|
|
111
|
+
f'api_host "{host}" is set by a config file in the current directory '
|
|
112
|
+
"and is not an allowed Cloudsmith host. Allowed hosts must be under "
|
|
113
|
+
"*.cloudsmith.io or *.cloudsmith.com. To use a custom host, set it via "
|
|
114
|
+
"--api-host, the CLOUDSMITH_API_HOST environment variable, an explicit "
|
|
115
|
+
"--config-file, or your user-level config."
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def validate_untrusted_api_proxy(proxy, allowed_suffixes=()):
|
|
120
|
+
"""Raise if an api_proxy from an untrusted config is not an allowed proxy."""
|
|
121
|
+
if host_matches_suffixes(proxy, allowed_suffixes):
|
|
122
|
+
return
|
|
123
|
+
raise click.UsageError(
|
|
124
|
+
f'api_proxy "{proxy}" is set by a config file in the current directory '
|
|
125
|
+
"and is not an allowed proxy. To use a proxy, set it via --api-proxy, "
|
|
126
|
+
"the CLOUDSMITH_API_PROXY environment variable, an explicit "
|
|
127
|
+
"--config-file, or your user-level config."
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def validate_slashes(
|
|
132
|
+
param, value, minimum=2, maximum=None, form=None, allow_blank=False
|
|
133
|
+
):
|
|
134
|
+
"""Ensure that parameter has slashes and minimum parts."""
|
|
135
|
+
try:
|
|
136
|
+
value = value.split("/")
|
|
137
|
+
except ValueError:
|
|
138
|
+
value = None
|
|
139
|
+
|
|
140
|
+
if value:
|
|
141
|
+
if len(value) < minimum:
|
|
142
|
+
value = None
|
|
143
|
+
elif maximum and len(value) > maximum:
|
|
144
|
+
value = None
|
|
145
|
+
|
|
146
|
+
if not value:
|
|
147
|
+
form = form or "/".join("VALUE" for _ in range(minimum))
|
|
148
|
+
raise click.BadParameter(f"Must be in the form of {form}", param=param)
|
|
149
|
+
|
|
150
|
+
value = [v.strip() for v in value]
|
|
151
|
+
if not allow_blank and not all(value):
|
|
152
|
+
raise click.BadParameter("Individual values cannot be blank", param=param)
|
|
153
|
+
|
|
154
|
+
return value
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def validate_optional_owner_repo(ctx, param, value):
|
|
158
|
+
"""Ensure that owner/repo is formatted correctly, where owner and repo are optional."""
|
|
159
|
+
# pylint: disable=unused-argument
|
|
160
|
+
form = "OWNER/REPO"
|
|
161
|
+
|
|
162
|
+
return validate_slashes(
|
|
163
|
+
param, value, minimum=0, maximum=2, form=form, allow_blank=True
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def validate_required_owner_optional_repo(ctx, param, value):
|
|
168
|
+
"""Ensure that owner/repo is formatted correctly, where owner is required and repo is optional."""
|
|
169
|
+
form = "OWNER[/REPO]"
|
|
170
|
+
return validate_slashes(param, value, minimum=1, maximum=2, form=form)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def validate_owner(ctx, param, value):
|
|
174
|
+
"""Ensure that owner is formatted correctly."""
|
|
175
|
+
# pylint: disable=unused-argument
|
|
176
|
+
form = "OWNER"
|
|
177
|
+
return validate_slashes(param, value, minimum=1, maximum=1, form=form)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def validate_owner_repo(ctx, param, value):
|
|
181
|
+
"""Ensure that owner/repo is formatted correctly."""
|
|
182
|
+
# pylint: disable=unused-argument
|
|
183
|
+
form = "OWNER/REPO"
|
|
184
|
+
return validate_slashes(param, value, minimum=2, maximum=2, form=form)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def validate_owner_repo_package(ctx, param, value):
|
|
188
|
+
"""Ensure that owner/repo/package is formatted correctly."""
|
|
189
|
+
# pylint: disable=unused-argument
|
|
190
|
+
form = "OWNER/REPO/PACKAGE"
|
|
191
|
+
return validate_slashes(param, value, minimum=3, maximum=3, form=form)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def validate_owner_repo_slug_perm(ctx, param, value):
|
|
195
|
+
"""Ensure that owner/repo/slug_perm is formatted correctly."""
|
|
196
|
+
# pylint: disable=unused-argument
|
|
197
|
+
form = "OWNER/REPO/SLUG_PERM"
|
|
198
|
+
return validate_slashes(param, value, minimum=3, maximum=3, form=form)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def validate_owner_repo_distro(ctx, param, value):
|
|
202
|
+
"""Ensure that owner/repo/distro/version is formatted correctly."""
|
|
203
|
+
# pylint: disable=unused-argument
|
|
204
|
+
form = "OWNER/REPO/DISTRO[/RELEASE]"
|
|
205
|
+
return validate_slashes(param, value, minimum=3, maximum=4, form=form)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def validate_page(ctx, param, value):
|
|
209
|
+
"""Ensure that a valid value for page is chosen."""
|
|
210
|
+
# pylint: disable=unused-argument
|
|
211
|
+
if value == 0:
|
|
212
|
+
raise click.BadParameter(
|
|
213
|
+
"Page is not zero-based, please set a value to 1 or higher.", param=param
|
|
214
|
+
)
|
|
215
|
+
return value
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def validate_page_size(ctx, param, value):
|
|
219
|
+
"""Ensure that a valid value for page size is chosen.
|
|
220
|
+
|
|
221
|
+
The IntOrWildcard type already converts '*' to -1 and validates integers.
|
|
222
|
+
"""
|
|
223
|
+
# pylint: disable=unused-argument
|
|
224
|
+
if value == 0:
|
|
225
|
+
raise click.BadParameter("Page size must be non-zero or unset.", param=param)
|
|
226
|
+
return value
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def enforce_page_all_exclusive(ctx, wildcard_used=False):
|
|
230
|
+
"""Order-independent mutual exclusivity check for pagination options.
|
|
231
|
+
|
|
232
|
+
Raises click.BadParameter bound to the --page-all option if it was used
|
|
233
|
+
together with explicit --page or --page-size. "Explicit" means supplied
|
|
234
|
+
via command line, environment variable, or prompt (Click ParameterSource).
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
ctx: Click context
|
|
238
|
+
wildcard_used: If True, validates even if --page-all wasn't explicitly passed
|
|
239
|
+
(used when --page-size '*' or -1 was used)
|
|
240
|
+
"""
|
|
241
|
+
page_all = ctx.params.get("page_all")
|
|
242
|
+
if not page_all and not wildcard_used:
|
|
243
|
+
return
|
|
244
|
+
|
|
245
|
+
explicit_sources = {
|
|
246
|
+
src
|
|
247
|
+
for src in (
|
|
248
|
+
ParameterSource.COMMANDLINE,
|
|
249
|
+
ParameterSource.ENVIRONMENT,
|
|
250
|
+
getattr(ParameterSource, "PROMPT", None),
|
|
251
|
+
)
|
|
252
|
+
if src is not None
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
page_explicit = ctx.get_parameter_source("page") in explicit_sources
|
|
256
|
+
# When checking wildcard usage, don't count page_size as conflicting with itself
|
|
257
|
+
size_source = ctx.get_parameter_source("page_size")
|
|
258
|
+
size_explicit = size_source in explicit_sources and not (
|
|
259
|
+
wildcard_used and size_source == ParameterSource.COMMANDLINE
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
if page_explicit or size_explicit:
|
|
263
|
+
page_all_param = next(
|
|
264
|
+
(p for p in ctx.command.params if p.name == "page_all"), None
|
|
265
|
+
)
|
|
266
|
+
error_msg = "Cannot be used with --page (-p) or --page-size (-l). (--show-all is an alias for --page-all)"
|
|
267
|
+
if wildcard_used:
|
|
268
|
+
error_msg = "Wildcard '*' or -1 in --page-size cannot be used with --page (-p). Use --page-all instead."
|
|
269
|
+
raise click.BadParameter(
|
|
270
|
+
error_msg,
|
|
271
|
+
param=page_all_param,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def validate_optional_timestamp(ctx, param, value):
|
|
276
|
+
"""Ensure that a valid value for a timestamp is used."""
|
|
277
|
+
|
|
278
|
+
if value:
|
|
279
|
+
try:
|
|
280
|
+
return datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(
|
|
281
|
+
hour=0, minute=0, second=0
|
|
282
|
+
)
|
|
283
|
+
except ValueError:
|
|
284
|
+
raise click.BadParameter(
|
|
285
|
+
f"{param.name} must be a valid utc timestamp formatted as `%Y-%m-%dT%H:%M:%SZ` e.g. `2020-12-31T00:00:00Z`",
|
|
286
|
+
param=param,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
return value
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def validate_bandwidth_unit(ctx, param, value):
|
|
293
|
+
"""Ensure that a valid value for bandwidth unit is used."""
|
|
294
|
+
|
|
295
|
+
units = [
|
|
296
|
+
"Byte",
|
|
297
|
+
"Kilobyte",
|
|
298
|
+
"Megabyte",
|
|
299
|
+
"Gigabyte",
|
|
300
|
+
"Terabyte",
|
|
301
|
+
"Petabyte",
|
|
302
|
+
"Exabyte",
|
|
303
|
+
"Zettabyte",
|
|
304
|
+
"Yottabyte",
|
|
305
|
+
]
|
|
306
|
+
|
|
307
|
+
if value:
|
|
308
|
+
for unit in units:
|
|
309
|
+
if value.lower() == unit.lower():
|
|
310
|
+
return unit
|
|
311
|
+
|
|
312
|
+
raise click.BadParameter(
|
|
313
|
+
"Bandwidth unit must be one of the allowed values "
|
|
314
|
+
"(Byte, Kilobyte, Megabyte, Gigabyte, Terabyte, Petabyte, "
|
|
315
|
+
"Exabyte, Zettabyte, Yottabyte).",
|
|
316
|
+
param=param,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
return value
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def validate_scheduled_reset_period(ctx, param, value):
|
|
323
|
+
"""Ensure that a valid value for scheduled reset period is used."""
|
|
324
|
+
|
|
325
|
+
periods = [
|
|
326
|
+
"Never Reset",
|
|
327
|
+
"Daily",
|
|
328
|
+
"Weekly",
|
|
329
|
+
"Fortnightly",
|
|
330
|
+
"Monthly",
|
|
331
|
+
"Bi-Monthly",
|
|
332
|
+
"Quarterly",
|
|
333
|
+
"Every 6 months",
|
|
334
|
+
"Annual",
|
|
335
|
+
]
|
|
336
|
+
|
|
337
|
+
if value:
|
|
338
|
+
for period in periods:
|
|
339
|
+
if value.lower() == period.lower():
|
|
340
|
+
return period
|
|
341
|
+
|
|
342
|
+
raise click.BadParameter(
|
|
343
|
+
"The refresh token period must be one of the allowed values "
|
|
344
|
+
"(Never reset, Daily, Weekly, Fortnightly, Monthly "
|
|
345
|
+
"Bi-Monthly, Quarterly, Every 6 months, Annual).",
|
|
346
|
+
param=param,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
return value
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def validate_extra_files_parameter(ctx, param, value):
|
|
353
|
+
"""Validate and resolve paths for all extra files."""
|
|
354
|
+
|
|
355
|
+
if not value:
|
|
356
|
+
return []
|
|
357
|
+
|
|
358
|
+
path_obj = ExpandPath(
|
|
359
|
+
exists=True,
|
|
360
|
+
dir_okay=False,
|
|
361
|
+
writable=False,
|
|
362
|
+
resolve_path=True,
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
files = []
|
|
366
|
+
for v in value:
|
|
367
|
+
for path in v.split(","):
|
|
368
|
+
path = path.strip()
|
|
369
|
+
if not path:
|
|
370
|
+
continue
|
|
371
|
+
|
|
372
|
+
try:
|
|
373
|
+
resolved_path = path_obj.convert(path, param, ctx)
|
|
374
|
+
files.append(resolved_path)
|
|
375
|
+
except click.BadParameter as e:
|
|
376
|
+
raise click.BadParameter(f"Invalid file path '{path}': {e}")
|
|
377
|
+
|
|
378
|
+
return files
|