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,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