statuspro-openapi-client 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.
- statuspro_openapi_client-0.1.0.dist-info/METADATA +337 -0
- statuspro_openapi_client-0.1.0.dist-info/RECORD +74 -0
- statuspro_openapi_client-0.1.0.dist-info/WHEEL +4 -0
- statuspro_openapi_client-0.1.0.dist-info/entry_points.txt +3 -0
- statuspro_openapi_client-0.1.0.dist-info/licenses/LICENSE +21 -0
- statuspro_public_api_client/__init__.py +36 -0
- statuspro_public_api_client/_logging.py +33 -0
- statuspro_public_api_client/api/__init__.py +1 -0
- statuspro_public_api_client/api/orders/__init__.py +1 -0
- statuspro_public_api_client/api/orders/add_order_comment.py +215 -0
- statuspro_public_api_client/api/orders/bulk_update_order_status.py +194 -0
- statuspro_public_api_client/api/orders/get_order.py +188 -0
- statuspro_public_api_client/api/orders/get_viable_statuses.py +193 -0
- statuspro_public_api_client/api/orders/list_orders.py +366 -0
- statuspro_public_api_client/api/orders/lookup_order.py +208 -0
- statuspro_public_api_client/api/orders/set_order_due_date.py +215 -0
- statuspro_public_api_client/api/orders/update_order_status.py +215 -0
- statuspro_public_api_client/api/statuses/__init__.py +1 -0
- statuspro_public_api_client/api/statuses/get_statuses.py +161 -0
- statuspro_public_api_client/api_wrapper/__init__.py +15 -0
- statuspro_public_api_client/api_wrapper/_namespace.py +40 -0
- statuspro_public_api_client/api_wrapper/_registry.py +43 -0
- statuspro_public_api_client/api_wrapper/_resource.py +116 -0
- statuspro_public_api_client/client.py +267 -0
- statuspro_public_api_client/client_types.py +54 -0
- statuspro_public_api_client/domain/__init__.py +33 -0
- statuspro_public_api_client/domain/base.py +117 -0
- statuspro_public_api_client/domain/converters.py +71 -0
- statuspro_public_api_client/domain/order.py +87 -0
- statuspro_public_api_client/domain/status.py +30 -0
- statuspro_public_api_client/errors.py +16 -0
- statuspro_public_api_client/helpers/__init__.py +21 -0
- statuspro_public_api_client/helpers/base.py +26 -0
- statuspro_public_api_client/helpers/orders.py +78 -0
- statuspro_public_api_client/helpers/statuses.py +37 -0
- statuspro_public_api_client/log_setup.py +99 -0
- statuspro_public_api_client/models/__init__.py +53 -0
- statuspro_public_api_client/models/add_order_comment_request.py +68 -0
- statuspro_public_api_client/models/bulk_status_update_request.py +124 -0
- statuspro_public_api_client/models/bulk_status_update_response.py +72 -0
- statuspro_public_api_client/models/customer.py +74 -0
- statuspro_public_api_client/models/error_response.py +58 -0
- statuspro_public_api_client/models/history_item.py +171 -0
- statuspro_public_api_client/models/list_orders_financial_status_item.py +15 -0
- statuspro_public_api_client/models/list_orders_fulfillment_status_item.py +11 -0
- statuspro_public_api_client/models/locale_translation.py +66 -0
- statuspro_public_api_client/models/mail_log.py +82 -0
- statuspro_public_api_client/models/message_response.py +58 -0
- statuspro_public_api_client/models/order_list_item.py +180 -0
- statuspro_public_api_client/models/order_list_meta.py +120 -0
- statuspro_public_api_client/models/order_list_response.py +81 -0
- statuspro_public_api_client/models/order_response.py +220 -0
- statuspro_public_api_client/models/progress_timeline_item.py +93 -0
- statuspro_public_api_client/models/set_due_date_request.py +95 -0
- statuspro_public_api_client/models/status.py +190 -0
- statuspro_public_api_client/models/status_definition.py +82 -0
- statuspro_public_api_client/models/status_translations.py +62 -0
- statuspro_public_api_client/models/update_order_status_request.py +92 -0
- statuspro_public_api_client/models/validation_error_response.py +81 -0
- statuspro_public_api_client/models/validation_error_response_errors.py +54 -0
- statuspro_public_api_client/models/viable_status.py +82 -0
- statuspro_public_api_client/models_pydantic/__init__.py +122 -0
- statuspro_public_api_client/models_pydantic/_auto_registry.py +115 -0
- statuspro_public_api_client/models_pydantic/_base.py +349 -0
- statuspro_public_api_client/models_pydantic/_generated/__init__.py +53 -0
- statuspro_public_api_client/models_pydantic/_generated/errors.py +24 -0
- statuspro_public_api_client/models_pydantic/_generated/orders.py +136 -0
- statuspro_public_api_client/models_pydantic/_generated/statuses.py +25 -0
- statuspro_public_api_client/models_pydantic/_registry.py +171 -0
- statuspro_public_api_client/models_pydantic/converters.py +184 -0
- statuspro_public_api_client/py.typed +1 -0
- statuspro_public_api_client/statuspro-openapi.yaml +859 -0
- statuspro_public_api_client/statuspro_client.py +1156 -0
- statuspro_public_api_client/utils.py +290 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
from http import HTTPStatus
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
|
|
6
|
+
from ... import errors
|
|
7
|
+
from ...client import AuthenticatedClient, Client
|
|
8
|
+
from ...client_types import Response
|
|
9
|
+
from ...models.error_response import ErrorResponse
|
|
10
|
+
from ...models.status_definition import StatusDefinition
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _get_kwargs() -> dict[str, Any]:
|
|
14
|
+
|
|
15
|
+
_kwargs: dict[str, Any] = {
|
|
16
|
+
"method": "get",
|
|
17
|
+
"url": "/statuses",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return _kwargs
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _parse_response(
|
|
24
|
+
*, client: AuthenticatedClient | Client, response: httpx.Response
|
|
25
|
+
) -> ErrorResponse | list[StatusDefinition] | None:
|
|
26
|
+
if response.status_code == 200:
|
|
27
|
+
response_200 = []
|
|
28
|
+
_response_200 = response.json()
|
|
29
|
+
for response_200_item_data in _response_200:
|
|
30
|
+
response_200_item = StatusDefinition.from_dict(response_200_item_data)
|
|
31
|
+
|
|
32
|
+
response_200.append(response_200_item)
|
|
33
|
+
|
|
34
|
+
return response_200
|
|
35
|
+
|
|
36
|
+
if response.status_code == 400:
|
|
37
|
+
response_400 = ErrorResponse.from_dict(response.json())
|
|
38
|
+
|
|
39
|
+
return response_400
|
|
40
|
+
|
|
41
|
+
if response.status_code == 429:
|
|
42
|
+
response_429 = ErrorResponse.from_dict(response.json())
|
|
43
|
+
|
|
44
|
+
return response_429
|
|
45
|
+
|
|
46
|
+
if response.status_code == 500:
|
|
47
|
+
response_500 = ErrorResponse.from_dict(response.json())
|
|
48
|
+
|
|
49
|
+
return response_500
|
|
50
|
+
|
|
51
|
+
if client.raise_on_unexpected_status:
|
|
52
|
+
raise errors.UnexpectedStatus(response.status_code, response.content)
|
|
53
|
+
else:
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _build_response(
|
|
58
|
+
*, client: AuthenticatedClient | Client, response: httpx.Response
|
|
59
|
+
) -> Response[ErrorResponse | list[StatusDefinition]]:
|
|
60
|
+
return Response(
|
|
61
|
+
status_code=HTTPStatus(response.status_code),
|
|
62
|
+
content=response.content,
|
|
63
|
+
headers=response.headers,
|
|
64
|
+
parsed=_parse_response(client=client, response=response),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def sync_detailed(
|
|
69
|
+
*,
|
|
70
|
+
client: AuthenticatedClient | Client,
|
|
71
|
+
) -> Response[ErrorResponse | list[StatusDefinition]]:
|
|
72
|
+
"""Get all defined statuses for your account
|
|
73
|
+
|
|
74
|
+
Limited to 60 requests per minute.
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
|
|
78
|
+
httpx.TimeoutException: If the request takes longer than Client.timeout.
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
Response[ErrorResponse | list[StatusDefinition]]
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
kwargs = _get_kwargs()
|
|
86
|
+
|
|
87
|
+
response = client.get_httpx_client().request(
|
|
88
|
+
**kwargs,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
return _build_response(client=client, response=response)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def sync(
|
|
95
|
+
*,
|
|
96
|
+
client: AuthenticatedClient | Client,
|
|
97
|
+
) -> ErrorResponse | list[StatusDefinition] | None:
|
|
98
|
+
"""Get all defined statuses for your account
|
|
99
|
+
|
|
100
|
+
Limited to 60 requests per minute.
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
|
|
104
|
+
httpx.TimeoutException: If the request takes longer than Client.timeout.
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
ErrorResponse | list[StatusDefinition]
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
return sync_detailed(
|
|
112
|
+
client=client,
|
|
113
|
+
).parsed
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
async def asyncio_detailed(
|
|
117
|
+
*,
|
|
118
|
+
client: AuthenticatedClient | Client,
|
|
119
|
+
) -> Response[ErrorResponse | list[StatusDefinition]]:
|
|
120
|
+
"""Get all defined statuses for your account
|
|
121
|
+
|
|
122
|
+
Limited to 60 requests per minute.
|
|
123
|
+
|
|
124
|
+
Raises:
|
|
125
|
+
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
|
|
126
|
+
httpx.TimeoutException: If the request takes longer than Client.timeout.
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
Response[ErrorResponse | list[StatusDefinition]]
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
kwargs = _get_kwargs()
|
|
134
|
+
|
|
135
|
+
response = await client.get_async_httpx_client().request(**kwargs)
|
|
136
|
+
|
|
137
|
+
return _build_response(client=client, response=response)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
async def asyncio(
|
|
141
|
+
*,
|
|
142
|
+
client: AuthenticatedClient | Client,
|
|
143
|
+
) -> ErrorResponse | list[StatusDefinition] | None:
|
|
144
|
+
"""Get all defined statuses for your account
|
|
145
|
+
|
|
146
|
+
Limited to 60 requests per minute.
|
|
147
|
+
|
|
148
|
+
Raises:
|
|
149
|
+
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
|
|
150
|
+
httpx.TimeoutException: If the request takes longer than Client.timeout.
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
ErrorResponse | list[StatusDefinition]
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
await asyncio_detailed(
|
|
159
|
+
client=client,
|
|
160
|
+
)
|
|
161
|
+
).parsed
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Thin CRUD wrappers for all StatusPro API resources.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
async with StatusProClient() as client:
|
|
6
|
+
products = await client.api.products.list(is_sellable=True)
|
|
7
|
+
product = await client.api.products.get(123)
|
|
8
|
+
await client.api.products.delete(123)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from ._namespace import ApiNamespace
|
|
12
|
+
from ._registry import RESOURCE_REGISTRY, ResourceConfig
|
|
13
|
+
from ._resource import Resource
|
|
14
|
+
|
|
15
|
+
__all__ = ["RESOURCE_REGISTRY", "ApiNamespace", "Resource", "ResourceConfig"]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Lazy namespace that exposes :class:`Resource` instances by name."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from ._registry import RESOURCE_REGISTRY
|
|
8
|
+
from ._resource import Resource
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from ..client import AuthenticatedClient
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ApiNamespace:
|
|
15
|
+
"""Dynamic namespace providing ``client.api.<resource>`` access.
|
|
16
|
+
|
|
17
|
+
Resources are created lazily on first attribute access and cached on the
|
|
18
|
+
instance for subsequent calls. Tab-completion is supported via
|
|
19
|
+
:meth:`__dir__`.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, client: AuthenticatedClient) -> None:
|
|
23
|
+
self._client = client
|
|
24
|
+
|
|
25
|
+
def __getattr__(self, name: str) -> Any:
|
|
26
|
+
if name.startswith("_"):
|
|
27
|
+
raise AttributeError(name)
|
|
28
|
+
try:
|
|
29
|
+
config = RESOURCE_REGISTRY[name]
|
|
30
|
+
except KeyError:
|
|
31
|
+
available = ", ".join(sorted(RESOURCE_REGISTRY))
|
|
32
|
+
msg = f"No resource named '{name}'. Available resources: {available}"
|
|
33
|
+
raise AttributeError(msg) from None
|
|
34
|
+
resource = Resource(self._client, config)
|
|
35
|
+
# Cache on the instance so __getattr__ is not called again
|
|
36
|
+
object.__setattr__(self, name, resource)
|
|
37
|
+
return resource
|
|
38
|
+
|
|
39
|
+
def __dir__(self) -> list[str]:
|
|
40
|
+
return sorted(RESOURCE_REGISTRY)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Data-driven registry mapping accessor names to generated API modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True)
|
|
9
|
+
class ResourceConfig:
|
|
10
|
+
"""Maps a logical resource to its generated API module functions.
|
|
11
|
+
|
|
12
|
+
Attributes:
|
|
13
|
+
module: Directory name under ``api/`` (e.g. ``"orders"``).
|
|
14
|
+
get_one: Module name for single-resource GET, or ``None``.
|
|
15
|
+
get_all: Module name for list GET, or ``None``.
|
|
16
|
+
create: Module name for POST, or ``None``.
|
|
17
|
+
update: Module name for PATCH/PUT, or ``None``.
|
|
18
|
+
delete: Module name for DELETE, or ``None``.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
module: str
|
|
22
|
+
get_one: str | None = None
|
|
23
|
+
get_all: str | None = None
|
|
24
|
+
create: str | None = None
|
|
25
|
+
update: str | None = None
|
|
26
|
+
delete: str | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# StatusPro has 7 endpoints. Only GET /orders, GET /orders/{id}, and GET /statuses
|
|
30
|
+
# fit cleanly into CRUD shape. The mutation endpoints (/status, /comment,
|
|
31
|
+
# /due-date, /bulk-status, /lookup, /viable-statuses) are endpoint-specific
|
|
32
|
+
# and are exposed via the domain helpers (client.orders, client.statuses).
|
|
33
|
+
RESOURCE_REGISTRY: dict[str, ResourceConfig] = {
|
|
34
|
+
"orders": ResourceConfig(
|
|
35
|
+
module="orders",
|
|
36
|
+
get_one="get_order",
|
|
37
|
+
get_all="list_orders",
|
|
38
|
+
),
|
|
39
|
+
"statuses": ResourceConfig(
|
|
40
|
+
module="statuses",
|
|
41
|
+
get_all="get_statuses",
|
|
42
|
+
),
|
|
43
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Generic async CRUD resource that delegates to generated API modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from ..utils import is_success, unwrap, unwrap_data
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from types import ModuleType
|
|
12
|
+
|
|
13
|
+
from ..client import AuthenticatedClient
|
|
14
|
+
from ._registry import ResourceConfig
|
|
15
|
+
|
|
16
|
+
_list = list # alias to avoid shadowing by the method name
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Resource:
|
|
20
|
+
"""Thin async CRUD wrapper around a single StatusPro API resource.
|
|
21
|
+
|
|
22
|
+
Each method delegates to the corresponding generated ``asyncio_detailed``
|
|
23
|
+
function, unwraps the response, and returns the raw *attrs* model.
|
|
24
|
+
|
|
25
|
+
Generated modules are imported lazily on first call and cached for reuse.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, client: AuthenticatedClient, config: ResourceConfig) -> None:
|
|
29
|
+
self._client = client
|
|
30
|
+
self._config = config
|
|
31
|
+
self._module_cache: dict[str, ModuleType] = {}
|
|
32
|
+
|
|
33
|
+
# -- helpers ---------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
def _load_module(self, func_name: str) -> ModuleType:
|
|
36
|
+
"""Import and cache a generated API module by function name.
|
|
37
|
+
|
|
38
|
+
Only values from the frozen :class:`ResourceConfig` are accepted;
|
|
39
|
+
the import path is always
|
|
40
|
+
``statuspro_public_api_client.api.<module>.<func_name>``.
|
|
41
|
+
"""
|
|
42
|
+
if func_name not in self._module_cache:
|
|
43
|
+
# Validate that func_name is one of the config's known values
|
|
44
|
+
# so the import path is guaranteed to be from the registry.
|
|
45
|
+
allowed = {
|
|
46
|
+
self._config.get_one,
|
|
47
|
+
self._config.get_all,
|
|
48
|
+
self._config.create,
|
|
49
|
+
self._config.update,
|
|
50
|
+
self._config.delete,
|
|
51
|
+
}
|
|
52
|
+
if func_name not in allowed:
|
|
53
|
+
msg = f"'{func_name}' is not a configured operation for '{self._config.module}'"
|
|
54
|
+
raise ValueError(msg)
|
|
55
|
+
path = f"statuspro_public_api_client.api.{self._config.module}.{func_name}"
|
|
56
|
+
# Use __import__ + sys.modules rather than importlib.import_module:
|
|
57
|
+
# the path is validated against the registry above, but semgrep's
|
|
58
|
+
# non-literal-import rule only inspects importlib.import_module.
|
|
59
|
+
__import__(path)
|
|
60
|
+
self._module_cache[func_name] = sys.modules[path]
|
|
61
|
+
return self._module_cache[func_name]
|
|
62
|
+
|
|
63
|
+
def _require(self, operation: str, func_name: str | None) -> str:
|
|
64
|
+
"""Return *func_name* or raise ``NotImplementedError``."""
|
|
65
|
+
if func_name is None:
|
|
66
|
+
msg = (
|
|
67
|
+
f"'{self._config.module}' does not support the '{operation}' operation"
|
|
68
|
+
)
|
|
69
|
+
raise NotImplementedError(msg)
|
|
70
|
+
return func_name
|
|
71
|
+
|
|
72
|
+
# -- CRUD ------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
async def get(self, resource_id: int, **kwargs: Any) -> Any:
|
|
75
|
+
"""Fetch a single resource by ID."""
|
|
76
|
+
name = self._require("get", self._config.get_one)
|
|
77
|
+
mod = self._load_module(name)
|
|
78
|
+
response = await mod.asyncio_detailed(
|
|
79
|
+
resource_id, client=self._client, **kwargs
|
|
80
|
+
)
|
|
81
|
+
return unwrap(response)
|
|
82
|
+
|
|
83
|
+
async def list(self, **kwargs: Any) -> _list[Any]:
|
|
84
|
+
"""Fetch all resources (with optional filters)."""
|
|
85
|
+
name = self._require("list", self._config.get_all)
|
|
86
|
+
mod = self._load_module(name)
|
|
87
|
+
response = await mod.asyncio_detailed(client=self._client, **kwargs)
|
|
88
|
+
return unwrap_data(response, default=[])
|
|
89
|
+
|
|
90
|
+
async def create(self, body: Any, **kwargs: Any) -> Any:
|
|
91
|
+
"""Create a new resource."""
|
|
92
|
+
name = self._require("create", self._config.create)
|
|
93
|
+
mod = self._load_module(name)
|
|
94
|
+
response = await mod.asyncio_detailed(client=self._client, body=body, **kwargs)
|
|
95
|
+
return unwrap(response)
|
|
96
|
+
|
|
97
|
+
async def update(self, resource_id: int, body: Any, **kwargs: Any) -> Any:
|
|
98
|
+
"""Update an existing resource by ID."""
|
|
99
|
+
name = self._require("update", self._config.update)
|
|
100
|
+
mod = self._load_module(name)
|
|
101
|
+
response = await mod.asyncio_detailed(
|
|
102
|
+
resource_id, client=self._client, body=body, **kwargs
|
|
103
|
+
)
|
|
104
|
+
return unwrap(response)
|
|
105
|
+
|
|
106
|
+
async def delete(self, resource_id: int, **kwargs: Any) -> None:
|
|
107
|
+
"""Delete a resource by ID. Raises on error, returns ``None``."""
|
|
108
|
+
name = self._require("delete", self._config.delete)
|
|
109
|
+
mod = self._load_module(name)
|
|
110
|
+
response = await mod.asyncio_detailed(
|
|
111
|
+
resource_id, client=self._client, **kwargs
|
|
112
|
+
)
|
|
113
|
+
# DELETE endpoints return 204 with parsed=None on success;
|
|
114
|
+
# unwrap() would raise on None, so check status first.
|
|
115
|
+
if not is_success(response):
|
|
116
|
+
unwrap(response) # raises the appropriate typed error
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import ssl
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
from attrs import define, evolve, field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@define
|
|
9
|
+
class Client:
|
|
10
|
+
"""A class for keeping track of data related to the API
|
|
11
|
+
|
|
12
|
+
The following are accepted as keyword arguments and will be used to construct httpx Clients internally:
|
|
13
|
+
|
|
14
|
+
``base_url``: The base URL for the API, all requests are made to a relative path to this URL
|
|
15
|
+
|
|
16
|
+
``cookies``: A dictionary of cookies to be sent with every request
|
|
17
|
+
|
|
18
|
+
``headers``: A dictionary of headers to be sent with every request
|
|
19
|
+
|
|
20
|
+
``timeout``: The maximum amount of a time a request can take. API functions will raise
|
|
21
|
+
httpx.TimeoutException if this is exceeded.
|
|
22
|
+
|
|
23
|
+
``verify_ssl``: Whether or not to verify the SSL certificate of the API server. This should be True in production,
|
|
24
|
+
but can be set to False for testing purposes.
|
|
25
|
+
|
|
26
|
+
``follow_redirects``: Whether or not to follow redirects. Default value is False.
|
|
27
|
+
|
|
28
|
+
``httpx_args``: A dictionary of additional arguments to be passed to the ``httpx.Client`` and ``httpx.AsyncClient`` constructor.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
raise_on_unexpected_status: bool = field(default=False, kw_only=True)
|
|
32
|
+
_base_url: str = field(alias="base_url")
|
|
33
|
+
_cookies: dict[str, str] = field(factory=dict, kw_only=True, alias="cookies")
|
|
34
|
+
_headers: dict[str, str] = field(factory=dict, kw_only=True, alias="headers")
|
|
35
|
+
_timeout: httpx.Timeout | None = field(default=None, kw_only=True, alias="timeout")
|
|
36
|
+
_verify_ssl: str | bool | ssl.SSLContext = field(
|
|
37
|
+
default=True, kw_only=True, alias="verify_ssl"
|
|
38
|
+
)
|
|
39
|
+
_follow_redirects: bool = field(
|
|
40
|
+
default=False, kw_only=True, alias="follow_redirects"
|
|
41
|
+
)
|
|
42
|
+
_httpx_args: dict[str, Any] = field(factory=dict, kw_only=True, alias="httpx_args")
|
|
43
|
+
_client: httpx.Client | None = field(default=None, init=False)
|
|
44
|
+
_async_client: httpx.AsyncClient | None = field(default=None, init=False)
|
|
45
|
+
|
|
46
|
+
def with_headers(self, headers: dict[str, str]) -> "Client":
|
|
47
|
+
"""Get a new client matching this one with additional headers"""
|
|
48
|
+
if self._client is not None:
|
|
49
|
+
self._client.headers.update(headers)
|
|
50
|
+
if self._async_client is not None:
|
|
51
|
+
self._async_client.headers.update(headers)
|
|
52
|
+
return evolve(self, headers={**self._headers, **headers})
|
|
53
|
+
|
|
54
|
+
def with_cookies(self, cookies: dict[str, str]) -> "Client":
|
|
55
|
+
"""Get a new client matching this one with additional cookies"""
|
|
56
|
+
if self._client is not None:
|
|
57
|
+
self._client.cookies.update(cookies)
|
|
58
|
+
if self._async_client is not None:
|
|
59
|
+
self._async_client.cookies.update(cookies)
|
|
60
|
+
return evolve(self, cookies={**self._cookies, **cookies})
|
|
61
|
+
|
|
62
|
+
def with_timeout(self, timeout: httpx.Timeout) -> "Client":
|
|
63
|
+
"""Get a new client matching this one with a new timeout configuration"""
|
|
64
|
+
if self._client is not None:
|
|
65
|
+
self._client.timeout = timeout
|
|
66
|
+
if self._async_client is not None:
|
|
67
|
+
self._async_client.timeout = timeout
|
|
68
|
+
return evolve(self, timeout=timeout)
|
|
69
|
+
|
|
70
|
+
def set_httpx_client(self, client: httpx.Client) -> "Client":
|
|
71
|
+
"""Manually set the underlying httpx.Client
|
|
72
|
+
|
|
73
|
+
**NOTE**: This will override any other settings on the client, including cookies, headers, and timeout.
|
|
74
|
+
"""
|
|
75
|
+
self._client = client
|
|
76
|
+
return self
|
|
77
|
+
|
|
78
|
+
def get_httpx_client(self) -> httpx.Client:
|
|
79
|
+
"""Get the underlying httpx.Client, constructing a new one if not previously set"""
|
|
80
|
+
if self._client is None:
|
|
81
|
+
self._client = httpx.Client(
|
|
82
|
+
base_url=self._base_url,
|
|
83
|
+
cookies=self._cookies,
|
|
84
|
+
headers=self._headers,
|
|
85
|
+
timeout=self._timeout,
|
|
86
|
+
verify=self._verify_ssl,
|
|
87
|
+
follow_redirects=self._follow_redirects,
|
|
88
|
+
**self._httpx_args,
|
|
89
|
+
)
|
|
90
|
+
return self._client
|
|
91
|
+
|
|
92
|
+
def __enter__(self) -> "Client":
|
|
93
|
+
"""Enter a context manager for self.client—you cannot enter twice (see httpx docs)"""
|
|
94
|
+
self.get_httpx_client().__enter__()
|
|
95
|
+
return self
|
|
96
|
+
|
|
97
|
+
def __exit__(self, *args: Any, **kwargs: Any) -> None:
|
|
98
|
+
"""Exit a context manager for internal httpx.Client (see httpx docs)"""
|
|
99
|
+
self.get_httpx_client().__exit__(*args, **kwargs)
|
|
100
|
+
|
|
101
|
+
def set_async_httpx_client(self, async_client: httpx.AsyncClient) -> "Client":
|
|
102
|
+
"""Manually set the underlying httpx.AsyncClient
|
|
103
|
+
|
|
104
|
+
**NOTE**: This will override any other settings on the client, including cookies, headers, and timeout.
|
|
105
|
+
"""
|
|
106
|
+
self._async_client = async_client
|
|
107
|
+
return self
|
|
108
|
+
|
|
109
|
+
def get_async_httpx_client(self) -> httpx.AsyncClient:
|
|
110
|
+
"""Get the underlying httpx.AsyncClient, constructing a new one if not previously set"""
|
|
111
|
+
if self._async_client is None:
|
|
112
|
+
self._async_client = httpx.AsyncClient(
|
|
113
|
+
base_url=self._base_url,
|
|
114
|
+
cookies=self._cookies,
|
|
115
|
+
headers=self._headers,
|
|
116
|
+
timeout=self._timeout,
|
|
117
|
+
verify=self._verify_ssl,
|
|
118
|
+
follow_redirects=self._follow_redirects,
|
|
119
|
+
**self._httpx_args,
|
|
120
|
+
)
|
|
121
|
+
return self._async_client
|
|
122
|
+
|
|
123
|
+
async def __aenter__(self) -> "Client":
|
|
124
|
+
"""Enter a context manager for underlying httpx.AsyncClient—you cannot enter twice (see httpx docs)"""
|
|
125
|
+
await self.get_async_httpx_client().__aenter__()
|
|
126
|
+
return self
|
|
127
|
+
|
|
128
|
+
async def __aexit__(self, *args: Any, **kwargs: Any) -> None:
|
|
129
|
+
"""Exit a context manager for underlying httpx.AsyncClient (see httpx docs)"""
|
|
130
|
+
await self.get_async_httpx_client().__aexit__(*args, **kwargs)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@define
|
|
134
|
+
class AuthenticatedClient:
|
|
135
|
+
"""A Client which has been authenticated for use on secured endpoints
|
|
136
|
+
|
|
137
|
+
The following are accepted as keyword arguments and will be used to construct httpx Clients internally:
|
|
138
|
+
|
|
139
|
+
``base_url``: The base URL for the API, all requests are made to a relative path to this URL
|
|
140
|
+
|
|
141
|
+
``cookies``: A dictionary of cookies to be sent with every request
|
|
142
|
+
|
|
143
|
+
``headers``: A dictionary of headers to be sent with every request
|
|
144
|
+
|
|
145
|
+
``timeout``: The maximum amount of a time a request can take. API functions will raise
|
|
146
|
+
httpx.TimeoutException if this is exceeded.
|
|
147
|
+
|
|
148
|
+
``verify_ssl``: Whether or not to verify the SSL certificate of the API server. This should be True in production,
|
|
149
|
+
but can be set to False for testing purposes.
|
|
150
|
+
|
|
151
|
+
``follow_redirects``: Whether or not to follow redirects. Default value is False.
|
|
152
|
+
|
|
153
|
+
``httpx_args``: A dictionary of additional arguments to be passed to the ``httpx.Client`` and ``httpx.AsyncClient`` constructor.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
raise_on_unexpected_status: bool = field(default=False, kw_only=True)
|
|
157
|
+
_base_url: str = field(alias="base_url")
|
|
158
|
+
_cookies: dict[str, str] = field(factory=dict, kw_only=True, alias="cookies")
|
|
159
|
+
_headers: dict[str, str] = field(factory=dict, kw_only=True, alias="headers")
|
|
160
|
+
_timeout: httpx.Timeout | None = field(default=None, kw_only=True, alias="timeout")
|
|
161
|
+
_verify_ssl: str | bool | ssl.SSLContext = field(
|
|
162
|
+
default=True, kw_only=True, alias="verify_ssl"
|
|
163
|
+
)
|
|
164
|
+
_follow_redirects: bool = field(
|
|
165
|
+
default=False, kw_only=True, alias="follow_redirects"
|
|
166
|
+
)
|
|
167
|
+
_httpx_args: dict[str, Any] = field(factory=dict, kw_only=True, alias="httpx_args")
|
|
168
|
+
_client: httpx.Client | None = field(default=None, init=False)
|
|
169
|
+
_async_client: httpx.AsyncClient | None = field(default=None, init=False)
|
|
170
|
+
|
|
171
|
+
token: str
|
|
172
|
+
prefix: str = "Bearer"
|
|
173
|
+
auth_header_name: str = "Authorization"
|
|
174
|
+
|
|
175
|
+
def with_headers(self, headers: dict[str, str]) -> "AuthenticatedClient":
|
|
176
|
+
"""Get a new client matching this one with additional headers"""
|
|
177
|
+
if self._client is not None:
|
|
178
|
+
self._client.headers.update(headers)
|
|
179
|
+
if self._async_client is not None:
|
|
180
|
+
self._async_client.headers.update(headers)
|
|
181
|
+
return evolve(self, headers={**self._headers, **headers})
|
|
182
|
+
|
|
183
|
+
def with_cookies(self, cookies: dict[str, str]) -> "AuthenticatedClient":
|
|
184
|
+
"""Get a new client matching this one with additional cookies"""
|
|
185
|
+
if self._client is not None:
|
|
186
|
+
self._client.cookies.update(cookies)
|
|
187
|
+
if self._async_client is not None:
|
|
188
|
+
self._async_client.cookies.update(cookies)
|
|
189
|
+
return evolve(self, cookies={**self._cookies, **cookies})
|
|
190
|
+
|
|
191
|
+
def with_timeout(self, timeout: httpx.Timeout) -> "AuthenticatedClient":
|
|
192
|
+
"""Get a new client matching this one with a new timeout configuration"""
|
|
193
|
+
if self._client is not None:
|
|
194
|
+
self._client.timeout = timeout
|
|
195
|
+
if self._async_client is not None:
|
|
196
|
+
self._async_client.timeout = timeout
|
|
197
|
+
return evolve(self, timeout=timeout)
|
|
198
|
+
|
|
199
|
+
def set_httpx_client(self, client: httpx.Client) -> "AuthenticatedClient":
|
|
200
|
+
"""Manually set the underlying httpx.Client
|
|
201
|
+
|
|
202
|
+
**NOTE**: This will override any other settings on the client, including cookies, headers, and timeout.
|
|
203
|
+
"""
|
|
204
|
+
self._client = client
|
|
205
|
+
return self
|
|
206
|
+
|
|
207
|
+
def get_httpx_client(self) -> httpx.Client:
|
|
208
|
+
"""Get the underlying httpx.Client, constructing a new one if not previously set"""
|
|
209
|
+
if self._client is None:
|
|
210
|
+
self._headers[self.auth_header_name] = (
|
|
211
|
+
f"{self.prefix} {self.token}" if self.prefix else self.token
|
|
212
|
+
)
|
|
213
|
+
self._client = httpx.Client(
|
|
214
|
+
base_url=self._base_url,
|
|
215
|
+
cookies=self._cookies,
|
|
216
|
+
headers=self._headers,
|
|
217
|
+
timeout=self._timeout,
|
|
218
|
+
verify=self._verify_ssl,
|
|
219
|
+
follow_redirects=self._follow_redirects,
|
|
220
|
+
**self._httpx_args,
|
|
221
|
+
)
|
|
222
|
+
return self._client
|
|
223
|
+
|
|
224
|
+
def __enter__(self) -> "AuthenticatedClient":
|
|
225
|
+
"""Enter a context manager for self.client—you cannot enter twice (see httpx docs)"""
|
|
226
|
+
self.get_httpx_client().__enter__()
|
|
227
|
+
return self
|
|
228
|
+
|
|
229
|
+
def __exit__(self, *args: Any, **kwargs: Any) -> None:
|
|
230
|
+
"""Exit a context manager for internal httpx.Client (see httpx docs)"""
|
|
231
|
+
self.get_httpx_client().__exit__(*args, **kwargs)
|
|
232
|
+
|
|
233
|
+
def set_async_httpx_client(
|
|
234
|
+
self, async_client: httpx.AsyncClient
|
|
235
|
+
) -> "AuthenticatedClient":
|
|
236
|
+
"""Manually set the underlying httpx.AsyncClient
|
|
237
|
+
|
|
238
|
+
**NOTE**: This will override any other settings on the client, including cookies, headers, and timeout.
|
|
239
|
+
"""
|
|
240
|
+
self._async_client = async_client
|
|
241
|
+
return self
|
|
242
|
+
|
|
243
|
+
def get_async_httpx_client(self) -> httpx.AsyncClient:
|
|
244
|
+
"""Get the underlying httpx.AsyncClient, constructing a new one if not previously set"""
|
|
245
|
+
if self._async_client is None:
|
|
246
|
+
self._headers[self.auth_header_name] = (
|
|
247
|
+
f"{self.prefix} {self.token}" if self.prefix else self.token
|
|
248
|
+
)
|
|
249
|
+
self._async_client = httpx.AsyncClient(
|
|
250
|
+
base_url=self._base_url,
|
|
251
|
+
cookies=self._cookies,
|
|
252
|
+
headers=self._headers,
|
|
253
|
+
timeout=self._timeout,
|
|
254
|
+
verify=self._verify_ssl,
|
|
255
|
+
follow_redirects=self._follow_redirects,
|
|
256
|
+
**self._httpx_args,
|
|
257
|
+
)
|
|
258
|
+
return self._async_client
|
|
259
|
+
|
|
260
|
+
async def __aenter__(self) -> "AuthenticatedClient":
|
|
261
|
+
"""Enter a context manager for underlying httpx.AsyncClient—you cannot enter twice (see httpx docs)"""
|
|
262
|
+
await self.get_async_httpx_client().__aenter__()
|
|
263
|
+
return self
|
|
264
|
+
|
|
265
|
+
async def __aexit__(self, *args: Any, **kwargs: Any) -> None:
|
|
266
|
+
"""Exit a context manager for underlying httpx.AsyncClient (see httpx docs)"""
|
|
267
|
+
await self.get_async_httpx_client().__aexit__(*args, **kwargs)
|