meterbase 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.
- meterbase/__init__.py +59 -0
- meterbase/_async/__init__.py +0 -0
- meterbase/_async/_io.py +22 -0
- meterbase/_async/_transport.py +181 -0
- meterbase/_async/client.py +427 -0
- meterbase/_async/resources/__init__.py +16 -0
- meterbase/_async/resources/customer_allowances.py +89 -0
- meterbase/_async/resources/customer_periods.py +41 -0
- meterbase/_async/resources/customer_plan.py +228 -0
- meterbase/_async/resources/customers.py +176 -0
- meterbase/_async/resources/meters.py +102 -0
- meterbase/_async/resources/plans.py +140 -0
- meterbase/_http.py +205 -0
- meterbase/_json.py +18 -0
- meterbase/_keys.py +39 -0
- meterbase/_models.py +177 -0
- meterbase/_not_given.py +32 -0
- meterbase/_sync/__init__.py +4 -0
- meterbase/_sync/_io.py +21 -0
- meterbase/_sync/_transport.py +185 -0
- meterbase/_sync/client.py +431 -0
- meterbase/_sync/resources/__init__.py +20 -0
- meterbase/_sync/resources/customer_allowances.py +93 -0
- meterbase/_sync/resources/customer_periods.py +45 -0
- meterbase/_sync/resources/customer_plan.py +230 -0
- meterbase/_sync/resources/customers.py +176 -0
- meterbase/_sync/resources/meters.py +106 -0
- meterbase/_sync/resources/plans.py +144 -0
- meterbase/_time.py +49 -0
- meterbase/_version.py +1 -0
- meterbase/errors.py +309 -0
- meterbase/py.typed +0 -0
- meterbase/resources.py +41 -0
- meterbase/types.py +600 -0
- meterbase/webhooks.py +154 -0
- meterbase-0.1.0.dist-info/METADATA +867 -0
- meterbase-0.1.0.dist-info/RECORD +39 -0
- meterbase-0.1.0.dist-info/WHEEL +4 -0
- meterbase-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ..._http import segment
|
|
4
|
+
from ..._models import ListResult, list_of
|
|
5
|
+
from ..._not_given import NOT_GIVEN, NotGiven
|
|
6
|
+
from ...types import (
|
|
7
|
+
ApplyMode,
|
|
8
|
+
Plan,
|
|
9
|
+
PlanAllowance,
|
|
10
|
+
PlanAllowanceKind,
|
|
11
|
+
PlanCycle,
|
|
12
|
+
Validity,
|
|
13
|
+
ValidityParam,
|
|
14
|
+
)
|
|
15
|
+
from .._transport import AsyncRequester
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AsyncPlanAllowances:
|
|
19
|
+
"""
|
|
20
|
+
A plan's entitlement, versioned. There is no update and no delete: an edit
|
|
21
|
+
appends the next version, so which amount applied when stays derivable.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, api: AsyncRequester) -> None:
|
|
25
|
+
self._api = api
|
|
26
|
+
|
|
27
|
+
async def set(
|
|
28
|
+
self,
|
|
29
|
+
plan_id: str,
|
|
30
|
+
*,
|
|
31
|
+
meter_id: str,
|
|
32
|
+
amount: int | NotGiven = NOT_GIVEN,
|
|
33
|
+
no_cap: bool | NotGiven = NOT_GIVEN,
|
|
34
|
+
allow_overage: bool | NotGiven = NOT_GIVEN,
|
|
35
|
+
kind: PlanAllowanceKind | NotGiven = NOT_GIVEN,
|
|
36
|
+
valid_for: ValidityParam | Validity | NotGiven = NOT_GIVEN,
|
|
37
|
+
apply_mode: ApplyMode | NotGiven = NOT_GIVEN,
|
|
38
|
+
) -> PlanAllowance:
|
|
39
|
+
"""
|
|
40
|
+
An allowance says how much, never how often: the period is the plan's
|
|
41
|
+
cycle. Give exactly one of `amount` or `no_cap`.
|
|
42
|
+
|
|
43
|
+
`allow_overage` is a soft cap: past `amount`, admit rather than refuse.
|
|
44
|
+
Defaults to `False`; refused beside `no_cap` and on `one_time`. `kind`
|
|
45
|
+
defaults to `recurring`. `valid_for` is `one_time` only. `apply_mode`
|
|
46
|
+
defaults to `next_cycle`, except `one_time` which is always immediate.
|
|
47
|
+
"""
|
|
48
|
+
return await self._api.request(
|
|
49
|
+
"POST",
|
|
50
|
+
f"/v1/plans/{segment(plan_id)}/allowances",
|
|
51
|
+
PlanAllowance,
|
|
52
|
+
body={
|
|
53
|
+
"meter_id": meter_id,
|
|
54
|
+
"amount": amount,
|
|
55
|
+
"no_cap": no_cap,
|
|
56
|
+
"allow_overage": allow_overage,
|
|
57
|
+
"kind": kind,
|
|
58
|
+
"valid_for": valid_for,
|
|
59
|
+
"apply_mode": apply_mode,
|
|
60
|
+
},
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
async def list(
|
|
64
|
+
self, plan_id: str, *, meter_id: str | NotGiven = NOT_GIVEN
|
|
65
|
+
) -> ListResult[PlanAllowance]:
|
|
66
|
+
"""Every version of every meter, newest first — history, not just
|
|
67
|
+
current."""
|
|
68
|
+
return await self._api.request(
|
|
69
|
+
"GET",
|
|
70
|
+
f"/v1/plans/{segment(plan_id)}/allowances",
|
|
71
|
+
list_of(PlanAllowance),
|
|
72
|
+
query={"meter_id": meter_id},
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class AsyncPlans:
|
|
77
|
+
allowances: AsyncPlanAllowances
|
|
78
|
+
|
|
79
|
+
def __init__(self, api: AsyncRequester) -> None:
|
|
80
|
+
self._api = api
|
|
81
|
+
self.allowances = AsyncPlanAllowances(api)
|
|
82
|
+
|
|
83
|
+
async def create(
|
|
84
|
+
self,
|
|
85
|
+
*,
|
|
86
|
+
key: str,
|
|
87
|
+
name: str,
|
|
88
|
+
cycle: PlanCycle,
|
|
89
|
+
description: str | NotGiven = NOT_GIVEN,
|
|
90
|
+
) -> Plan:
|
|
91
|
+
"""A new plan grants nothing; attach entitlement with
|
|
92
|
+
`allowances.set`."""
|
|
93
|
+
return await self._api.request(
|
|
94
|
+
"POST",
|
|
95
|
+
"/v1/plans",
|
|
96
|
+
Plan,
|
|
97
|
+
body={"key": key, "name": name, "description": description, "cycle": cycle},
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
async def list(
|
|
101
|
+
self, *, include_archived: bool | NotGiven = NOT_GIVEN
|
|
102
|
+
) -> ListResult[Plan]:
|
|
103
|
+
return await self._api.request(
|
|
104
|
+
"GET",
|
|
105
|
+
"/v1/plans",
|
|
106
|
+
list_of(Plan),
|
|
107
|
+
query={"include_archived": include_archived},
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
async def retrieve(self, id: str) -> Plan:
|
|
111
|
+
return await self._api.request("GET", f"/v1/plans/{segment(id)}", Plan)
|
|
112
|
+
|
|
113
|
+
async def update(
|
|
114
|
+
self,
|
|
115
|
+
id: str,
|
|
116
|
+
*,
|
|
117
|
+
key: str | NotGiven = NOT_GIVEN,
|
|
118
|
+
name: str | NotGiven = NOT_GIVEN,
|
|
119
|
+
description: str | NotGiven = NOT_GIVEN,
|
|
120
|
+
) -> Plan:
|
|
121
|
+
"""
|
|
122
|
+
An omitted field is left alone; `""` clears the description. There is
|
|
123
|
+
no `cycle`: moving a plan's period would re-date every period its
|
|
124
|
+
customers were already measured against, so a different cadence is a
|
|
125
|
+
different plan.
|
|
126
|
+
"""
|
|
127
|
+
return await self._api.request(
|
|
128
|
+
"PATCH",
|
|
129
|
+
f"/v1/plans/{segment(id)}",
|
|
130
|
+
Plan,
|
|
131
|
+
body={"key": key, "name": name, "description": description},
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
async def archive(self, id: str) -> Plan:
|
|
135
|
+
"""
|
|
136
|
+
Stops new assignments without withdrawing capacity from anyone already
|
|
137
|
+
on the plan, which is why an archived plan still resolves by id.
|
|
138
|
+
Idempotent.
|
|
139
|
+
"""
|
|
140
|
+
return await self._api.request("DELETE", f"/v1/plans/{segment(id)}", Plan)
|
meterbase/_http.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""
|
|
2
|
+
What the two transports share: building a request, reading the answer, and
|
|
3
|
+
deciding whether to try again. None of it does I/O, so the sync and async
|
|
4
|
+
clients cannot disagree about any of it.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import math
|
|
11
|
+
import random
|
|
12
|
+
from collections.abc import Mapping, Sequence
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any, cast
|
|
15
|
+
from urllib.parse import quote, urlencode
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from ._json import is_object
|
|
20
|
+
from ._models import Model
|
|
21
|
+
from ._not_given import NotGiven
|
|
22
|
+
from ._version import __version__
|
|
23
|
+
from .errors import APIError, MeterbaseError, error_from_response
|
|
24
|
+
|
|
25
|
+
DEFAULT_BASE_URL = "https://api.meterbase.tech"
|
|
26
|
+
DEFAULT_TIMEOUT = 10.0
|
|
27
|
+
DEFAULT_MAX_RETRIES = 2
|
|
28
|
+
MAX_BACKOFF = 8.0
|
|
29
|
+
|
|
30
|
+
USER_AGENT = f"meterbase-python/{__version__}"
|
|
31
|
+
|
|
32
|
+
Query = Mapping[str, object]
|
|
33
|
+
Body = Mapping[str, object]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class RequestOptions:
|
|
38
|
+
timeout: float
|
|
39
|
+
"""Seconds, per attempt rather than per call."""
|
|
40
|
+
max_retries: int
|
|
41
|
+
|
|
42
|
+
def __post_init__(self) -> None:
|
|
43
|
+
# A negative count would run the loop no times at all, and a call that
|
|
44
|
+
# makes no attempt has nothing to answer with.
|
|
45
|
+
if self.max_retries < 0:
|
|
46
|
+
raise MeterbaseError("max_retries should be 0 or more.")
|
|
47
|
+
if not self.timeout > 0:
|
|
48
|
+
raise MeterbaseError("timeout should be a number of seconds above 0.")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def normalize_base_url(base_url: str | None) -> str:
|
|
52
|
+
url = (DEFAULT_BASE_URL if base_url is None else base_url).rstrip("/")
|
|
53
|
+
try:
|
|
54
|
+
parsed = httpx.URL(url)
|
|
55
|
+
except httpx.InvalidURL:
|
|
56
|
+
parsed = None
|
|
57
|
+
|
|
58
|
+
if parsed is None or parsed.scheme not in ("http", "https") or not parsed.host:
|
|
59
|
+
raise MeterbaseError(
|
|
60
|
+
f"base_url should be an http or https URL, such as {DEFAULT_BASE_URL}; "
|
|
61
|
+
f"got {base_url!r}."
|
|
62
|
+
)
|
|
63
|
+
return url
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def origin_of(base_url: str) -> str:
|
|
67
|
+
url = httpx.URL(base_url)
|
|
68
|
+
return f"{url.scheme}://{url.netloc.decode('ascii')}"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def segment(value: str) -> str:
|
|
72
|
+
"""One path segment, percent-encoded, a slash included."""
|
|
73
|
+
return quote(value, safe="")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def build_url(base_url: str, path: str, query: Query | None) -> str:
|
|
77
|
+
pairs = [
|
|
78
|
+
(key, _query_value(value))
|
|
79
|
+
for key, value in (query or {}).items()
|
|
80
|
+
if value is not None and not isinstance(value, NotGiven)
|
|
81
|
+
]
|
|
82
|
+
return base_url + path + (f"?{urlencode(pairs)}" if pairs else "")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _query_value(value: object) -> str:
|
|
86
|
+
# As the engine spells them, rather than Python's `True`.
|
|
87
|
+
if isinstance(value, bool):
|
|
88
|
+
return "true" if value else "false"
|
|
89
|
+
return str(value)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def request_headers(api_key: str, *, has_body: bool) -> dict[str, str]:
|
|
93
|
+
headers = {
|
|
94
|
+
"Authorization": f"Bearer {api_key}",
|
|
95
|
+
"Accept": "application/json",
|
|
96
|
+
"User-Agent": USER_AGENT,
|
|
97
|
+
}
|
|
98
|
+
if has_body:
|
|
99
|
+
headers["Content-Type"] = "application/json"
|
|
100
|
+
return headers
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def encode_body(body: Body) -> bytes:
|
|
104
|
+
"""
|
|
105
|
+
JSON, leaving out every field that is `NOT_GIVEN`. The engine refuses a
|
|
106
|
+
field it does not know, so a default this SDK sent on the caller's behalf
|
|
107
|
+
could break a call against an engine that predates it.
|
|
108
|
+
"""
|
|
109
|
+
fields = {
|
|
110
|
+
key: value for key, value in body.items() if not isinstance(value, NotGiven)
|
|
111
|
+
}
|
|
112
|
+
return json.dumps(
|
|
113
|
+
fields,
|
|
114
|
+
default=_json_default,
|
|
115
|
+
ensure_ascii=False,
|
|
116
|
+
allow_nan=False,
|
|
117
|
+
separators=(",", ":"),
|
|
118
|
+
).encode("utf-8")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _json_default(value: object) -> object:
|
|
122
|
+
# A model sent back as a parameter is the JSON it was read from.
|
|
123
|
+
if isinstance(value, Model):
|
|
124
|
+
return value.to_dict()
|
|
125
|
+
if isinstance(value, Mapping):
|
|
126
|
+
return dict(cast("Mapping[object, object]", value))
|
|
127
|
+
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
|
128
|
+
return list(cast("Sequence[object]", value))
|
|
129
|
+
raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def parse_body(response: httpx.Response) -> Any:
|
|
133
|
+
if response.status_code == 204 or not response.content:
|
|
134
|
+
return None
|
|
135
|
+
try:
|
|
136
|
+
return json.loads(response.content)
|
|
137
|
+
except ValueError as error:
|
|
138
|
+
raise MeterbaseError("The engine returned a body that is not JSON.") from error
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def error_from(response: httpx.Response) -> APIError:
|
|
142
|
+
text = response.text
|
|
143
|
+
parsed = _safe_json(text)
|
|
144
|
+
envelope = _error_envelope(parsed)
|
|
145
|
+
status = response.status_code
|
|
146
|
+
|
|
147
|
+
return error_from_response(
|
|
148
|
+
status=status,
|
|
149
|
+
code=envelope[0] if envelope else f"http_{status}",
|
|
150
|
+
message=envelope[1] if envelope else f"The engine responded {status}.",
|
|
151
|
+
request_id=response.headers.get("x-request-id"),
|
|
152
|
+
retry_after=parse_retry_after(response.headers.get("retry-after")),
|
|
153
|
+
body=None if text == "" else (text if parsed is _NOT_JSON else parsed),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
_NOT_JSON = object()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _safe_json(text: str) -> Any:
|
|
161
|
+
try:
|
|
162
|
+
return json.loads(text)
|
|
163
|
+
except ValueError:
|
|
164
|
+
return _NOT_JSON
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _error_envelope(parsed: Any) -> tuple[str, str] | None:
|
|
168
|
+
"""`{"error": {"code", "message"}}`, both there, or nothing."""
|
|
169
|
+
error = parsed.get("error") if is_object(parsed) else None
|
|
170
|
+
if not is_object(error):
|
|
171
|
+
return None
|
|
172
|
+
code, message = error.get("code"), error.get("message")
|
|
173
|
+
if (
|
|
174
|
+
not isinstance(code, str)
|
|
175
|
+
or not isinstance(message, str)
|
|
176
|
+
or not code
|
|
177
|
+
or not message
|
|
178
|
+
):
|
|
179
|
+
return None
|
|
180
|
+
return code, message
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def parse_retry_after(header: str | None) -> float | None:
|
|
184
|
+
"""Seconds. The date form of `Retry-After` reads as nothing."""
|
|
185
|
+
if not header:
|
|
186
|
+
return None
|
|
187
|
+
try:
|
|
188
|
+
seconds = float(header)
|
|
189
|
+
except ValueError:
|
|
190
|
+
return None
|
|
191
|
+
return seconds if math.isfinite(seconds) else None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def should_retry(status: int) -> bool:
|
|
195
|
+
return status == 408 or status == 429 or status >= 500
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def backoff(attempt: int, last_error: BaseException | None) -> float:
|
|
199
|
+
"""Exponential backoff with full jitter, unless the engine named a delay."""
|
|
200
|
+
retry_after = getattr(last_error, "retry_after", None)
|
|
201
|
+
if isinstance(retry_after, (int, float)):
|
|
202
|
+
return max(0.0, min(float(retry_after), MAX_BACKOFF))
|
|
203
|
+
|
|
204
|
+
ceiling = min(0.5 * 2.0 ** (attempt - 1), MAX_BACKOFF)
|
|
205
|
+
return random.random() * ceiling
|
meterbase/_json.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from typing import Any, TypeGuard
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def is_object(value: object) -> TypeGuard[dict[str, Any]]:
|
|
8
|
+
"""A JSON object, as `json.loads` reads one."""
|
|
9
|
+
return isinstance(value, dict)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def is_mapping(value: object) -> TypeGuard[Mapping[str, Any]]:
|
|
13
|
+
return isinstance(value, Mapping)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def is_array(value: object) -> TypeGuard[list[Any]]:
|
|
17
|
+
"""A JSON array, as `json.loads` reads one."""
|
|
18
|
+
return isinstance(value, list)
|
meterbase/_keys.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import secrets
|
|
4
|
+
import uuid
|
|
5
|
+
|
|
6
|
+
IDEMPOTENCY_WINDOW_MS = 60 * 60 * 1000
|
|
7
|
+
"""How far the engine lets a key's timestamp sit from its own clock."""
|
|
8
|
+
|
|
9
|
+
RESERVE_WINDOW_MS = 30 * 60 * 1000
|
|
10
|
+
"""
|
|
11
|
+
The same guard for a reservation id, and narrower on purpose: the id is minted
|
|
12
|
+
at the call, and the engine has to leave room to commit the hold it names long
|
|
13
|
+
after.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def mint(at_ms: float) -> str:
|
|
18
|
+
"""
|
|
19
|
+
A UUIDv7: 48 bits of Unix milliseconds, then the version and variant, then
|
|
20
|
+
74 random bits. `uuid.uuid4()` mints a v4, which carries no time, and
|
|
21
|
+
`uuid.uuid7()` reads this machine's clock, so this builds one from random
|
|
22
|
+
bytes.
|
|
23
|
+
|
|
24
|
+
`at_ms` is the engine's clock as the client knows it, not this machine's: a
|
|
25
|
+
device an hour out would otherwise mint keys the engine refuses.
|
|
26
|
+
"""
|
|
27
|
+
raw = bytearray(secrets.token_bytes(16))
|
|
28
|
+
|
|
29
|
+
ms = max(0, int(at_ms))
|
|
30
|
+
raw[0:6] = (ms & 0xFFFF_FFFF_FFFF).to_bytes(6, "big")
|
|
31
|
+
raw[6] = (raw[6] & 0x0F) | 0x70 # version 7
|
|
32
|
+
raw[8] = (raw[8] & 0x3F) | 0x80 # variant 10
|
|
33
|
+
|
|
34
|
+
return str(uuid.UUID(bytes=bytes(raw)))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def minted_at_ms(key: str) -> int:
|
|
38
|
+
"""The millisecond a v7 carries in its first 48 bits."""
|
|
39
|
+
return int(key.replace("-", "")[:12], 16)
|
meterbase/_models.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import keyword
|
|
5
|
+
import types
|
|
6
|
+
from collections.abc import Callable, Mapping
|
|
7
|
+
from typing import (
|
|
8
|
+
TYPE_CHECKING,
|
|
9
|
+
Any,
|
|
10
|
+
ClassVar,
|
|
11
|
+
Generic,
|
|
12
|
+
TypeVar,
|
|
13
|
+
Union,
|
|
14
|
+
get_args,
|
|
15
|
+
get_origin,
|
|
16
|
+
get_type_hints,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from ._json import is_array, is_mapping, is_object
|
|
20
|
+
from .errors import MeterbaseError
|
|
21
|
+
|
|
22
|
+
_M = TypeVar("_M", bound="Model")
|
|
23
|
+
_Convert = Callable[[Any], Any]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Model:
|
|
27
|
+
"""
|
|
28
|
+
One JSON object the engine sent, as it sent it, read-only.
|
|
29
|
+
|
|
30
|
+
There is no translation layer between the two: `to_dict()` returns the
|
|
31
|
+
object exactly as it arrived, and `model["field"]` reads one field of it,
|
|
32
|
+
which is how to reach a field this release of the SDK does not know yet.
|
|
33
|
+
The attributes are the same values, typed. A field that is itself an object
|
|
34
|
+
reads as a model, and a list of them as a list of models.
|
|
35
|
+
|
|
36
|
+
A field named for a Python keyword takes a trailing underscore: `from_`
|
|
37
|
+
reads `from`.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
_raw: dict[str, Any]
|
|
41
|
+
|
|
42
|
+
def __init__(self, raw: Mapping[str, Any]) -> None:
|
|
43
|
+
if not is_mapping(raw):
|
|
44
|
+
raise TypeError(
|
|
45
|
+
f"{type(self).__name__} reads a JSON object, not {type(raw).__name__}"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
state = self.__dict__
|
|
49
|
+
state["_raw"] = data = dict(raw)
|
|
50
|
+
for attr, (key, convert) in _fields(type(self)).items():
|
|
51
|
+
if key in data:
|
|
52
|
+
state[attr] = convert(data[key])
|
|
53
|
+
|
|
54
|
+
def to_dict(self) -> dict[str, Any]:
|
|
55
|
+
"""The object exactly as the engine sent it, as plain JSON types."""
|
|
56
|
+
return copy.deepcopy(self._raw)
|
|
57
|
+
|
|
58
|
+
def __getitem__(self, key: str) -> Any:
|
|
59
|
+
return self._raw[key]
|
|
60
|
+
|
|
61
|
+
def __contains__(self, key: object) -> bool:
|
|
62
|
+
return key in self._raw
|
|
63
|
+
|
|
64
|
+
def __eq__(self, other: object) -> bool:
|
|
65
|
+
# Equal to the same JSON, whether that arrives as a model or a dict.
|
|
66
|
+
if isinstance(other, Model):
|
|
67
|
+
return self._raw == other._raw
|
|
68
|
+
if is_mapping(other):
|
|
69
|
+
return self._raw == dict(other)
|
|
70
|
+
return NotImplemented
|
|
71
|
+
|
|
72
|
+
def __repr__(self) -> str:
|
|
73
|
+
attrs = {key: attr for attr, (key, _) in _fields(type(self)).items()}
|
|
74
|
+
shown = ", ".join(
|
|
75
|
+
f"{attrs[key]}={self.__dict__.get(attrs[key], value)!r}"
|
|
76
|
+
if key in attrs
|
|
77
|
+
else f"{key}={value!r}"
|
|
78
|
+
for key, value in self._raw.items()
|
|
79
|
+
)
|
|
80
|
+
return f"{type(self).__name__}({shown})"
|
|
81
|
+
|
|
82
|
+
if not TYPE_CHECKING: # pragma: no branch
|
|
83
|
+
# Hidden from type checkers, which would otherwise take a class that
|
|
84
|
+
# defines these to accept any attribute at all.
|
|
85
|
+
|
|
86
|
+
def __setattr__(self, name, value):
|
|
87
|
+
raise AttributeError(f"{type(self).__name__} is read-only")
|
|
88
|
+
|
|
89
|
+
def __delattr__(self, name):
|
|
90
|
+
raise AttributeError(f"{type(self).__name__} is read-only")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
_T = TypeVar("_T")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class ListResult(Model, Generic[_T]):
|
|
97
|
+
"""Enveloped so pagination can arrive without breaking callers."""
|
|
98
|
+
|
|
99
|
+
data: list[_T]
|
|
100
|
+
|
|
101
|
+
def __init__(
|
|
102
|
+
self,
|
|
103
|
+
raw: Mapping[str, Any],
|
|
104
|
+
item: Callable[[Mapping[str, Any]], _T] | None = None,
|
|
105
|
+
) -> None:
|
|
106
|
+
super().__init__(raw)
|
|
107
|
+
items = self._raw.get("data")
|
|
108
|
+
if item is not None and is_array(items):
|
|
109
|
+
self.__dict__["data"] = [
|
|
110
|
+
item(each) if is_mapping(each) else each for each in items
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def list_of(item: type[_M]) -> Callable[[Mapping[str, Any]], ListResult[_M]]:
|
|
115
|
+
"""Reads a listing whose rows are `item`."""
|
|
116
|
+
return lambda raw: ListResult(raw, item)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def construct(cast_to: Callable[[dict[str, Any]], _T], data: Any) -> _T:
|
|
120
|
+
"""Reads a response body as the object a call answers with."""
|
|
121
|
+
if not is_object(data):
|
|
122
|
+
raise MeterbaseError(
|
|
123
|
+
"The engine answered without the JSON object this call reads, "
|
|
124
|
+
f"sending {type(data).__name__} instead."
|
|
125
|
+
)
|
|
126
|
+
return cast_to(data)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
_FIELDS: dict[type[Model], dict[str, tuple[str, _Convert]]] = {}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _fields(cls: type[Model]) -> dict[str, tuple[str, _Convert]]:
|
|
133
|
+
"""Each attribute a model declares, the wire key behind it, and how to read
|
|
134
|
+
that key's value."""
|
|
135
|
+
cached = _FIELDS.get(cls)
|
|
136
|
+
if cached is not None:
|
|
137
|
+
return cached
|
|
138
|
+
|
|
139
|
+
fields: dict[str, tuple[str, _Convert]] = {}
|
|
140
|
+
for attr, hint in get_type_hints(cls).items():
|
|
141
|
+
if attr.startswith("_") or get_origin(hint) is ClassVar:
|
|
142
|
+
continue
|
|
143
|
+
stem = attr[:-1]
|
|
144
|
+
key = stem if attr.endswith("_") and keyword.iskeyword(stem) else attr
|
|
145
|
+
fields[attr] = (key, _converter(hint))
|
|
146
|
+
|
|
147
|
+
_FIELDS[cls] = fields
|
|
148
|
+
return fields
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _identity(value: Any) -> Any:
|
|
152
|
+
return value
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _converter(hint: Any) -> _Convert:
|
|
156
|
+
origin = get_origin(hint)
|
|
157
|
+
|
|
158
|
+
if origin is Union or origin is types.UnionType:
|
|
159
|
+
shapes = [arg for arg in get_args(hint) if arg is not type(None)]
|
|
160
|
+
# A union of several shapes is left as sent: nothing here can tell
|
|
161
|
+
# which one a value is.
|
|
162
|
+
return _converter(shapes[0]) if len(shapes) == 1 else _identity
|
|
163
|
+
|
|
164
|
+
if origin is list:
|
|
165
|
+
args = get_args(hint)
|
|
166
|
+
read_item = _converter(args[0]) if args else _identity
|
|
167
|
+
if read_item is _identity:
|
|
168
|
+
return _identity
|
|
169
|
+
return lambda value: (
|
|
170
|
+
[read_item(each) for each in value] if is_array(value) else value
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
if origin is None and isinstance(hint, type) and issubclass(hint, Model):
|
|
174
|
+
model = hint
|
|
175
|
+
return lambda value: model(value) if is_mapping(value) else value
|
|
176
|
+
|
|
177
|
+
return _identity
|
meterbase/_not_given.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Final
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class NotGiven:
|
|
7
|
+
"""
|
|
8
|
+
A parameter the caller left out, which is not the same as `None`.
|
|
9
|
+
|
|
10
|
+
The engine reads an omitted field and a `null` differently where it reads
|
|
11
|
+
`null` at all: `thresholds=None` goes back to inheriting, and leaving
|
|
12
|
+
`thresholds` out keeps the stored list. So an omitted parameter is not
|
|
13
|
+
sent, and `None` is sent as `null`. Every optional parameter defaults to
|
|
14
|
+
`NOT_GIVEN`, and only the ones where `null` means something accept `None`.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
_instance: NotGiven | None = None
|
|
18
|
+
|
|
19
|
+
def __new__(cls) -> NotGiven:
|
|
20
|
+
# One value, so `is NOT_GIVEN` holds for a copy and a pickle too.
|
|
21
|
+
if cls._instance is None:
|
|
22
|
+
cls._instance = super().__new__(cls)
|
|
23
|
+
return cls._instance
|
|
24
|
+
|
|
25
|
+
def __repr__(self) -> str:
|
|
26
|
+
return "NOT_GIVEN"
|
|
27
|
+
|
|
28
|
+
def __reduce__(self) -> str:
|
|
29
|
+
return "NOT_GIVEN"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
NOT_GIVEN: Final = NotGiven()
|
meterbase/_sync/_io.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""
|
|
2
|
+
What the sync client does that the async one does differently, kept out of the
|
|
3
|
+
code `scripts/unasync.py` generates. `_async/_io.py` is its twin, and like this
|
|
4
|
+
file is written by hand.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from typing import TypeVar
|
|
11
|
+
|
|
12
|
+
_A = TypeVar("_A")
|
|
13
|
+
_B = TypeVar("_B")
|
|
14
|
+
|
|
15
|
+
sleep = time.sleep
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def both(first: _A, second: _B) -> tuple[_A, _B]:
|
|
19
|
+
"""Two calls' answers. The async client makes them at once; here they have
|
|
20
|
+
already been made, one after the other."""
|
|
21
|
+
return first, second
|