pyholded 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.
- pyholded/__init__.py +55 -0
- pyholded/_proxies.py +73 -0
- pyholded/_registry.py +84 -0
- pyholded/cli.py +329 -0
- pyholded/client.py +159 -0
- pyholded/config.py +196 -0
- pyholded/endpoints.py +210 -0
- pyholded/exceptions.py +43 -0
- pyholded/multi.py +98 -0
- pyholded/output.py +97 -0
- pyholded/py.typed +0 -0
- pyholded/transport.py +139 -0
- pyholded-0.1.0.dist-info/METADATA +376 -0
- pyholded-0.1.0.dist-info/RECORD +17 -0
- pyholded-0.1.0.dist-info/WHEEL +4 -0
- pyholded-0.1.0.dist-info/entry_points.txt +3 -0
- pyholded-0.1.0.dist-info/licenses/LICENSE +21 -0
pyholded/__init__.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""pyholded — a modular Python client and CLI for the complete Holded API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib import metadata
|
|
6
|
+
|
|
7
|
+
from ._registry import Endpoint, Resource
|
|
8
|
+
from .client import HoldedClient
|
|
9
|
+
from .config import Config, resolve_accounts, resolve_config, resolve_token
|
|
10
|
+
from .endpoints import REGISTRY
|
|
11
|
+
from .exceptions import (
|
|
12
|
+
APIError,
|
|
13
|
+
AuthenticationError,
|
|
14
|
+
ConfigError,
|
|
15
|
+
EndpointNotFoundError,
|
|
16
|
+
HoldedError,
|
|
17
|
+
NotFoundError,
|
|
18
|
+
RateLimitError,
|
|
19
|
+
)
|
|
20
|
+
from .multi import MultiClient
|
|
21
|
+
from .output import OutputFormat, render, to_json, to_toon
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _detect_version() -> str:
|
|
25
|
+
try:
|
|
26
|
+
return metadata.version("pyholded")
|
|
27
|
+
except metadata.PackageNotFoundError:
|
|
28
|
+
return "0.0.0"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
__version__ = _detect_version()
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"REGISTRY",
|
|
35
|
+
"APIError",
|
|
36
|
+
"AuthenticationError",
|
|
37
|
+
"Config",
|
|
38
|
+
"ConfigError",
|
|
39
|
+
"Endpoint",
|
|
40
|
+
"EndpointNotFoundError",
|
|
41
|
+
"HoldedClient",
|
|
42
|
+
"HoldedError",
|
|
43
|
+
"MultiClient",
|
|
44
|
+
"NotFoundError",
|
|
45
|
+
"OutputFormat",
|
|
46
|
+
"RateLimitError",
|
|
47
|
+
"Resource",
|
|
48
|
+
"__version__",
|
|
49
|
+
"render",
|
|
50
|
+
"resolve_accounts",
|
|
51
|
+
"resolve_config",
|
|
52
|
+
"resolve_token",
|
|
53
|
+
"to_json",
|
|
54
|
+
"to_toon",
|
|
55
|
+
]
|
pyholded/_proxies.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Attribute-based dispatch for the registry-driven clients.
|
|
2
|
+
|
|
3
|
+
These proxies turn ``caller.<resource>.<operation>(**kwargs)`` into a single
|
|
4
|
+
:meth:`call` on whatever caller they are bound to. They hold no API knowledge
|
|
5
|
+
of their own — the endpoint registry is the single source of truth — and work
|
|
6
|
+
identically for the single-account :class:`~pyholded.client.HoldedClient` and
|
|
7
|
+
the fan-out :class:`~pyholded.multi.MultiClient`, since both expose the same
|
|
8
|
+
:meth:`call` contract.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any, Protocol
|
|
14
|
+
|
|
15
|
+
from ._registry import Endpoint, Resource
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _Caller(Protocol):
|
|
19
|
+
"""Anything the proxies can dispatch a registered operation to."""
|
|
20
|
+
|
|
21
|
+
def call(
|
|
22
|
+
self,
|
|
23
|
+
resource: str,
|
|
24
|
+
operation: str,
|
|
25
|
+
*,
|
|
26
|
+
path_params: dict[str, str] | None = ...,
|
|
27
|
+
params: dict[str, Any] | None = ...,
|
|
28
|
+
data: Any = ...,
|
|
29
|
+
paginate: bool = ...,
|
|
30
|
+
) -> Any: ...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ResourceProxy:
|
|
34
|
+
"""Attribute access wrapper for a single resource's operations."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, caller: _Caller, resource: Resource) -> None:
|
|
37
|
+
self._caller = caller
|
|
38
|
+
self._resource = resource
|
|
39
|
+
|
|
40
|
+
def __getattr__(self, name: str) -> OperationProxy:
|
|
41
|
+
endpoint = self._resource.get(name)
|
|
42
|
+
if endpoint is None:
|
|
43
|
+
raise AttributeError(name)
|
|
44
|
+
return OperationProxy(self._caller, self._resource.name, endpoint)
|
|
45
|
+
|
|
46
|
+
def __dir__(self) -> list[str]:
|
|
47
|
+
return [*super().__dir__(), *self._resource.operations]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class OperationProxy:
|
|
51
|
+
"""A bound, callable API operation."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, caller: _Caller, resource: str, endpoint: Endpoint) -> None:
|
|
54
|
+
self._caller = caller
|
|
55
|
+
self._resource = resource
|
|
56
|
+
self._endpoint = endpoint
|
|
57
|
+
|
|
58
|
+
def __call__(
|
|
59
|
+
self,
|
|
60
|
+
*,
|
|
61
|
+
params: dict[str, Any] | None = None,
|
|
62
|
+
data: Any = None,
|
|
63
|
+
paginate: bool = False,
|
|
64
|
+
**path_params: str,
|
|
65
|
+
) -> Any:
|
|
66
|
+
return self._caller.call(
|
|
67
|
+
self._resource,
|
|
68
|
+
self._endpoint.name,
|
|
69
|
+
path_params=path_params,
|
|
70
|
+
params=params,
|
|
71
|
+
data=data,
|
|
72
|
+
paginate=paginate,
|
|
73
|
+
)
|
pyholded/_registry.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Declarative model for the Holded API surface.
|
|
2
|
+
|
|
3
|
+
Every endpoint is described once, as data. Both the high-level client
|
|
4
|
+
(:mod:`pyholded.client`) and the command-line interface (:mod:`pyholded.cli`)
|
|
5
|
+
are generated from this registry, so the API surface has a single source of truth.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from urllib.parse import quote
|
|
13
|
+
|
|
14
|
+
_PLACEHOLDER = re.compile(r"\{([^}]+)\}")
|
|
15
|
+
|
|
16
|
+
HTTPMethod = str # one of GET, POST, PUT, DELETE
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class Endpoint:
|
|
21
|
+
"""A single API operation within a resource."""
|
|
22
|
+
|
|
23
|
+
name: str
|
|
24
|
+
method: HTTPMethod
|
|
25
|
+
path: str
|
|
26
|
+
description: str = ""
|
|
27
|
+
query_params: tuple[str, ...] = ()
|
|
28
|
+
has_body: bool = False
|
|
29
|
+
binary: bool = False
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def path_params(self) -> tuple[str, ...]:
|
|
33
|
+
"""Names of the ``{placeholder}`` segments in :attr:`path`."""
|
|
34
|
+
return tuple(_PLACEHOLDER.findall(self.path))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class Resource:
|
|
39
|
+
"""A named group of related endpoints belonging to one API module."""
|
|
40
|
+
|
|
41
|
+
module: str
|
|
42
|
+
name: str
|
|
43
|
+
description: str
|
|
44
|
+
endpoints: tuple[Endpoint, ...]
|
|
45
|
+
operations: dict[str, Endpoint] = field(init=False, default_factory=dict, compare=False)
|
|
46
|
+
|
|
47
|
+
def __post_init__(self) -> None:
|
|
48
|
+
# frozen dataclass: populate the lookup table without reassigning the field.
|
|
49
|
+
self.operations.update({ep.name: ep for ep in self.endpoints})
|
|
50
|
+
|
|
51
|
+
def get(self, operation: str) -> Endpoint | None:
|
|
52
|
+
return self.operations.get(operation)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def build_index(resources: tuple[Resource, ...]) -> dict[str, Resource]:
|
|
56
|
+
"""Index resources by name, rejecting duplicates."""
|
|
57
|
+
index: dict[str, Resource] = {}
|
|
58
|
+
for resource in resources:
|
|
59
|
+
if resource.name in index:
|
|
60
|
+
raise ValueError(f"Duplicate resource name: {resource.name}")
|
|
61
|
+
index[resource.name] = resource
|
|
62
|
+
return index
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def render_path(template: str, path_params: dict[str, str]) -> str:
|
|
66
|
+
"""Substitute ``{placeholders}`` in ``template`` with ``path_params`` values.
|
|
67
|
+
|
|
68
|
+
Each value is percent-encoded as a single path segment, so an id containing
|
|
69
|
+
``/``, ``#`` or ``?`` cannot corrupt the request URL.
|
|
70
|
+
"""
|
|
71
|
+
missing: list[str] = []
|
|
72
|
+
|
|
73
|
+
def _sub(match: re.Match[str]) -> str:
|
|
74
|
+
key = match.group(1)
|
|
75
|
+
value = path_params.get(key)
|
|
76
|
+
if value is None or value == "":
|
|
77
|
+
missing.append(key)
|
|
78
|
+
return ""
|
|
79
|
+
return quote(str(value), safe="")
|
|
80
|
+
|
|
81
|
+
rendered = _PLACEHOLDER.sub(_sub, template)
|
|
82
|
+
if missing:
|
|
83
|
+
raise KeyError(f"Missing path parameter(s): {', '.join(missing)}")
|
|
84
|
+
return rendered.lstrip("/")
|
pyholded/cli.py
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""Command-line interface for the Holded API.
|
|
2
|
+
|
|
3
|
+
The CLI mirrors the endpoint registry: every resource is a command group and
|
|
4
|
+
every operation a subcommand whose path/query parameters are flags. Output is
|
|
5
|
+
selectable per invocation (``--output rich|json|toon``)::
|
|
6
|
+
|
|
7
|
+
holded invoices list --limit 50
|
|
8
|
+
holded contacts get --id 0123456789abcdef01234567 --output json
|
|
9
|
+
holded --account acme contacts list # one named account
|
|
10
|
+
holded --all-accounts contacts list # fan out to every account
|
|
11
|
+
holded accounts # list configured accounts
|
|
12
|
+
holded raw GET taxes --output toon
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import click
|
|
23
|
+
|
|
24
|
+
from ._registry import Endpoint, Resource
|
|
25
|
+
from .client import HoldedClient
|
|
26
|
+
from .config import resolve_accounts
|
|
27
|
+
from .endpoints import REGISTRY
|
|
28
|
+
from .exceptions import HoldedError
|
|
29
|
+
from .multi import MultiClient
|
|
30
|
+
from .output import OutputFormat, render
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _Context:
|
|
34
|
+
"""Shared CLI state carried on the click context object."""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
token: str | None,
|
|
39
|
+
config: Path | None,
|
|
40
|
+
base_url: str | None,
|
|
41
|
+
output: str,
|
|
42
|
+
timeout: float,
|
|
43
|
+
account: str | None,
|
|
44
|
+
all_accounts: bool,
|
|
45
|
+
) -> None:
|
|
46
|
+
self.token = token
|
|
47
|
+
self.config = config
|
|
48
|
+
self.base_url = base_url
|
|
49
|
+
self.output = OutputFormat(output)
|
|
50
|
+
self.timeout = timeout
|
|
51
|
+
self.account = account
|
|
52
|
+
self.all_accounts = all_accounts
|
|
53
|
+
|
|
54
|
+
def client(self) -> HoldedClient:
|
|
55
|
+
return HoldedClient(
|
|
56
|
+
self.token,
|
|
57
|
+
account=self.account,
|
|
58
|
+
base_url=self.base_url,
|
|
59
|
+
config_path=self.config,
|
|
60
|
+
timeout=self.timeout,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def multi(self) -> MultiClient:
|
|
64
|
+
return MultiClient.from_accounts(config_path=self.config, timeout=self.timeout)
|
|
65
|
+
|
|
66
|
+
def run_call(self, resource: str, operation: str, **kwargs: Any) -> Any:
|
|
67
|
+
"""Run an operation on the selected account, or fan out to all accounts."""
|
|
68
|
+
if self.all_accounts:
|
|
69
|
+
with self.multi() as multi:
|
|
70
|
+
return multi.call(resource, operation, **kwargs)
|
|
71
|
+
with self.client() as client:
|
|
72
|
+
return client.call(resource, operation, **kwargs)
|
|
73
|
+
|
|
74
|
+
def run_request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
75
|
+
if self.all_accounts:
|
|
76
|
+
with self.multi() as multi:
|
|
77
|
+
return multi.request(method, path, **kwargs)
|
|
78
|
+
with self.client() as client:
|
|
79
|
+
return client.request(method, path, **kwargs)
|
|
80
|
+
|
|
81
|
+
def resolve_format(self, override: str | None) -> OutputFormat:
|
|
82
|
+
"""Per-command --output wins over the group-level default."""
|
|
83
|
+
return OutputFormat(override) if override else self.output
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
_OUTPUT_CHOICES = [fmt.value for fmt in OutputFormat]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _output_option() -> click.Option:
|
|
90
|
+
return click.Option(
|
|
91
|
+
["-o", "--output"],
|
|
92
|
+
type=click.Choice(_OUTPUT_CHOICES),
|
|
93
|
+
default=None,
|
|
94
|
+
help="Output format (overrides the global default).",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _to_flag(name: str) -> str:
|
|
99
|
+
out = [name[0].lower()]
|
|
100
|
+
for char in name[1:]:
|
|
101
|
+
if char.isupper():
|
|
102
|
+
out.append("-")
|
|
103
|
+
out.append(char.lower())
|
|
104
|
+
else:
|
|
105
|
+
out.append(char)
|
|
106
|
+
return "--" + "".join(out).replace("_", "-")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _dest(name: str) -> str:
|
|
110
|
+
return _to_flag(name)[2:].replace("-", "_")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _kebab(name: str) -> str:
|
|
114
|
+
return _to_flag(name)[2:]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _parse_data(data: str | None, fields: tuple[str, ...]) -> Any:
|
|
118
|
+
body: Any = None
|
|
119
|
+
if data is not None:
|
|
120
|
+
if data.startswith("@"):
|
|
121
|
+
path = Path(data[1:])
|
|
122
|
+
try:
|
|
123
|
+
text = path.read_text(encoding="utf-8")
|
|
124
|
+
except OSError as exc:
|
|
125
|
+
raise click.BadParameter(f"cannot read {path}: {exc}", param_hint="--data") from exc
|
|
126
|
+
else:
|
|
127
|
+
text = data
|
|
128
|
+
try:
|
|
129
|
+
body = json.loads(text)
|
|
130
|
+
except json.JSONDecodeError as exc:
|
|
131
|
+
raise click.BadParameter(f"invalid JSON: {exc}", param_hint="--data") from exc
|
|
132
|
+
if fields:
|
|
133
|
+
merged: dict[str, str] = body if isinstance(body, dict) else {}
|
|
134
|
+
for item in fields:
|
|
135
|
+
key, _, value = item.partition("=")
|
|
136
|
+
merged[key] = value
|
|
137
|
+
body = merged
|
|
138
|
+
return body
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _operation_command(resource: Resource, endpoint: Endpoint) -> click.Command:
|
|
142
|
+
params: list[click.Parameter] = []
|
|
143
|
+
path_dests = {name: _dest(name) for name in endpoint.path_params}
|
|
144
|
+
query_dests = {name: _dest(name) for name in endpoint.query_params}
|
|
145
|
+
|
|
146
|
+
for name in endpoint.path_params:
|
|
147
|
+
params.append(click.Option([_to_flag(name)], required=True, help="path parameter"))
|
|
148
|
+
for name in endpoint.query_params:
|
|
149
|
+
params.append(click.Option([_to_flag(name)], required=False, help="query parameter"))
|
|
150
|
+
if endpoint.has_body:
|
|
151
|
+
params.append(
|
|
152
|
+
click.Option(
|
|
153
|
+
["--data"],
|
|
154
|
+
required=False,
|
|
155
|
+
help="JSON request body, or @file.json to read from a file",
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
params.append(
|
|
159
|
+
click.Option(
|
|
160
|
+
["--field", "fields"],
|
|
161
|
+
multiple=True,
|
|
162
|
+
help="body field as key=value (repeatable)",
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
paginates = endpoint.method == "GET" and not endpoint.binary
|
|
166
|
+
if paginates:
|
|
167
|
+
params.append(
|
|
168
|
+
click.Option(
|
|
169
|
+
["--all", "fetch_all"],
|
|
170
|
+
is_flag=True,
|
|
171
|
+
help="follow the cursor and fetch every page",
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
params.append(_output_option())
|
|
175
|
+
|
|
176
|
+
def callback(**kwargs: Any) -> None:
|
|
177
|
+
ctx = click.get_current_context()
|
|
178
|
+
state: _Context = ctx.obj
|
|
179
|
+
path_params = {
|
|
180
|
+
name: kwargs[dest] for name, dest in path_dests.items() if kwargs.get(dest) is not None
|
|
181
|
+
}
|
|
182
|
+
query = {
|
|
183
|
+
name: kwargs[dest] for name, dest in query_dests.items() if kwargs.get(dest) is not None
|
|
184
|
+
}
|
|
185
|
+
data = (
|
|
186
|
+
_parse_data(kwargs.get("data"), kwargs.get("fields", ())) if endpoint.has_body else None
|
|
187
|
+
)
|
|
188
|
+
result = state.run_call(
|
|
189
|
+
resource.name,
|
|
190
|
+
endpoint.name,
|
|
191
|
+
path_params=path_params,
|
|
192
|
+
params=query or None,
|
|
193
|
+
data=data,
|
|
194
|
+
paginate=bool(kwargs.get("fetch_all")),
|
|
195
|
+
)
|
|
196
|
+
render(result, state.resolve_format(kwargs.get("output")))
|
|
197
|
+
|
|
198
|
+
return click.Command(
|
|
199
|
+
name=_kebab(endpoint.name),
|
|
200
|
+
params=params,
|
|
201
|
+
callback=callback,
|
|
202
|
+
help=endpoint.description or f"{endpoint.method} {endpoint.path}",
|
|
203
|
+
short_help=endpoint.description,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _resource_group(resource: Resource) -> click.Group:
|
|
208
|
+
group = click.Group(
|
|
209
|
+
name=resource.name,
|
|
210
|
+
help=f"[{resource.module}] {resource.description}",
|
|
211
|
+
)
|
|
212
|
+
for endpoint in resource.endpoints:
|
|
213
|
+
group.add_command(_operation_command(resource, endpoint))
|
|
214
|
+
return group
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
|
|
218
|
+
@click.option("--token", envvar="HOLDED_TOKEN", help="Holded API token (PAT).")
|
|
219
|
+
@click.option("-a", "--account", help="Use a named account from env/config.")
|
|
220
|
+
@click.option("--all-accounts", is_flag=True, help="Run on every configured account.")
|
|
221
|
+
@click.option(
|
|
222
|
+
"--config",
|
|
223
|
+
type=click.Path(exists=False, dir_okay=False, path_type=Path),
|
|
224
|
+
help="Path to a TOML config file.",
|
|
225
|
+
)
|
|
226
|
+
@click.option("--base-url", help="Override the API base URL.")
|
|
227
|
+
@click.option(
|
|
228
|
+
"-o",
|
|
229
|
+
"--output",
|
|
230
|
+
type=click.Choice(_OUTPUT_CHOICES),
|
|
231
|
+
default=OutputFormat.RICH.value,
|
|
232
|
+
show_default=True,
|
|
233
|
+
help="Default output format.",
|
|
234
|
+
)
|
|
235
|
+
@click.option("--timeout", type=float, default=30.0, show_default=True, help="HTTP timeout (s).")
|
|
236
|
+
@click.pass_context
|
|
237
|
+
def cli(
|
|
238
|
+
ctx: click.Context,
|
|
239
|
+
token: str | None,
|
|
240
|
+
account: str | None,
|
|
241
|
+
all_accounts: bool,
|
|
242
|
+
config: Path | None,
|
|
243
|
+
base_url: str | None,
|
|
244
|
+
output: str,
|
|
245
|
+
timeout: float,
|
|
246
|
+
) -> None:
|
|
247
|
+
"""Modular command-line client for the complete Holded API."""
|
|
248
|
+
ctx.obj = _Context(token, config, base_url, output, timeout, account, all_accounts)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@cli.command(name="resources")
|
|
252
|
+
@click.option("-o", "--output", type=click.Choice(_OUTPUT_CHOICES), default=None)
|
|
253
|
+
@click.pass_context
|
|
254
|
+
def list_resources(ctx: click.Context, output: str | None) -> None:
|
|
255
|
+
"""List every resource and its operations."""
|
|
256
|
+
state: _Context = ctx.obj
|
|
257
|
+
overview = {
|
|
258
|
+
resource.name: {
|
|
259
|
+
"module": resource.module,
|
|
260
|
+
"operations": [ep.name for ep in resource.endpoints],
|
|
261
|
+
}
|
|
262
|
+
for resource in REGISTRY
|
|
263
|
+
}
|
|
264
|
+
render(overview, state.resolve_format(output))
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@cli.command(name="raw")
|
|
268
|
+
@click.argument("method")
|
|
269
|
+
@click.argument("path")
|
|
270
|
+
@click.option("--param", "params", multiple=True, help="query parameter key=value (repeatable)")
|
|
271
|
+
@click.option("--data", help="JSON request body, or @file.json")
|
|
272
|
+
@click.option("--binary", is_flag=True, help="treat the response as raw bytes")
|
|
273
|
+
@click.option("-o", "--output", type=click.Choice(_OUTPUT_CHOICES), default=None)
|
|
274
|
+
@click.pass_context
|
|
275
|
+
def raw(
|
|
276
|
+
ctx: click.Context,
|
|
277
|
+
method: str,
|
|
278
|
+
path: str,
|
|
279
|
+
params: tuple[str, ...],
|
|
280
|
+
data: str | None,
|
|
281
|
+
binary: bool,
|
|
282
|
+
output: str | None,
|
|
283
|
+
) -> None:
|
|
284
|
+
"""Call an arbitrary endpoint: METHOD and PATH (relative to the API base URL)."""
|
|
285
|
+
state: _Context = ctx.obj
|
|
286
|
+
query = dict(item.partition("=")[::2] for item in params) or None
|
|
287
|
+
body = _parse_data(data, ()) if data else None
|
|
288
|
+
result = state.run_request(method, path.lstrip("/"), params=query, json=body, binary=binary)
|
|
289
|
+
if binary and isinstance(result, bytes):
|
|
290
|
+
sys.stdout.buffer.write(result)
|
|
291
|
+
return
|
|
292
|
+
render(result, state.resolve_format(output))
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@cli.command(name="accounts")
|
|
296
|
+
@click.option("-o", "--output", type=click.Choice(_OUTPUT_CHOICES), default=None)
|
|
297
|
+
@click.pass_context
|
|
298
|
+
def list_accounts(ctx: click.Context, output: str | None) -> None:
|
|
299
|
+
"""List configured accounts (names and base URLs; tokens are never shown)."""
|
|
300
|
+
state: _Context = ctx.obj
|
|
301
|
+
accounts = resolve_accounts(state.config)
|
|
302
|
+
overview = [
|
|
303
|
+
{"account": name, "base_url": cfg.base_url} for name, cfg in sorted(accounts.items())
|
|
304
|
+
]
|
|
305
|
+
render(overview, state.resolve_format(output))
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _register_resources() -> None:
|
|
309
|
+
for resource in REGISTRY:
|
|
310
|
+
cli.add_command(_resource_group(resource))
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def main() -> None:
|
|
314
|
+
"""Console-script entry point."""
|
|
315
|
+
_register_resources()
|
|
316
|
+
try:
|
|
317
|
+
cli.main(standalone_mode=False)
|
|
318
|
+
except HoldedError as exc:
|
|
319
|
+
click.echo(f"error: {exc}", err=True)
|
|
320
|
+
sys.exit(1)
|
|
321
|
+
except click.ClickException as exc:
|
|
322
|
+
exc.show()
|
|
323
|
+
sys.exit(exc.exit_code)
|
|
324
|
+
except click.exceptions.Abort:
|
|
325
|
+
sys.exit(130)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
if __name__ == "__main__":
|
|
329
|
+
main()
|
pyholded/client.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""High-level Holded client.
|
|
2
|
+
|
|
3
|
+
Resources and their operations are exposed as attributes generated from the
|
|
4
|
+
endpoint registry::
|
|
5
|
+
|
|
6
|
+
client = HoldedClient() # token from env or config file
|
|
7
|
+
invoices = client.invoices.list(params={"limit": 50})
|
|
8
|
+
contact = client.contacts.get(id="0123456789abcdef01234567")
|
|
9
|
+
|
|
10
|
+
Any endpoint can also be reached generically via :meth:`HoldedClient.request`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from ._proxies import ResourceProxy
|
|
19
|
+
from ._registry import Endpoint, Resource, build_index, render_path
|
|
20
|
+
from .config import resolve_config
|
|
21
|
+
from .endpoints import REGISTRY
|
|
22
|
+
from .exceptions import EndpointNotFoundError
|
|
23
|
+
from .transport import DEFAULT_TIMEOUT, Transport
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class HoldedClient:
|
|
27
|
+
"""Entry point to the Holded API."""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
token: str | None = None,
|
|
32
|
+
*,
|
|
33
|
+
account: str | None = None,
|
|
34
|
+
base_url: str | None = None,
|
|
35
|
+
config_path: Path | None = None,
|
|
36
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
37
|
+
transport: Transport | None = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
config = resolve_config(token, base_url=base_url, config_path=config_path, account=account)
|
|
40
|
+
self._account = config.name
|
|
41
|
+
self._transport = transport or Transport(
|
|
42
|
+
config.token, base_url=config.base_url, timeout=timeout
|
|
43
|
+
)
|
|
44
|
+
self._resources = build_index(REGISTRY)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def account(self) -> str:
|
|
48
|
+
"""The name of the account this client is bound to."""
|
|
49
|
+
return self._account
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def resources(self) -> dict[str, Resource]:
|
|
53
|
+
"""The full resource index, keyed by resource name."""
|
|
54
|
+
return self._resources
|
|
55
|
+
|
|
56
|
+
def request(
|
|
57
|
+
self,
|
|
58
|
+
method: str,
|
|
59
|
+
path: str,
|
|
60
|
+
*,
|
|
61
|
+
params: dict[str, Any] | None = None,
|
|
62
|
+
json: Any = None,
|
|
63
|
+
binary: bool = False,
|
|
64
|
+
) -> Any:
|
|
65
|
+
"""Call an arbitrary endpoint (escape hatch for anything not modelled)."""
|
|
66
|
+
return self._transport.request(method, path, params=params, json=json, binary=binary)
|
|
67
|
+
|
|
68
|
+
def call(
|
|
69
|
+
self,
|
|
70
|
+
resource: str,
|
|
71
|
+
operation: str,
|
|
72
|
+
*,
|
|
73
|
+
path_params: dict[str, str] | None = None,
|
|
74
|
+
params: dict[str, Any] | None = None,
|
|
75
|
+
data: Any = None,
|
|
76
|
+
paginate: bool = False,
|
|
77
|
+
) -> Any:
|
|
78
|
+
"""Invoke a registered operation by name (used by the CLI).
|
|
79
|
+
|
|
80
|
+
When ``paginate`` is true on a GET that returns a cursor-paginated
|
|
81
|
+
``{items, cursor, has_more}`` body, every page is fetched and the
|
|
82
|
+
merged ``items`` list is returned.
|
|
83
|
+
"""
|
|
84
|
+
endpoint = self._lookup(resource, operation)
|
|
85
|
+
path_params = path_params or {}
|
|
86
|
+
unknown = set(path_params) - set(endpoint.path_params)
|
|
87
|
+
if unknown:
|
|
88
|
+
raise TypeError(
|
|
89
|
+
f"unexpected argument(s) {sorted(unknown)} for {resource}.{operation}; "
|
|
90
|
+
f"pass query parameters via params=, the request body via data="
|
|
91
|
+
)
|
|
92
|
+
path = render_path(endpoint.path, path_params)
|
|
93
|
+
if paginate and endpoint.method == "GET" and not endpoint.binary:
|
|
94
|
+
return self._paginate(path, params)
|
|
95
|
+
return self._invoke(endpoint, path, params, data)
|
|
96
|
+
|
|
97
|
+
def close(self) -> None:
|
|
98
|
+
self._transport.close()
|
|
99
|
+
|
|
100
|
+
def __enter__(self) -> HoldedClient:
|
|
101
|
+
return self
|
|
102
|
+
|
|
103
|
+
def __exit__(self, *_exc: object) -> None:
|
|
104
|
+
self.close()
|
|
105
|
+
|
|
106
|
+
def __getattr__(self, name: str) -> ResourceProxy:
|
|
107
|
+
# Only reached for attributes not found normally.
|
|
108
|
+
resources = self.__dict__.get("_resources", {})
|
|
109
|
+
resource = resources.get(name)
|
|
110
|
+
if resource is None:
|
|
111
|
+
raise AttributeError(name)
|
|
112
|
+
return ResourceProxy(self, resource)
|
|
113
|
+
|
|
114
|
+
def __dir__(self) -> list[str]:
|
|
115
|
+
return [*super().__dir__(), *self._resources]
|
|
116
|
+
|
|
117
|
+
def _lookup(self, resource: str, operation: str) -> Endpoint:
|
|
118
|
+
res = self._resources.get(resource)
|
|
119
|
+
if res is None:
|
|
120
|
+
raise EndpointNotFoundError(f"Unknown resource: {resource}")
|
|
121
|
+
endpoint = res.get(operation)
|
|
122
|
+
if endpoint is None:
|
|
123
|
+
raise EndpointNotFoundError(f"Unknown operation '{operation}' on resource '{resource}'")
|
|
124
|
+
return endpoint
|
|
125
|
+
|
|
126
|
+
def _invoke(
|
|
127
|
+
self,
|
|
128
|
+
endpoint: Endpoint,
|
|
129
|
+
path: str,
|
|
130
|
+
params: dict[str, Any] | None,
|
|
131
|
+
data: Any,
|
|
132
|
+
) -> Any:
|
|
133
|
+
body = data if endpoint.has_body else None
|
|
134
|
+
return self._transport.request(
|
|
135
|
+
endpoint.method,
|
|
136
|
+
path,
|
|
137
|
+
params=params,
|
|
138
|
+
json=body,
|
|
139
|
+
binary=endpoint.binary,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def _paginate(self, path: str, params: dict[str, Any] | None) -> list[Any]:
|
|
143
|
+
items: list[Any] = []
|
|
144
|
+
query = dict(params or {})
|
|
145
|
+
seen_cursors: set[str] = set()
|
|
146
|
+
while True:
|
|
147
|
+
page = self._transport.request("GET", path, params=query)
|
|
148
|
+
if not isinstance(page, dict) or "items" not in page:
|
|
149
|
+
# Not a paginated shape; hand the raw body back as a single item.
|
|
150
|
+
return [page]
|
|
151
|
+
items.extend(page["items"])
|
|
152
|
+
cursor = page.get("cursor")
|
|
153
|
+
if not page.get("has_more") or not cursor:
|
|
154
|
+
return items
|
|
155
|
+
# Defend against an API that keeps returning the same cursor.
|
|
156
|
+
if cursor in seen_cursors:
|
|
157
|
+
return items
|
|
158
|
+
seen_cursors.add(cursor)
|
|
159
|
+
query["cursor"] = cursor
|