admatrix-client 0.1.0__tar.gz
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.
- admatrix_client-0.1.0/.gitignore +8 -0
- admatrix_client-0.1.0/PKG-INFO +135 -0
- admatrix_client-0.1.0/README.md +125 -0
- admatrix_client-0.1.0/pyproject.toml +37 -0
- admatrix_client-0.1.0/src/admatrix_client/__init__.py +27 -0
- admatrix_client-0.1.0/src/admatrix_client/auth.py +223 -0
- admatrix_client-0.1.0/src/admatrix_client/client.py +315 -0
- admatrix_client-0.1.0/src/admatrix_client/errors.py +58 -0
- admatrix_client-0.1.0/src/admatrix_client/types.py +131 -0
- admatrix_client-0.1.0/src/yc_cli/__init__.py +1 -0
- admatrix_client-0.1.0/src/yc_cli/main.py +454 -0
- admatrix_client-0.1.0/tests/test_auth.py +152 -0
- admatrix_client-0.1.0/tests/test_cli.py +505 -0
- admatrix_client-0.1.0/tests/test_client.py +541 -0
- admatrix_client-0.1.0/uv.lock +612 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: admatrix-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Generic Python client and yc CLI for the ad-matrix model API
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: httpx<1,>=0.27
|
|
7
|
+
Requires-Dist: jsonschema<5,>=4.23
|
|
8
|
+
Requires-Dist: keyring<26,>=25
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# ad-matrix Python client and `yc` CLI
|
|
12
|
+
|
|
13
|
+
This package is the generic client for the model catalog exposed by ad-matrix.
|
|
14
|
+
It contains no model IDs or Provider-specific behavior: models, JSON Schemas,
|
|
15
|
+
selection metadata, and generation results are discovered at runtime.
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
Install the CLI as an isolated tool:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
uv tool install admatrix-client
|
|
23
|
+
# or: pipx install admatrix-client
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
For one-off use without a persistent installation:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
uvx --from admatrix-client yc --help
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Python integrations can install the same package with their normal package
|
|
33
|
+
manager and import `admatrix_client`.
|
|
34
|
+
|
|
35
|
+
The command-line client is installed as `yc`. Set the service URL explicitly:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
export YC_BASE_URL=https://your-ad-matrix-host
|
|
39
|
+
yc auth login
|
|
40
|
+
yc models list
|
|
41
|
+
yc generate --model MODEL_ID --input @payload.json --wait
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`yc` requires HTTPS so API Keys are never sent over plaintext transport.
|
|
45
|
+
Plain HTTP is accepted only for `localhost` and loopback addresses during
|
|
46
|
+
local development.
|
|
47
|
+
|
|
48
|
+
`--json` can appear before or after a command. It always writes one JSON
|
|
49
|
+
document to stdout, including for validation, authentication, rate-limit, and
|
|
50
|
+
service errors.
|
|
51
|
+
|
|
52
|
+
For automation, `YC_API_KEY` takes precedence over the stored credential.
|
|
53
|
+
matrix-agent injects its current runtime JWT through `RuntimeJWTAuth` and uses
|
|
54
|
+
the same `AdMatrixClient`; it does not need an API Key.
|
|
55
|
+
|
|
56
|
+
The plaintext API Key is never printed by `yc`. Login prefers the operating
|
|
57
|
+
system keychain and prompts through a hidden terminal input; it never accepts
|
|
58
|
+
the Key as a command-line argument. The fallback credential file must be
|
|
59
|
+
owner-only (`0600`) or the client refuses to load it. When a fallback file
|
|
60
|
+
exists, it remains authoritative until the next successful login stores the
|
|
61
|
+
new Key in the system keychain and removes the file.
|
|
62
|
+
|
|
63
|
+
Local input validation follows the JSON Schema draft declared by ad-matrix.
|
|
64
|
+
API failures preserve request IDs and structured rate-limit metadata such as
|
|
65
|
+
`limit_type` and `retry_after_seconds`.
|
|
66
|
+
|
|
67
|
+
## Commands
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
yc auth login|logout|status
|
|
71
|
+
yc models list
|
|
72
|
+
yc models schema MODEL_ID
|
|
73
|
+
yc upload ./reference.png
|
|
74
|
+
yc score --model MODEL_ID --input @payload.json
|
|
75
|
+
yc generate --model MODEL_ID --input @payload.json --request-num 2 --wait
|
|
76
|
+
yc generations get GENERATION_ID
|
|
77
|
+
yc generations wait GENERATION_ID
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Any string value inside the input JSON that starts with `@` and resolves to a
|
|
81
|
+
local file is uploaded first and replaced with its public URL. Relative paths
|
|
82
|
+
are resolved from the payload file:
|
|
83
|
+
|
|
84
|
+
```json
|
|
85
|
+
{
|
|
86
|
+
"prompt": "一只猫在雨夜的霓虹街道上奔跑",
|
|
87
|
+
"references": [{ "image_url": "@reference.png" }]
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Upload sessions are accounted per 亿创 user, not per API Key, so creating
|
|
92
|
+
multiple Keys does not multiply the allowance. The current defaults are 30
|
|
93
|
+
signed sessions per minute and 10 GiB of signed bytes per UTC day; deployments
|
|
94
|
+
may override them. A signed URL is valid for 10 minutes, and its required
|
|
95
|
+
`x-obs-expires` header makes the temporary object expire after 7 days. The
|
|
96
|
+
`yc upload --json` response includes `expires_at` and the latest server-side
|
|
97
|
+
deletion boundary in `delete_after`.
|
|
98
|
+
|
|
99
|
+
When `--wait` is used, successful managed resource files are downloaded to
|
|
100
|
+
`yc-output/<generation_id>/` by default, or to the directory supplied through
|
|
101
|
+
`--output`. Existing files are never overwritten. A partial generation keeps
|
|
102
|
+
its successful downloads and the complete failed-output list, then exits with
|
|
103
|
+
code 7. The terminal `score` is the final settled score; the score returned by
|
|
104
|
+
the initial generate call is only the estimate/reservation.
|
|
105
|
+
|
|
106
|
+
The CLI creates one idempotency key for each generate command. A lost response
|
|
107
|
+
or retryable gateway failure is retried once with that same key, so the retry
|
|
108
|
+
does not create a second task or charge.
|
|
109
|
+
Keys are user-global across all model generation routes, so one key must never
|
|
110
|
+
be reused for a different model or a different generation request.
|
|
111
|
+
|
|
112
|
+
## Exit codes
|
|
113
|
+
|
|
114
|
+
| Code | Meaning |
|
|
115
|
+
| --- | --- |
|
|
116
|
+
| 0 | Success |
|
|
117
|
+
| 2 | Command argument, JSON, or model Schema error |
|
|
118
|
+
| 3 | Missing or invalid authentication |
|
|
119
|
+
| 4 | Insufficient score |
|
|
120
|
+
| 5 | User/model rate or concurrency limit |
|
|
121
|
+
| 6 | Network, timeout, or service failure |
|
|
122
|
+
| 7 | Generation terminal failure or partial failure |
|
|
123
|
+
|
|
124
|
+
## Development
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
uv sync
|
|
128
|
+
uv run pytest
|
|
129
|
+
uv run ruff check .
|
|
130
|
+
uv build
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Release artifacts are published to PyPI so matrix-agent and external tools
|
|
134
|
+
resolve the same immutable package source and hashes. Do not use a sibling
|
|
135
|
+
filesystem dependency or copy the HTTP client into a consuming repository.
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# ad-matrix Python client and `yc` CLI
|
|
2
|
+
|
|
3
|
+
This package is the generic client for the model catalog exposed by ad-matrix.
|
|
4
|
+
It contains no model IDs or Provider-specific behavior: models, JSON Schemas,
|
|
5
|
+
selection metadata, and generation results are discovered at runtime.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
Install the CLI as an isolated tool:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
uv tool install admatrix-client
|
|
13
|
+
# or: pipx install admatrix-client
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
For one-off use without a persistent installation:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
uvx --from admatrix-client yc --help
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Python integrations can install the same package with their normal package
|
|
23
|
+
manager and import `admatrix_client`.
|
|
24
|
+
|
|
25
|
+
The command-line client is installed as `yc`. Set the service URL explicitly:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
export YC_BASE_URL=https://your-ad-matrix-host
|
|
29
|
+
yc auth login
|
|
30
|
+
yc models list
|
|
31
|
+
yc generate --model MODEL_ID --input @payload.json --wait
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`yc` requires HTTPS so API Keys are never sent over plaintext transport.
|
|
35
|
+
Plain HTTP is accepted only for `localhost` and loopback addresses during
|
|
36
|
+
local development.
|
|
37
|
+
|
|
38
|
+
`--json` can appear before or after a command. It always writes one JSON
|
|
39
|
+
document to stdout, including for validation, authentication, rate-limit, and
|
|
40
|
+
service errors.
|
|
41
|
+
|
|
42
|
+
For automation, `YC_API_KEY` takes precedence over the stored credential.
|
|
43
|
+
matrix-agent injects its current runtime JWT through `RuntimeJWTAuth` and uses
|
|
44
|
+
the same `AdMatrixClient`; it does not need an API Key.
|
|
45
|
+
|
|
46
|
+
The plaintext API Key is never printed by `yc`. Login prefers the operating
|
|
47
|
+
system keychain and prompts through a hidden terminal input; it never accepts
|
|
48
|
+
the Key as a command-line argument. The fallback credential file must be
|
|
49
|
+
owner-only (`0600`) or the client refuses to load it. When a fallback file
|
|
50
|
+
exists, it remains authoritative until the next successful login stores the
|
|
51
|
+
new Key in the system keychain and removes the file.
|
|
52
|
+
|
|
53
|
+
Local input validation follows the JSON Schema draft declared by ad-matrix.
|
|
54
|
+
API failures preserve request IDs and structured rate-limit metadata such as
|
|
55
|
+
`limit_type` and `retry_after_seconds`.
|
|
56
|
+
|
|
57
|
+
## Commands
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
yc auth login|logout|status
|
|
61
|
+
yc models list
|
|
62
|
+
yc models schema MODEL_ID
|
|
63
|
+
yc upload ./reference.png
|
|
64
|
+
yc score --model MODEL_ID --input @payload.json
|
|
65
|
+
yc generate --model MODEL_ID --input @payload.json --request-num 2 --wait
|
|
66
|
+
yc generations get GENERATION_ID
|
|
67
|
+
yc generations wait GENERATION_ID
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Any string value inside the input JSON that starts with `@` and resolves to a
|
|
71
|
+
local file is uploaded first and replaced with its public URL. Relative paths
|
|
72
|
+
are resolved from the payload file:
|
|
73
|
+
|
|
74
|
+
```json
|
|
75
|
+
{
|
|
76
|
+
"prompt": "一只猫在雨夜的霓虹街道上奔跑",
|
|
77
|
+
"references": [{ "image_url": "@reference.png" }]
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Upload sessions are accounted per 亿创 user, not per API Key, so creating
|
|
82
|
+
multiple Keys does not multiply the allowance. The current defaults are 30
|
|
83
|
+
signed sessions per minute and 10 GiB of signed bytes per UTC day; deployments
|
|
84
|
+
may override them. A signed URL is valid for 10 minutes, and its required
|
|
85
|
+
`x-obs-expires` header makes the temporary object expire after 7 days. The
|
|
86
|
+
`yc upload --json` response includes `expires_at` and the latest server-side
|
|
87
|
+
deletion boundary in `delete_after`.
|
|
88
|
+
|
|
89
|
+
When `--wait` is used, successful managed resource files are downloaded to
|
|
90
|
+
`yc-output/<generation_id>/` by default, or to the directory supplied through
|
|
91
|
+
`--output`. Existing files are never overwritten. A partial generation keeps
|
|
92
|
+
its successful downloads and the complete failed-output list, then exits with
|
|
93
|
+
code 7. The terminal `score` is the final settled score; the score returned by
|
|
94
|
+
the initial generate call is only the estimate/reservation.
|
|
95
|
+
|
|
96
|
+
The CLI creates one idempotency key for each generate command. A lost response
|
|
97
|
+
or retryable gateway failure is retried once with that same key, so the retry
|
|
98
|
+
does not create a second task or charge.
|
|
99
|
+
Keys are user-global across all model generation routes, so one key must never
|
|
100
|
+
be reused for a different model or a different generation request.
|
|
101
|
+
|
|
102
|
+
## Exit codes
|
|
103
|
+
|
|
104
|
+
| Code | Meaning |
|
|
105
|
+
| --- | --- |
|
|
106
|
+
| 0 | Success |
|
|
107
|
+
| 2 | Command argument, JSON, or model Schema error |
|
|
108
|
+
| 3 | Missing or invalid authentication |
|
|
109
|
+
| 4 | Insufficient score |
|
|
110
|
+
| 5 | User/model rate or concurrency limit |
|
|
111
|
+
| 6 | Network, timeout, or service failure |
|
|
112
|
+
| 7 | Generation terminal failure or partial failure |
|
|
113
|
+
|
|
114
|
+
## Development
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
uv sync
|
|
118
|
+
uv run pytest
|
|
119
|
+
uv run ruff check .
|
|
120
|
+
uv build
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Release artifacts are published to PyPI so matrix-agent and external tools
|
|
124
|
+
resolve the same immutable package source and hashes. Do not use a sibling
|
|
125
|
+
filesystem dependency or copy the HTTP client into a consuming repository.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "admatrix-client"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Generic Python client and yc CLI for the ad-matrix model API"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"httpx>=0.27,<1",
|
|
9
|
+
"jsonschema>=4.23,<5",
|
|
10
|
+
"keyring>=25,<26",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[project.scripts]
|
|
14
|
+
yc = "yc_cli.main:run"
|
|
15
|
+
|
|
16
|
+
[build-system]
|
|
17
|
+
requires = ["hatchling"]
|
|
18
|
+
build-backend = "hatchling.build"
|
|
19
|
+
|
|
20
|
+
[tool.hatch.build.targets.wheel]
|
|
21
|
+
packages = ["src/admatrix_client", "src/yc_cli"]
|
|
22
|
+
|
|
23
|
+
[dependency-groups]
|
|
24
|
+
dev = [
|
|
25
|
+
"pytest>=9.0.3,<10",
|
|
26
|
+
"ruff>=0.12,<1",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[tool.pytest.ini_options]
|
|
30
|
+
testpaths = ["tests"]
|
|
31
|
+
|
|
32
|
+
[tool.ruff]
|
|
33
|
+
line-length = 100
|
|
34
|
+
target-version = "py311"
|
|
35
|
+
|
|
36
|
+
[tool.ruff.lint]
|
|
37
|
+
select = ["E", "F", "I", "B", "UP"]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from .auth import APIKeyAuth, AuthProvider, RuntimeJWTAuth
|
|
2
|
+
from .client import AdMatrixClient
|
|
3
|
+
from .errors import (
|
|
4
|
+
AdMatrixAPIError,
|
|
5
|
+
AdMatrixError,
|
|
6
|
+
AdMatrixTimeoutError,
|
|
7
|
+
AdMatrixTransportError,
|
|
8
|
+
AdMatrixValidationError,
|
|
9
|
+
)
|
|
10
|
+
from .types import Generation, Model, ModelSchema, Score, UploadedAsset
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"APIKeyAuth",
|
|
14
|
+
"AdMatrixAPIError",
|
|
15
|
+
"AdMatrixClient",
|
|
16
|
+
"AdMatrixError",
|
|
17
|
+
"AdMatrixTimeoutError",
|
|
18
|
+
"AdMatrixTransportError",
|
|
19
|
+
"AdMatrixValidationError",
|
|
20
|
+
"AuthProvider",
|
|
21
|
+
"Generation",
|
|
22
|
+
"Model",
|
|
23
|
+
"ModelSchema",
|
|
24
|
+
"RuntimeJWTAuth",
|
|
25
|
+
"Score",
|
|
26
|
+
"UploadedAsset",
|
|
27
|
+
]
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
import tempfile
|
|
8
|
+
from collections.abc import Callable, Mapping
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Protocol
|
|
11
|
+
from urllib.parse import urlparse
|
|
12
|
+
|
|
13
|
+
import keyring
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AuthProvider(Protocol):
|
|
17
|
+
def headers(self) -> Mapping[str, str]: ...
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class APIKeyAuth:
|
|
21
|
+
def __init__(self, api_key: str) -> None:
|
|
22
|
+
if not api_key:
|
|
23
|
+
raise ValueError("api_key is required")
|
|
24
|
+
self._api_key = api_key
|
|
25
|
+
|
|
26
|
+
def headers(self) -> Mapping[str, str]:
|
|
27
|
+
return {"X-API-KEY": self._api_key}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class RuntimeJWTAuth:
|
|
31
|
+
def __init__(self, token_supplier: Callable[[], str]) -> None:
|
|
32
|
+
self._token_supplier = token_supplier
|
|
33
|
+
|
|
34
|
+
def headers(self) -> Mapping[str, str]:
|
|
35
|
+
token = self._token_supplier()
|
|
36
|
+
if not token:
|
|
37
|
+
raise ValueError("runtime JWT supplier returned an empty token")
|
|
38
|
+
return {"Authorization": f"Bearer {token}"}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def validate_api_key_transport(base_url: str) -> None:
|
|
42
|
+
"""Reject plaintext API-Key transport except on loopback hosts."""
|
|
43
|
+
parsed = urlparse(base_url)
|
|
44
|
+
hostname = parsed.hostname
|
|
45
|
+
if parsed.scheme == "https" and hostname:
|
|
46
|
+
return
|
|
47
|
+
if parsed.scheme == "http" and hostname:
|
|
48
|
+
is_loopback = hostname == "localhost" or hostname.endswith(".localhost")
|
|
49
|
+
if not is_loopback:
|
|
50
|
+
try:
|
|
51
|
+
is_loopback = ipaddress.ip_address(hostname).is_loopback
|
|
52
|
+
except ValueError:
|
|
53
|
+
is_loopback = False
|
|
54
|
+
if is_loopback:
|
|
55
|
+
return
|
|
56
|
+
raise ValueError(
|
|
57
|
+
"API Key endpoints must use HTTPS; only localhost and loopback addresses may use HTTP"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class KeyringBackend(Protocol):
|
|
62
|
+
def get_password(self, service: str, username: str) -> str | None: ...
|
|
63
|
+
|
|
64
|
+
def set_password(self, service: str, username: str, value: str) -> None: ...
|
|
65
|
+
|
|
66
|
+
def delete_password(self, service: str, username: str) -> None: ...
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class CredentialStore:
|
|
70
|
+
service = "yc-admatrix"
|
|
71
|
+
username = "api-key"
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
*,
|
|
76
|
+
path: Path | None = None,
|
|
77
|
+
keyring_backend: KeyringBackend | None = None,
|
|
78
|
+
) -> None:
|
|
79
|
+
configured_path = os.getenv("YC_CREDENTIAL_FILE")
|
|
80
|
+
self.path = path or (
|
|
81
|
+
Path(configured_path)
|
|
82
|
+
if configured_path
|
|
83
|
+
else Path.home() / ".config" / "yc" / "credentials.json"
|
|
84
|
+
)
|
|
85
|
+
self._keyring = keyring_backend or keyring
|
|
86
|
+
|
|
87
|
+
def save(self, api_key: str) -> str:
|
|
88
|
+
if not api_key:
|
|
89
|
+
raise ValueError("API Key cannot be empty")
|
|
90
|
+
try:
|
|
91
|
+
self._keyring.set_password(self.service, self.username, api_key)
|
|
92
|
+
self._delete_file()
|
|
93
|
+
return "keyring"
|
|
94
|
+
except Exception:
|
|
95
|
+
try:
|
|
96
|
+
existing = self._keyring.get_password(self.service, self.username)
|
|
97
|
+
except Exception:
|
|
98
|
+
existing = None
|
|
99
|
+
if existing == api_key:
|
|
100
|
+
self._delete_file()
|
|
101
|
+
return "keyring"
|
|
102
|
+
if existing:
|
|
103
|
+
try:
|
|
104
|
+
self._keyring.delete_password(self.service, self.username)
|
|
105
|
+
except Exception as delete_error:
|
|
106
|
+
raise OSError(
|
|
107
|
+
"cannot replace the existing keyring API Key; "
|
|
108
|
+
"the old credential is still configured"
|
|
109
|
+
) from delete_error
|
|
110
|
+
try:
|
|
111
|
+
remaining = self._keyring.get_password(self.service, self.username)
|
|
112
|
+
except Exception as verify_error:
|
|
113
|
+
raise OSError(
|
|
114
|
+
"cannot verify removal of the existing keyring API Key"
|
|
115
|
+
) from verify_error
|
|
116
|
+
if remaining:
|
|
117
|
+
raise OSError(
|
|
118
|
+
"the existing keyring API Key could not be removed"
|
|
119
|
+
) from None
|
|
120
|
+
self._save_file(api_key)
|
|
121
|
+
return "file"
|
|
122
|
+
|
|
123
|
+
def load(self) -> str | None:
|
|
124
|
+
# A file only remains after keyring storage failed. Keep that fallback
|
|
125
|
+
# authoritative so a temporarily unavailable keyring cannot later
|
|
126
|
+
# resurrect an older credential.
|
|
127
|
+
file_value = self._load_file()
|
|
128
|
+
if file_value:
|
|
129
|
+
return file_value
|
|
130
|
+
try:
|
|
131
|
+
value = self._keyring.get_password(self.service, self.username)
|
|
132
|
+
if value:
|
|
133
|
+
return value
|
|
134
|
+
except Exception:
|
|
135
|
+
pass
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
def source(self) -> str | None:
|
|
139
|
+
if self._load_file():
|
|
140
|
+
return "file"
|
|
141
|
+
try:
|
|
142
|
+
if self._keyring.get_password(self.service, self.username):
|
|
143
|
+
return "keyring"
|
|
144
|
+
except Exception:
|
|
145
|
+
pass
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
def delete(self) -> None:
|
|
149
|
+
keyring_error: Exception | None = None
|
|
150
|
+
try:
|
|
151
|
+
self._keyring.delete_password(self.service, self.username)
|
|
152
|
+
except Exception as error:
|
|
153
|
+
try:
|
|
154
|
+
remaining = self._keyring.get_password(self.service, self.username)
|
|
155
|
+
except Exception:
|
|
156
|
+
keyring_error = error
|
|
157
|
+
else:
|
|
158
|
+
if remaining:
|
|
159
|
+
keyring_error = error
|
|
160
|
+
self._delete_file()
|
|
161
|
+
if keyring_error is not None:
|
|
162
|
+
raise OSError(
|
|
163
|
+
"cannot remove or verify removal of the keyring API Key"
|
|
164
|
+
) from keyring_error
|
|
165
|
+
|
|
166
|
+
def _save_file(self, api_key: str) -> None:
|
|
167
|
+
self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
168
|
+
descriptor, temporary_name = tempfile.mkstemp(
|
|
169
|
+
prefix=f".{self.path.name}.",
|
|
170
|
+
suffix=".tmp",
|
|
171
|
+
dir=self.path.parent,
|
|
172
|
+
)
|
|
173
|
+
temporary = Path(temporary_name)
|
|
174
|
+
try:
|
|
175
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
176
|
+
descriptor = -1
|
|
177
|
+
json.dump({"api_key": api_key}, handle)
|
|
178
|
+
handle.write("\n")
|
|
179
|
+
os.chmod(temporary, 0o600)
|
|
180
|
+
os.replace(temporary, self.path)
|
|
181
|
+
os.chmod(self.path, 0o600)
|
|
182
|
+
finally:
|
|
183
|
+
if descriptor >= 0:
|
|
184
|
+
os.close(descriptor)
|
|
185
|
+
if temporary.exists():
|
|
186
|
+
temporary.unlink()
|
|
187
|
+
|
|
188
|
+
def _load_file(self) -> str | None:
|
|
189
|
+
try:
|
|
190
|
+
file_stat = self.path.lstat()
|
|
191
|
+
except FileNotFoundError:
|
|
192
|
+
return None
|
|
193
|
+
if os.name == "posix":
|
|
194
|
+
mode = stat.S_IMODE(file_stat.st_mode)
|
|
195
|
+
if stat.S_ISLNK(file_stat.st_mode) or mode & 0o077:
|
|
196
|
+
raise PermissionError(
|
|
197
|
+
f"credential file {self.path} must be an owner-only 0600 file"
|
|
198
|
+
)
|
|
199
|
+
if hasattr(os, "getuid") and file_stat.st_uid != os.getuid():
|
|
200
|
+
raise PermissionError(
|
|
201
|
+
f"credential file {self.path} must be owned by the current user"
|
|
202
|
+
)
|
|
203
|
+
try:
|
|
204
|
+
payload = json.loads(self.path.read_text(encoding="utf-8"))
|
|
205
|
+
except (OSError, ValueError, TypeError):
|
|
206
|
+
return None
|
|
207
|
+
value = payload.get("api_key")
|
|
208
|
+
return value if isinstance(value, str) and value else None
|
|
209
|
+
|
|
210
|
+
def _delete_file(self) -> None:
|
|
211
|
+
try:
|
|
212
|
+
self.path.unlink()
|
|
213
|
+
except FileNotFoundError:
|
|
214
|
+
pass
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def resolve_api_key(store: CredentialStore | None = None) -> tuple[str | None, str | None]:
|
|
218
|
+
environment = os.getenv("YC_API_KEY")
|
|
219
|
+
if environment:
|
|
220
|
+
return environment, "environment"
|
|
221
|
+
credential_store = store or CredentialStore()
|
|
222
|
+
value = credential_store.load()
|
|
223
|
+
return value, credential_store.source() if value else None
|