originalpictures-cli 0.1.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.
- originalpictures_cli/__init__.py +7 -0
- originalpictures_cli/__main__.py +5 -0
- originalpictures_cli/cli.py +162 -0
- originalpictures_cli/click_boundary.py +69 -0
- originalpictures_cli/config.py +222 -0
- originalpictures_cli/exit_codes.py +24 -0
- originalpictures_cli/operations.py +704 -0
- originalpictures_cli/output.py +128 -0
- originalpictures_cli/runtime.py +175 -0
- originalpictures_cli/workflows.py +289 -0
- originalpictures_cli-0.1.0.dist-info/METADATA +298 -0
- originalpictures_cli-0.1.0.dist-info/RECORD +15 -0
- originalpictures_cli-0.1.0.dist-info/WHEEL +4 -0
- originalpictures_cli-0.1.0.dist-info/entry_points.txt +2 -0
- originalpictures_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Click command tree for Original Pictures public workflows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from originalpictures_cli import __version__
|
|
8
|
+
from originalpictures_cli.click_boundary import JsonUsageGroup
|
|
9
|
+
from originalpictures_cli.config import ConfigStore
|
|
10
|
+
from originalpictures_cli.operations import register_operation_commands
|
|
11
|
+
from originalpictures_cli.output import Output
|
|
12
|
+
from originalpictures_cli.runtime import Runtime, invoke
|
|
13
|
+
from originalpictures_cli.workflows import register_workflow_commands
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@click.group(
|
|
17
|
+
cls=JsonUsageGroup,
|
|
18
|
+
context_settings={"help_option_names": ["-h", "--help"], "max_content_width": 100},
|
|
19
|
+
help="Reviewed Original Pictures public API workflows.",
|
|
20
|
+
epilog="Root options must appear before the command group (for example: --json jobs get).",
|
|
21
|
+
)
|
|
22
|
+
@click.version_option(__version__, prog_name="Original Pictures CLI")
|
|
23
|
+
@click.option("--json", "json_mode", is_flag=True, help="Emit deterministic JSON on stdout.")
|
|
24
|
+
@click.option("--profile", help="Named local profile (or ORIGINALPICTURES_PROFILE).")
|
|
25
|
+
@click.option("--api-url", help="One-run API URL override (or ORIGINALPICTURES_API_URL).")
|
|
26
|
+
@click.option(
|
|
27
|
+
"--timeout",
|
|
28
|
+
type=click.FloatRange(0.1, 600.0),
|
|
29
|
+
default=30.0,
|
|
30
|
+
show_default=True,
|
|
31
|
+
help="Per-request SDK timeout in seconds.",
|
|
32
|
+
)
|
|
33
|
+
@click.option(
|
|
34
|
+
"--max-response-bytes",
|
|
35
|
+
type=click.IntRange(1024, 64 * 1024 * 1024),
|
|
36
|
+
default=16 * 1024 * 1024,
|
|
37
|
+
show_default=True,
|
|
38
|
+
help="Hard SDK JSON response bound.",
|
|
39
|
+
)
|
|
40
|
+
@click.pass_context
|
|
41
|
+
def cli(
|
|
42
|
+
ctx: click.Context,
|
|
43
|
+
json_mode: bool,
|
|
44
|
+
profile: str | None,
|
|
45
|
+
api_url: str | None,
|
|
46
|
+
timeout: float,
|
|
47
|
+
max_response_bytes: int,
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Initialize a lazy SDK runtime; no network call occurs for help/config commands."""
|
|
50
|
+
|
|
51
|
+
output = Output(json_mode=json_mode)
|
|
52
|
+
if isinstance(ctx.obj, Runtime):
|
|
53
|
+
runtime = ctx.obj
|
|
54
|
+
runtime.output = output
|
|
55
|
+
runtime.profile_name = profile or runtime.profile_name
|
|
56
|
+
runtime.api_url = api_url or runtime.api_url
|
|
57
|
+
runtime.timeout = timeout
|
|
58
|
+
runtime.max_response_bytes = max_response_bytes
|
|
59
|
+
else:
|
|
60
|
+
runtime = Runtime(
|
|
61
|
+
store=ConfigStore(),
|
|
62
|
+
output=output,
|
|
63
|
+
profile_name=profile,
|
|
64
|
+
api_url=api_url,
|
|
65
|
+
timeout=timeout,
|
|
66
|
+
max_response_bytes=max_response_bytes,
|
|
67
|
+
)
|
|
68
|
+
ctx.obj = runtime
|
|
69
|
+
ctx.call_on_close(runtime.close)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@cli.group("auth", help="Store and inspect API credentials without argv secrets.")
|
|
73
|
+
def auth_group() -> None:
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@auth_group.command("login", help="Store an API credential in the operating-system keyring.")
|
|
78
|
+
@click.option(
|
|
79
|
+
"--token-stdin",
|
|
80
|
+
is_flag=True,
|
|
81
|
+
help="Read one credential line from stdin instead of hidden interactive input.",
|
|
82
|
+
)
|
|
83
|
+
@click.pass_obj
|
|
84
|
+
def auth_login(runtime: Runtime, token_stdin: bool) -> None:
|
|
85
|
+
if token_stdin:
|
|
86
|
+
token = click.get_text_stream("stdin").readline(4098)
|
|
87
|
+
if len(token) > 4097 or (token and not token.endswith("\n")):
|
|
88
|
+
raise click.UsageError("credential input exceeds the 4096-character limit")
|
|
89
|
+
token = token.rstrip("\r\n")
|
|
90
|
+
else:
|
|
91
|
+
token = click.prompt("API credential", hide_input=True, confirmation_prompt=True)
|
|
92
|
+
profile_name = runtime.profile_name or runtime.store.default_profile
|
|
93
|
+
invoke(runtime, lambda: runtime.store.set_api_key(profile_name, token))
|
|
94
|
+
runtime.output.success({"credential_stored": True, "profile": profile_name})
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@auth_group.command("logout", help="Remove a profile credential from the keyring.")
|
|
98
|
+
@click.pass_obj
|
|
99
|
+
def auth_logout(runtime: Runtime) -> None:
|
|
100
|
+
profile_name = runtime.profile_name or runtime.store.default_profile
|
|
101
|
+
removed = invoke(runtime, lambda: runtime.store.delete_api_key(profile_name))
|
|
102
|
+
runtime.output.success({"credential_removed": bool(removed), "profile": profile_name})
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@auth_group.command("status", help="Show credential presence and source, never its value.")
|
|
106
|
+
@click.pass_obj
|
|
107
|
+
def auth_status(runtime: Runtime) -> None:
|
|
108
|
+
profile = invoke(runtime, lambda: runtime.profile)
|
|
109
|
+
runtime.output.success(
|
|
110
|
+
{
|
|
111
|
+
"api_url": profile.api_url,
|
|
112
|
+
"credential_configured": profile.api_key is not None,
|
|
113
|
+
"credential_source": profile.credential_source,
|
|
114
|
+
"profile": profile.name,
|
|
115
|
+
}
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@cli.group("config", help="Manage non-secret local profile configuration.")
|
|
120
|
+
def config_group() -> None:
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@config_group.command("set-api-url", help="Set a profile API URL (credentials are rejected).")
|
|
125
|
+
@click.argument("url")
|
|
126
|
+
@click.pass_obj
|
|
127
|
+
def config_set_api_url(runtime: Runtime, url: str) -> None:
|
|
128
|
+
profile_name = runtime.profile_name or runtime.store.default_profile
|
|
129
|
+
profile = invoke(runtime, lambda: runtime.store.set_api_url(profile_name, url))
|
|
130
|
+
runtime.output.success({"api_url": profile.api_url, "profile": profile.name})
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@config_group.command("show", help="Show the selected non-secret profile configuration.")
|
|
134
|
+
@click.pass_obj
|
|
135
|
+
def config_show(runtime: Runtime) -> None:
|
|
136
|
+
profile = invoke(runtime, lambda: runtime.profile)
|
|
137
|
+
runtime.output.success(
|
|
138
|
+
{
|
|
139
|
+
"api_url": profile.api_url,
|
|
140
|
+
"credential_configured": profile.api_key is not None,
|
|
141
|
+
"profile": profile.name,
|
|
142
|
+
}
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@config_group.command("profiles", help="List configured profiles without credential values.")
|
|
147
|
+
@click.pass_obj
|
|
148
|
+
def config_profiles(runtime: Runtime) -> None:
|
|
149
|
+
profiles = invoke(runtime, runtime.store.list_profiles)
|
|
150
|
+
runtime.output.success({"profiles": profiles})
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def main() -> None:
|
|
154
|
+
"""Console script entry point."""
|
|
155
|
+
|
|
156
|
+
cli()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
register_operation_commands(cli)
|
|
160
|
+
register_workflow_commands(cli)
|
|
161
|
+
|
|
162
|
+
__all__ = ["cli", "main"]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Root Click error boundary for deterministic machine-readable usage failures."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from originalpictures_cli.exit_codes import ExitCode
|
|
12
|
+
from originalpictures_cli.output import canonical_json
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class JsonUsageGroup(click.Group):
|
|
16
|
+
"""Preserve normal Click UX while making ``--json`` parsing errors canonical."""
|
|
17
|
+
|
|
18
|
+
def main(
|
|
19
|
+
self,
|
|
20
|
+
args: Sequence[str] | None = None,
|
|
21
|
+
prog_name: str | None = None,
|
|
22
|
+
complete_var: str | None = None,
|
|
23
|
+
standalone_mode: bool = True,
|
|
24
|
+
windows_expand_args: bool = True,
|
|
25
|
+
**extra: Any,
|
|
26
|
+
) -> Any:
|
|
27
|
+
resolved_args = list(args) if args is not None else sys.argv[1:]
|
|
28
|
+
json_requested = any(
|
|
29
|
+
argument == "--json" or argument.startswith("--json=") for argument in resolved_args
|
|
30
|
+
)
|
|
31
|
+
try:
|
|
32
|
+
result = super().main(
|
|
33
|
+
args=resolved_args,
|
|
34
|
+
prog_name=prog_name,
|
|
35
|
+
complete_var=complete_var,
|
|
36
|
+
standalone_mode=False,
|
|
37
|
+
windows_expand_args=windows_expand_args,
|
|
38
|
+
**extra,
|
|
39
|
+
)
|
|
40
|
+
if standalone_mode and isinstance(result, int):
|
|
41
|
+
raise SystemExit(result)
|
|
42
|
+
return result
|
|
43
|
+
except click.ClickException as error:
|
|
44
|
+
if json_requested:
|
|
45
|
+
payload = {
|
|
46
|
+
"error": {
|
|
47
|
+
"exit_code": int(ExitCode.USAGE),
|
|
48
|
+
"kind": "usage",
|
|
49
|
+
"message": error.format_message(),
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
click.echo(canonical_json(payload), err=True)
|
|
53
|
+
elif standalone_mode:
|
|
54
|
+
error.show()
|
|
55
|
+
if standalone_mode:
|
|
56
|
+
raise SystemExit(int(ExitCode.USAGE)) from None
|
|
57
|
+
raise
|
|
58
|
+
except click.exceptions.Exit as error:
|
|
59
|
+
if standalone_mode:
|
|
60
|
+
raise SystemExit(error.exit_code) from None
|
|
61
|
+
raise
|
|
62
|
+
except click.Abort:
|
|
63
|
+
if standalone_mode:
|
|
64
|
+
click.echo("Aborted!", err=True)
|
|
65
|
+
raise SystemExit(int(ExitCode.UNEXPECTED)) from None
|
|
66
|
+
raise
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
__all__ = ["JsonUsageGroup"]
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Non-secret profile configuration and OS keyring credential storage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import stat
|
|
9
|
+
import tempfile
|
|
10
|
+
from collections.abc import Mapping, MutableMapping
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Protocol
|
|
14
|
+
from urllib.parse import urlsplit
|
|
15
|
+
|
|
16
|
+
import keyring
|
|
17
|
+
from platformdirs import PlatformDirs
|
|
18
|
+
|
|
19
|
+
DEFAULT_API_URL = "http://localhost:8000"
|
|
20
|
+
DEFAULT_PROFILE = "default"
|
|
21
|
+
MAX_CONFIG_BYTES = 256 * 1024
|
|
22
|
+
_PROFILE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ConfigurationFailure(ValueError):
|
|
26
|
+
"""Local configuration is absent, unsafe, or invalid."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CredentialBackend(Protocol):
|
|
30
|
+
def get_password(self, service_name: str, username: str) -> str | None: ...
|
|
31
|
+
|
|
32
|
+
def set_password(self, service_name: str, username: str, password: str) -> None: ...
|
|
33
|
+
|
|
34
|
+
def delete_password(self, service_name: str, username: str) -> None: ...
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class ResolvedProfile:
|
|
39
|
+
name: str
|
|
40
|
+
api_url: str
|
|
41
|
+
api_key: str | None
|
|
42
|
+
credential_source: str | None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ConfigStore:
|
|
46
|
+
"""Read profile settings from platformdirs and credentials from keyring."""
|
|
47
|
+
|
|
48
|
+
service_name = "originalpictures-cli"
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
*,
|
|
53
|
+
config_dir: Path | None = None,
|
|
54
|
+
credential_backend: CredentialBackend | None = None,
|
|
55
|
+
environ: Mapping[str, str] | None = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
platform_dir = PlatformDirs("Original Pictures", "Original Pictures").user_config_path
|
|
58
|
+
self.config_dir = config_dir or platform_dir
|
|
59
|
+
self.path = self.config_dir / "config.json"
|
|
60
|
+
self._credentials = credential_backend or keyring
|
|
61
|
+
self._environ = environ if environ is not None else os.environ
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def default_profile(self) -> str:
|
|
65
|
+
return validate_profile_name(self._environ.get("ORIGINALPICTURES_PROFILE", DEFAULT_PROFILE))
|
|
66
|
+
|
|
67
|
+
def resolve(self, *, profile: str | None = None, api_url: str | None = None) -> ResolvedProfile:
|
|
68
|
+
name = validate_profile_name(profile or self.default_profile)
|
|
69
|
+
data = self._load()
|
|
70
|
+
profiles = data.get("profiles", {})
|
|
71
|
+
if not isinstance(profiles, dict):
|
|
72
|
+
raise ConfigurationFailure("configuration profiles must be an object")
|
|
73
|
+
profile_data = profiles.get(name, {})
|
|
74
|
+
if not isinstance(profile_data, dict):
|
|
75
|
+
raise ConfigurationFailure(f"profile {name!r} is malformed")
|
|
76
|
+
configured_url = profile_data.get("api_url", DEFAULT_API_URL)
|
|
77
|
+
env_url = self._environ.get("ORIGINALPICTURES_API_URL")
|
|
78
|
+
resolved_url = validate_api_url(api_url or env_url or configured_url)
|
|
79
|
+
|
|
80
|
+
env_key = self._environ.get("ORIGINALPICTURES_API_KEY")
|
|
81
|
+
if env_key:
|
|
82
|
+
return ResolvedProfile(name, resolved_url, _validate_api_key(env_key), "environment")
|
|
83
|
+
try:
|
|
84
|
+
stored_key = self._credentials.get_password(self.service_name, name)
|
|
85
|
+
except Exception as error: # keyring backends expose platform-specific exceptions
|
|
86
|
+
raise ConfigurationFailure("the operating-system keyring is unavailable") from error
|
|
87
|
+
if stored_key:
|
|
88
|
+
return ResolvedProfile(name, resolved_url, _validate_api_key(stored_key), "keyring")
|
|
89
|
+
return ResolvedProfile(name, resolved_url, None, None)
|
|
90
|
+
|
|
91
|
+
def set_api_url(self, profile: str, api_url: str) -> ResolvedProfile:
|
|
92
|
+
name = validate_profile_name(profile)
|
|
93
|
+
validated_url = validate_api_url(api_url)
|
|
94
|
+
data = self._load()
|
|
95
|
+
profiles = data.setdefault("profiles", {})
|
|
96
|
+
if not isinstance(profiles, dict):
|
|
97
|
+
raise ConfigurationFailure("configuration profiles must be an object")
|
|
98
|
+
profiles[name] = {"api_url": validated_url}
|
|
99
|
+
self._save(data)
|
|
100
|
+
return ResolvedProfile(name, validated_url, None, None)
|
|
101
|
+
|
|
102
|
+
def set_api_key(self, profile: str, api_key: str) -> None:
|
|
103
|
+
name = validate_profile_name(profile)
|
|
104
|
+
validated_key = _validate_api_key(api_key)
|
|
105
|
+
try:
|
|
106
|
+
self._credentials.set_password(self.service_name, name, validated_key)
|
|
107
|
+
except Exception as error:
|
|
108
|
+
raise ConfigurationFailure(
|
|
109
|
+
"the operating-system keyring rejected the credential"
|
|
110
|
+
) from error
|
|
111
|
+
|
|
112
|
+
def delete_api_key(self, profile: str) -> bool:
|
|
113
|
+
name = validate_profile_name(profile)
|
|
114
|
+
try:
|
|
115
|
+
if self._credentials.get_password(self.service_name, name) is None:
|
|
116
|
+
return False
|
|
117
|
+
self._credentials.delete_password(self.service_name, name)
|
|
118
|
+
except Exception as error:
|
|
119
|
+
raise ConfigurationFailure(
|
|
120
|
+
"the operating-system keyring could not remove the credential"
|
|
121
|
+
) from error
|
|
122
|
+
return True
|
|
123
|
+
|
|
124
|
+
def list_profiles(self) -> list[dict[str, object]]:
|
|
125
|
+
data = self._load()
|
|
126
|
+
profiles = data.get("profiles", {})
|
|
127
|
+
if not isinstance(profiles, dict):
|
|
128
|
+
raise ConfigurationFailure("configuration profiles must be an object")
|
|
129
|
+
names = sorted(set(profiles) | {self.default_profile})
|
|
130
|
+
result: list[dict[str, object]] = []
|
|
131
|
+
for name in names:
|
|
132
|
+
resolved = self.resolve(profile=name)
|
|
133
|
+
result.append(
|
|
134
|
+
{
|
|
135
|
+
"api_url": resolved.api_url,
|
|
136
|
+
"credential_configured": resolved.api_key is not None,
|
|
137
|
+
"credential_source": resolved.credential_source,
|
|
138
|
+
"name": name,
|
|
139
|
+
}
|
|
140
|
+
)
|
|
141
|
+
return result
|
|
142
|
+
|
|
143
|
+
def _load(self) -> MutableMapping[str, object]:
|
|
144
|
+
if not self.path.exists():
|
|
145
|
+
return {"profiles": {}}
|
|
146
|
+
if self.path.is_symlink():
|
|
147
|
+
raise ConfigurationFailure("refusing to read a symlinked configuration file")
|
|
148
|
+
file_mode = stat.S_IMODE(self.path.stat().st_mode)
|
|
149
|
+
if file_mode & 0o077:
|
|
150
|
+
raise ConfigurationFailure(
|
|
151
|
+
"configuration permissions must not allow group/other access"
|
|
152
|
+
)
|
|
153
|
+
if self.path.stat().st_size > MAX_CONFIG_BYTES:
|
|
154
|
+
raise ConfigurationFailure("configuration file exceeds the 256 KiB limit")
|
|
155
|
+
try:
|
|
156
|
+
value = json.loads(self.path.read_text(encoding="utf-8"))
|
|
157
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as error:
|
|
158
|
+
raise ConfigurationFailure("configuration file is not valid UTF-8 JSON") from error
|
|
159
|
+
if not isinstance(value, dict):
|
|
160
|
+
raise ConfigurationFailure("configuration root must be an object")
|
|
161
|
+
return value
|
|
162
|
+
|
|
163
|
+
def _save(self, data: Mapping[str, object]) -> None:
|
|
164
|
+
self.config_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
165
|
+
try:
|
|
166
|
+
os.chmod(self.config_dir, 0o700)
|
|
167
|
+
with tempfile.NamedTemporaryFile(
|
|
168
|
+
mode="w",
|
|
169
|
+
encoding="utf-8",
|
|
170
|
+
dir=self.config_dir,
|
|
171
|
+
prefix=".config-",
|
|
172
|
+
suffix=".json",
|
|
173
|
+
delete=False,
|
|
174
|
+
) as temporary:
|
|
175
|
+
json.dump(
|
|
176
|
+
data, temporary, ensure_ascii=False, separators=(",", ":"), sort_keys=True
|
|
177
|
+
)
|
|
178
|
+
temporary.write("\n")
|
|
179
|
+
temporary.flush()
|
|
180
|
+
os.fsync(temporary.fileno())
|
|
181
|
+
temporary_path = Path(temporary.name)
|
|
182
|
+
os.chmod(temporary_path, 0o600)
|
|
183
|
+
os.replace(temporary_path, self.path)
|
|
184
|
+
except OSError as error:
|
|
185
|
+
raise ConfigurationFailure("configuration could not be written securely") from error
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def validate_profile_name(value: str) -> str:
|
|
189
|
+
if not _PROFILE_RE.fullmatch(value):
|
|
190
|
+
raise ConfigurationFailure(
|
|
191
|
+
"profile name must be 1-64 letters, digits, dots, underscores, or hyphens"
|
|
192
|
+
)
|
|
193
|
+
return value
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def validate_api_url(value: object) -> str:
|
|
197
|
+
if not isinstance(value, str):
|
|
198
|
+
raise ConfigurationFailure("API URL must be a string")
|
|
199
|
+
parsed = urlsplit(value)
|
|
200
|
+
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
|
201
|
+
raise ConfigurationFailure("API URL must be an absolute http(s) URL")
|
|
202
|
+
if parsed.username or parsed.password or parsed.query or parsed.fragment:
|
|
203
|
+
raise ConfigurationFailure("API URL must not contain credentials, a query, or a fragment")
|
|
204
|
+
return value.rstrip("/")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _validate_api_key(value: str) -> str:
|
|
208
|
+
stripped = value.strip()
|
|
209
|
+
if not stripped or len(stripped) > 4096 or any(char.isspace() for char in stripped):
|
|
210
|
+
raise ConfigurationFailure("API credential must be 1-4096 non-whitespace characters")
|
|
211
|
+
return stripped
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
__all__ = [
|
|
215
|
+
"ConfigurationFailure",
|
|
216
|
+
"ConfigStore",
|
|
217
|
+
"DEFAULT_API_URL",
|
|
218
|
+
"DEFAULT_PROFILE",
|
|
219
|
+
"ResolvedProfile",
|
|
220
|
+
"validate_api_url",
|
|
221
|
+
"validate_profile_name",
|
|
222
|
+
]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Documented process exit codes for scripts and operators."""
|
|
2
|
+
|
|
3
|
+
from enum import IntEnum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ExitCode(IntEnum):
|
|
7
|
+
SUCCESS = 0
|
|
8
|
+
UNEXPECTED = 1
|
|
9
|
+
USAGE = 2
|
|
10
|
+
CONFIGURATION = 3
|
|
11
|
+
VALIDATION = 4
|
|
12
|
+
AUTHENTICATION = 5
|
|
13
|
+
PERMISSION = 6
|
|
14
|
+
NOT_FOUND = 7
|
|
15
|
+
CONFLICT = 8
|
|
16
|
+
RATE_LIMIT = 9
|
|
17
|
+
DEADLINE = 10
|
|
18
|
+
UNAVAILABLE = 11
|
|
19
|
+
INTEGRITY = 12
|
|
20
|
+
TOO_LARGE = 13
|
|
21
|
+
CANCELLED = 130
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
__all__ = ["ExitCode"]
|