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.
@@ -0,0 +1,7 @@
1
+ """Original Pictures command-line client."""
2
+
3
+ from originalpictures_cli.exit_codes import ExitCode
4
+
5
+ __version__ = "0.1.0"
6
+
7
+ __all__ = ["ExitCode", "__version__"]
@@ -0,0 +1,5 @@
1
+ """Module entry point for ``python -m originalpictures_cli``."""
2
+
3
+ from originalpictures_cli.cli import main
4
+
5
+ main()
@@ -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"]