onepin 0.2.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.
- onepin/.fern/metadata.json +14 -0
- onepin/CONTRIBUTING.md +125 -0
- onepin/__init__.py +48 -0
- onepin/_cli/__init__.py +13 -0
- onepin/_cli/_ctx.py +28 -0
- onepin/_cli/_http.py +126 -0
- onepin/_cli/_state.py +7 -0
- onepin/_cli/auth/__init__.py +3 -0
- onepin/_cli/auth/credentials.py +114 -0
- onepin/_cli/auth/resolver.py +76 -0
- onepin/_cli/commands/__init__.py +3 -0
- onepin/_cli/commands/_registry.py +17 -0
- onepin/_cli/commands/auth.py +149 -0
- onepin/_cli/commands/templates.py +44 -0
- onepin/_cli/commands/uploads.py +52 -0
- onepin/_cli/commands/voices.py +21 -0
- onepin/_cli/commands/workflows.py +54 -0
- onepin/_cli/main.py +80 -0
- onepin/_cli/py.typed +0 -0
- onepin/_cli/render.py +77 -0
- onepin/_default_clients.py +30 -0
- onepin/client.py +162 -0
- onepin/core/__init__.py +127 -0
- onepin/core/api_error.py +23 -0
- onepin/core/client_wrapper.py +104 -0
- onepin/core/datetime_utils.py +70 -0
- onepin/core/file.py +67 -0
- onepin/core/force_multipart.py +18 -0
- onepin/core/http_client.py +839 -0
- onepin/core/http_response.py +59 -0
- onepin/core/http_sse/__init__.py +42 -0
- onepin/core/http_sse/_api.py +148 -0
- onepin/core/http_sse/_decoders.py +61 -0
- onepin/core/http_sse/_exceptions.py +7 -0
- onepin/core/http_sse/_models.py +17 -0
- onepin/core/jsonable_encoder.py +120 -0
- onepin/core/logging.py +107 -0
- onepin/core/parse_error.py +36 -0
- onepin/core/pydantic_utilities.py +646 -0
- onepin/core/query_encoder.py +58 -0
- onepin/core/remove_none_from_dict.py +11 -0
- onepin/core/request_options.py +35 -0
- onepin/core/serialization.py +276 -0
- onepin/environment.py +15 -0
- onepin/py.typed +0 -0
- onepin/reference.md +1 -0
- onepin/tests/conftest.py +21 -0
- onepin/tests/test_aiohttp_autodetect.py +113 -0
- onepin-0.2.0.dist-info/METADATA +98 -0
- onepin-0.2.0.dist-info/RECORD +53 -0
- onepin-0.2.0.dist-info/WHEEL +4 -0
- onepin-0.2.0.dist-info/entry_points.txt +2 -0
- onepin-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"cliVersion": "5.38.0",
|
|
3
|
+
"generatorName": "fernapi/fern-python-sdk",
|
|
4
|
+
"generatorVersion": "latest",
|
|
5
|
+
"generatorConfig": {
|
|
6
|
+
"client_class_name": "OnePinClient",
|
|
7
|
+
"package_name": "onepin",
|
|
8
|
+
"flat_layout": false
|
|
9
|
+
},
|
|
10
|
+
"originGitCommit": "babfb180c9f10bd3e41e7f774d958819ad52e34e",
|
|
11
|
+
"originGitCommitIsDirty": false,
|
|
12
|
+
"invokedBy": "ci",
|
|
13
|
+
"ciProvider": "github"
|
|
14
|
+
}
|
onepin/CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
Thanks for your interest in contributing to this SDK! This document provides guidelines for contributing to the project.
|
|
4
|
+
|
|
5
|
+
## Getting Started
|
|
6
|
+
|
|
7
|
+
### Prerequisites
|
|
8
|
+
|
|
9
|
+
- Python 3.9+
|
|
10
|
+
- pip
|
|
11
|
+
- poetry
|
|
12
|
+
|
|
13
|
+
### Installation
|
|
14
|
+
|
|
15
|
+
Install the project dependencies:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
poetry install
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### Building
|
|
22
|
+
|
|
23
|
+
Build the project:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
poetry build
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### Testing
|
|
30
|
+
|
|
31
|
+
Run the test suite:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
poetry run pytest
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Linting and Formatting
|
|
38
|
+
|
|
39
|
+
Check code style:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
poetry run ruff check .
|
|
43
|
+
poetry run ruff format .
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Type Checking
|
|
47
|
+
|
|
48
|
+
Run the type checker:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
poetry run mypy .
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## About Generated Code
|
|
55
|
+
|
|
56
|
+
**Important**: Most files in this SDK are automatically generated by [Fern](https://buildwithfern.com) from the API definition. Direct modifications to generated files will be overwritten the next time the SDK is generated.
|
|
57
|
+
|
|
58
|
+
### Generated Files
|
|
59
|
+
|
|
60
|
+
The following directories contain generated code:
|
|
61
|
+
- `src/` - API client classes and types
|
|
62
|
+
- Most Python files in the project
|
|
63
|
+
|
|
64
|
+
### How to Customize
|
|
65
|
+
|
|
66
|
+
If you need to customize the SDK, you have two options:
|
|
67
|
+
|
|
68
|
+
#### Option 1: Use `.fernignore`
|
|
69
|
+
|
|
70
|
+
For custom code that should persist across SDK regenerations:
|
|
71
|
+
|
|
72
|
+
1. Create a `.fernignore` file in the project root
|
|
73
|
+
2. Add file patterns for files you want to preserve (similar to `.gitignore` syntax)
|
|
74
|
+
3. Add your custom code to those files
|
|
75
|
+
|
|
76
|
+
Files listed in `.fernignore` will not be overwritten when the SDK is regenerated.
|
|
77
|
+
|
|
78
|
+
For more information, see the [Fern documentation on custom code](https://buildwithfern.com/learn/sdks/overview/custom-code).
|
|
79
|
+
|
|
80
|
+
#### Option 2: Contribute to the Generator
|
|
81
|
+
|
|
82
|
+
If you want to change how code is generated for all users of this SDK:
|
|
83
|
+
|
|
84
|
+
1. The Python SDK generator lives in the [Fern repository](https://github.com/fern-api/fern)
|
|
85
|
+
2. Generator code is located at `generators/python-v2/`
|
|
86
|
+
3. Follow the [Fern contributing guidelines](https://github.com/fern-api/fern/blob/main/CONTRIBUTING.md)
|
|
87
|
+
4. Submit a pull request with your changes to the generator
|
|
88
|
+
|
|
89
|
+
This approach is best for:
|
|
90
|
+
- Bug fixes in generated code
|
|
91
|
+
- New features that would benefit all users
|
|
92
|
+
- Improvements to code generation patterns
|
|
93
|
+
|
|
94
|
+
## Making Changes
|
|
95
|
+
|
|
96
|
+
### Workflow
|
|
97
|
+
|
|
98
|
+
1. Create a new branch for your changes
|
|
99
|
+
2. Make your modifications
|
|
100
|
+
3. Run tests to ensure nothing breaks: `poetry run pytest`
|
|
101
|
+
4. Run linting and formatting: `poetry run ruff check .` and `poetry run ruff format .`
|
|
102
|
+
5. Run type checking: `poetry run mypy .`
|
|
103
|
+
6. Build the project: `poetry build`
|
|
104
|
+
7. Commit your changes with a clear commit message
|
|
105
|
+
8. Push your branch and create a pull request
|
|
106
|
+
|
|
107
|
+
### Commit Messages
|
|
108
|
+
|
|
109
|
+
Write clear, descriptive commit messages that explain what changed and why.
|
|
110
|
+
|
|
111
|
+
### Code Style
|
|
112
|
+
|
|
113
|
+
This project uses automated code formatting and linting. Run `poetry run ruff format .` and `poetry run ruff check .` before committing to ensure your code meets the project's style guidelines.
|
|
114
|
+
|
|
115
|
+
## Questions or Issues?
|
|
116
|
+
|
|
117
|
+
If you have questions or run into issues:
|
|
118
|
+
|
|
119
|
+
1. Check the [Fern documentation](https://buildwithfern.com)
|
|
120
|
+
2. Search existing [GitHub issues](https://github.com/fern-api/fern/issues)
|
|
121
|
+
3. Open a new issue if your question hasn't been addressed
|
|
122
|
+
|
|
123
|
+
## License
|
|
124
|
+
|
|
125
|
+
By contributing to this project, you agree that your contributions will be licensed under the same license as the project.
|
onepin/__init__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# This file was auto-generated by Fern from our API Definition.
|
|
2
|
+
|
|
3
|
+
# isort: skip_file
|
|
4
|
+
|
|
5
|
+
import typing
|
|
6
|
+
from importlib import import_module
|
|
7
|
+
|
|
8
|
+
if typing.TYPE_CHECKING:
|
|
9
|
+
from ._default_clients import DefaultAioHttpClient, DefaultAsyncHttpxClient
|
|
10
|
+
from .client import AsyncOnePinClient, OnePinClient
|
|
11
|
+
from .environment import OnePinClientEnvironment
|
|
12
|
+
_dynamic_imports: typing.Dict[str, str] = {
|
|
13
|
+
"AsyncOnePinClient": ".client",
|
|
14
|
+
"DefaultAioHttpClient": "._default_clients",
|
|
15
|
+
"DefaultAsyncHttpxClient": "._default_clients",
|
|
16
|
+
"OnePinClient": ".client",
|
|
17
|
+
"OnePinClientEnvironment": ".environment",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def __getattr__(attr_name: str) -> typing.Any:
|
|
22
|
+
module_name = _dynamic_imports.get(attr_name)
|
|
23
|
+
if module_name is None:
|
|
24
|
+
raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}")
|
|
25
|
+
try:
|
|
26
|
+
module = import_module(module_name, __package__)
|
|
27
|
+
if module_name == f".{attr_name}":
|
|
28
|
+
return module
|
|
29
|
+
else:
|
|
30
|
+
return getattr(module, attr_name)
|
|
31
|
+
except ImportError as e:
|
|
32
|
+
raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e
|
|
33
|
+
except AttributeError as e:
|
|
34
|
+
raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def __dir__():
|
|
38
|
+
lazy_attrs = list(_dynamic_imports.keys())
|
|
39
|
+
return sorted(lazy_attrs)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"AsyncOnePinClient",
|
|
44
|
+
"DefaultAioHttpClient",
|
|
45
|
+
"DefaultAsyncHttpxClient",
|
|
46
|
+
"OnePinClient",
|
|
47
|
+
"OnePinClientEnvironment",
|
|
48
|
+
]
|
onepin/_cli/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""OnePin CLI -- hand-rolled Typer CLI atop the Fern-generated SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import PackageNotFoundError
|
|
6
|
+
from importlib.metadata import version as _pkg_version
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
__version__ = _pkg_version("onepin")
|
|
10
|
+
except PackageNotFoundError: # editable install pre-build
|
|
11
|
+
__version__ = "0.0.0+local"
|
|
12
|
+
|
|
13
|
+
__all__ = ["__version__"]
|
onepin/_cli/_ctx.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Build OnePinClient from resolved credentials.
|
|
2
|
+
|
|
3
|
+
Pending Fern SDK regen -- raises NotImplementedError until client.py exists.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from onepin._cli.auth.resolver import ResolvedCredentials
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def build_client(creds: ResolvedCredentials) -> Any:
|
|
14
|
+
"""Build and return an OnePinClient from resolved credentials.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
creds: Resolved credentials from the priority chain.
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
OnePinClient instance.
|
|
21
|
+
|
|
22
|
+
Raises:
|
|
23
|
+
NotImplementedError: Until the first Fern SDK regen lands.
|
|
24
|
+
"""
|
|
25
|
+
raise NotImplementedError(
|
|
26
|
+
"OnePinClient is not yet available -- Fern SDK regen has not run yet. "
|
|
27
|
+
"See podonos/onepin-sdks for the Fern configuration."
|
|
28
|
+
)
|
onepin/_cli/_http.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Shared httpx helper for CLI auth calls.
|
|
2
|
+
|
|
3
|
+
Provides a typed exception hierarchy and a single _call_whoami() function
|
|
4
|
+
used by login, logout, and whoami commands.
|
|
5
|
+
|
|
6
|
+
No Fern SDK dependency -- direct httpx calls only.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
from typing import Any, Dict, Optional
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from onepin._cli import __version__
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class OnePinHTTPError(Exception):
|
|
20
|
+
"""Base error for all HTTP-layer failures.
|
|
21
|
+
|
|
22
|
+
Attributes:
|
|
23
|
+
status_code: HTTP status code, or None for network-level errors.
|
|
24
|
+
error_code: Machine-readable error code from the response envelope.
|
|
25
|
+
message: Human-readable message.
|
|
26
|
+
request_id: Request-ID from the response meta envelope, if available.
|
|
27
|
+
response_body: Raw response body string, if available.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
message: str,
|
|
33
|
+
*,
|
|
34
|
+
status_code: Optional[int] = None,
|
|
35
|
+
error_code: str = "HTTP_ERROR",
|
|
36
|
+
request_id: Optional[str] = None,
|
|
37
|
+
response_body: Optional[str] = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
super().__init__(message)
|
|
40
|
+
self.status_code = status_code
|
|
41
|
+
self.error_code = error_code
|
|
42
|
+
self.message = message
|
|
43
|
+
self.request_id = request_id
|
|
44
|
+
self.response_body = response_body
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class OnePinAuthError(OnePinHTTPError):
|
|
48
|
+
"""Raised for 401 / 403 responses."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class OnePinNetworkError(OnePinHTTPError):
|
|
52
|
+
"""Raised for connection / timeout failures (no status code)."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _user_agent() -> str:
|
|
56
|
+
major = sys.version_info.major
|
|
57
|
+
minor = sys.version_info.minor
|
|
58
|
+
return f"onepin-python/{__version__} python/{major}.{minor}"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _call_whoami(key: str, base_url: str, timeout: float = 10.0) -> Dict[str, Any]:
|
|
62
|
+
"""Call GET /api/v1/auth/whoami and return the ``data`` field.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
key: API key to authenticate with.
|
|
66
|
+
base_url: Base URL of the OnePin API (no trailing slash).
|
|
67
|
+
timeout: Request timeout in seconds.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Parsed ``data`` dict from the response envelope.
|
|
71
|
+
|
|
72
|
+
Raises:
|
|
73
|
+
OnePinAuthError: On 401 or 403.
|
|
74
|
+
OnePinNetworkError: On connection or timeout failure.
|
|
75
|
+
OnePinHTTPError: On any other non-2xx response.
|
|
76
|
+
"""
|
|
77
|
+
url = f"{base_url.rstrip('/')}/api/v1/auth/whoami"
|
|
78
|
+
headers = {
|
|
79
|
+
"Authorization": f"Bearer {key}",
|
|
80
|
+
"User-Agent": _user_agent(),
|
|
81
|
+
}
|
|
82
|
+
try:
|
|
83
|
+
with httpx.Client(timeout=timeout) as client:
|
|
84
|
+
response = client.get(url, headers=headers)
|
|
85
|
+
except (httpx.ConnectError, httpx.TimeoutException) as exc:
|
|
86
|
+
raise OnePinNetworkError(
|
|
87
|
+
f"Could not reach {base_url}. Pass --verbose for details.",
|
|
88
|
+
error_code="NETWORK_ERROR",
|
|
89
|
+
) from exc
|
|
90
|
+
|
|
91
|
+
# Try to extract envelope fields for richer errors
|
|
92
|
+
request_id: Optional[str] = None
|
|
93
|
+
error_code = "HTTP_ERROR"
|
|
94
|
+
error_message = response.reason_phrase or "Request failed"
|
|
95
|
+
body_text = response.text
|
|
96
|
+
|
|
97
|
+
if response.status_code != 200:
|
|
98
|
+
try:
|
|
99
|
+
payload = response.json()
|
|
100
|
+
meta = payload.get("meta", {})
|
|
101
|
+
request_id = meta.get("request_id")
|
|
102
|
+
error_obj = payload.get("error", {})
|
|
103
|
+
error_code = error_obj.get("code", error_code)
|
|
104
|
+
error_message = error_obj.get("message", error_message)
|
|
105
|
+
except Exception: # noqa: BLE001
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
if response.status_code in (401, 403):
|
|
109
|
+
raise OnePinAuthError(
|
|
110
|
+
error_message,
|
|
111
|
+
status_code=response.status_code,
|
|
112
|
+
error_code="INVALID_API_KEY",
|
|
113
|
+
request_id=request_id,
|
|
114
|
+
response_body=body_text,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
raise OnePinHTTPError(
|
|
118
|
+
error_message,
|
|
119
|
+
status_code=response.status_code,
|
|
120
|
+
error_code=error_code,
|
|
121
|
+
request_id=request_id,
|
|
122
|
+
response_body=body_text,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
payload = response.json()
|
|
126
|
+
return payload["data"]
|
onepin/_cli/_state.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Read/write ~/.onepin/credentials TOML file with mode 0600.
|
|
2
|
+
|
|
3
|
+
Uses ``tomllib`` (stdlib 3.11+) or ``tomli`` (backport for 3.10).
|
|
4
|
+
Writes are hand-formatted TOML -- avoids the ``tomli_w`` dependency.
|
|
5
|
+
|
|
6
|
+
Malformed TOML on read raises SystemExit(1) -- fail fast, no silent skip.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import stat
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path, PosixPath, WindowsPath
|
|
15
|
+
from typing import Any, Dict, Optional
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _home_path() -> Path:
|
|
19
|
+
"""Resolve the CLI home directory in a testable, cross-platform way."""
|
|
20
|
+
path_cls = WindowsPath if sys.platform == "win32" else PosixPath
|
|
21
|
+
|
|
22
|
+
home = os.environ.get("HOME")
|
|
23
|
+
if home:
|
|
24
|
+
return path_cls(home)
|
|
25
|
+
|
|
26
|
+
if sys.platform == "win32":
|
|
27
|
+
user_profile = os.environ.get("USERPROFILE")
|
|
28
|
+
if user_profile:
|
|
29
|
+
return path_cls(user_profile)
|
|
30
|
+
|
|
31
|
+
home_drive = os.environ.get("HOMEDRIVE")
|
|
32
|
+
home_path = os.environ.get("HOMEPATH")
|
|
33
|
+
if home_drive and home_path:
|
|
34
|
+
return path_cls(home_drive + home_path)
|
|
35
|
+
|
|
36
|
+
return Path.home()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _credentials_path() -> Path:
|
|
40
|
+
return _home_path() / ".onepin" / "credentials"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _load_toml(path: Path) -> Dict[str, Any]:
|
|
44
|
+
"""Parse TOML from path. Hard-fails on parse error."""
|
|
45
|
+
try:
|
|
46
|
+
if sys.version_info >= (3, 11):
|
|
47
|
+
import tomllib
|
|
48
|
+
|
|
49
|
+
with open(path, "rb") as f:
|
|
50
|
+
return tomllib.load(f)
|
|
51
|
+
else:
|
|
52
|
+
import tomli # type: ignore[import-not-found]
|
|
53
|
+
|
|
54
|
+
with open(path, "rb") as f:
|
|
55
|
+
return tomli.load(f)
|
|
56
|
+
except Exception as exc: # noqa: BLE001
|
|
57
|
+
sys.stderr.write(f"[ERROR] Malformed credentials file at {path}: {exc}\n")
|
|
58
|
+
sys.exit(1)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def read_credentials() -> Optional[Dict[str, Any]]:
|
|
62
|
+
"""Read credentials file. Returns None if file does not exist.
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
SystemExit(1): If the file exists but cannot be parsed as TOML.
|
|
66
|
+
"""
|
|
67
|
+
path = _credentials_path()
|
|
68
|
+
if not path.exists():
|
|
69
|
+
return None
|
|
70
|
+
return _load_toml(path)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def write_credentials(api_key: str, base_url: str, profile: str = "default") -> Path:
|
|
74
|
+
"""Write credentials to ~/.onepin/credentials with mode 0600.
|
|
75
|
+
|
|
76
|
+
Creates ~/.onepin/ with mode 0700 if it does not exist.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
api_key: The API key to store.
|
|
80
|
+
base_url: The base URL to store.
|
|
81
|
+
profile: Profile name (default: "default").
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Path to the written credentials file.
|
|
85
|
+
"""
|
|
86
|
+
path = _credentials_path()
|
|
87
|
+
parent = path.parent
|
|
88
|
+
|
|
89
|
+
# Create parent directory with mode 0700
|
|
90
|
+
parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
91
|
+
|
|
92
|
+
content = f'[{profile}]\napi_key = "{api_key}"\nbase_url = "{base_url}"\n'
|
|
93
|
+
|
|
94
|
+
# Write atomically: write to temp file, then rename
|
|
95
|
+
tmp_path = path.with_suffix(".tmp")
|
|
96
|
+
try:
|
|
97
|
+
tmp_path.write_text(content, encoding="utf-8")
|
|
98
|
+
if os.name != "nt":
|
|
99
|
+
os.chmod(tmp_path, stat.S_IRUSR | stat.S_IWUSR) # 0600
|
|
100
|
+
tmp_path.replace(path)
|
|
101
|
+
except Exception:
|
|
102
|
+
tmp_path.unlink(missing_ok=True)
|
|
103
|
+
raise
|
|
104
|
+
|
|
105
|
+
return path
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def delete_credentials() -> bool:
|
|
109
|
+
"""Delete credentials file. Returns True if deleted, False if not found."""
|
|
110
|
+
path = _credentials_path()
|
|
111
|
+
if path.exists():
|
|
112
|
+
path.unlink()
|
|
113
|
+
return True
|
|
114
|
+
return False
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Credential resolution: flag > env > file > None.
|
|
2
|
+
|
|
3
|
+
Priority chain (highest wins):
|
|
4
|
+
1. Explicit --api-key flag (passed as ``flag_api_key``)
|
|
5
|
+
2. ``ONEPIN_API_KEY`` environment variable
|
|
6
|
+
3. ``~/.onepin/credentials`` TOML file, ``[default]`` profile
|
|
7
|
+
4. None (caller decides whether to abort)
|
|
8
|
+
|
|
9
|
+
Malformed TOML file raises hard error -- no silent fallback per project policy.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Literal, Optional
|
|
17
|
+
|
|
18
|
+
from onepin._cli.auth.credentials import read_credentials
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class ResolvedCredentials:
|
|
23
|
+
api_key: Optional[str]
|
|
24
|
+
base_url: Optional[str]
|
|
25
|
+
source: Literal["flag", "env", "file", "default"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def resolve_credentials(
|
|
29
|
+
flag_api_key: Optional[str] = None,
|
|
30
|
+
flag_base_url: Optional[str] = None,
|
|
31
|
+
) -> ResolvedCredentials:
|
|
32
|
+
"""Resolve credentials from the priority chain.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
flag_api_key: Value of --api-key CLI flag (None if not provided).
|
|
36
|
+
flag_base_url: Value of --base-url CLI flag (None if not provided).
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
ResolvedCredentials with source indicating where the key came from.
|
|
40
|
+
|
|
41
|
+
Raises:
|
|
42
|
+
SystemExit: If the credentials file exists but is malformed TOML.
|
|
43
|
+
"""
|
|
44
|
+
# 1. Explicit flag
|
|
45
|
+
if flag_api_key is not None:
|
|
46
|
+
return ResolvedCredentials(
|
|
47
|
+
api_key=flag_api_key,
|
|
48
|
+
base_url=flag_base_url,
|
|
49
|
+
source="flag",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# 2. Environment variable
|
|
53
|
+
env_key = os.environ.get("ONEPIN_API_KEY")
|
|
54
|
+
if env_key:
|
|
55
|
+
env_url = os.environ.get("ONEPIN_BASE_URL", flag_base_url)
|
|
56
|
+
return ResolvedCredentials(
|
|
57
|
+
api_key=env_key,
|
|
58
|
+
base_url=env_url,
|
|
59
|
+
source="env",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# 3. Credentials file (~/.onepin/credentials)
|
|
63
|
+
file_data = read_credentials() # raises hard on malformed TOML
|
|
64
|
+
if file_data is not None:
|
|
65
|
+
profile = file_data.get("default", {})
|
|
66
|
+
file_key = profile.get("api_key")
|
|
67
|
+
file_url = flag_base_url or profile.get("base_url")
|
|
68
|
+
if file_key:
|
|
69
|
+
return ResolvedCredentials(
|
|
70
|
+
api_key=file_key,
|
|
71
|
+
base_url=file_url,
|
|
72
|
+
source="file",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# 4. Nothing found
|
|
76
|
+
return ResolvedCredentials(api_key=None, base_url=flag_base_url, source="default")
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Wire subcommands into the root Typer app."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from onepin._cli.commands import auth, templates, uploads, voices, workflows
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def register(app: typer.Typer) -> None:
|
|
11
|
+
app.command(name="login")(auth.login)
|
|
12
|
+
app.command(name="logout")(auth.logout)
|
|
13
|
+
app.command(name="whoami")(auth.whoami)
|
|
14
|
+
app.add_typer(workflows.app, name="workflows")
|
|
15
|
+
app.add_typer(voices.app, name="voices")
|
|
16
|
+
app.add_typer(templates.app, name="templates")
|
|
17
|
+
app.add_typer(uploads.app, name="uploads")
|