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.
Files changed (39) hide show
  1. meterbase/__init__.py +59 -0
  2. meterbase/_async/__init__.py +0 -0
  3. meterbase/_async/_io.py +22 -0
  4. meterbase/_async/_transport.py +181 -0
  5. meterbase/_async/client.py +427 -0
  6. meterbase/_async/resources/__init__.py +16 -0
  7. meterbase/_async/resources/customer_allowances.py +89 -0
  8. meterbase/_async/resources/customer_periods.py +41 -0
  9. meterbase/_async/resources/customer_plan.py +228 -0
  10. meterbase/_async/resources/customers.py +176 -0
  11. meterbase/_async/resources/meters.py +102 -0
  12. meterbase/_async/resources/plans.py +140 -0
  13. meterbase/_http.py +205 -0
  14. meterbase/_json.py +18 -0
  15. meterbase/_keys.py +39 -0
  16. meterbase/_models.py +177 -0
  17. meterbase/_not_given.py +32 -0
  18. meterbase/_sync/__init__.py +4 -0
  19. meterbase/_sync/_io.py +21 -0
  20. meterbase/_sync/_transport.py +185 -0
  21. meterbase/_sync/client.py +431 -0
  22. meterbase/_sync/resources/__init__.py +20 -0
  23. meterbase/_sync/resources/customer_allowances.py +93 -0
  24. meterbase/_sync/resources/customer_periods.py +45 -0
  25. meterbase/_sync/resources/customer_plan.py +230 -0
  26. meterbase/_sync/resources/customers.py +176 -0
  27. meterbase/_sync/resources/meters.py +106 -0
  28. meterbase/_sync/resources/plans.py +144 -0
  29. meterbase/_time.py +49 -0
  30. meterbase/_version.py +1 -0
  31. meterbase/errors.py +309 -0
  32. meterbase/py.typed +0 -0
  33. meterbase/resources.py +41 -0
  34. meterbase/types.py +600 -0
  35. meterbase/webhooks.py +154 -0
  36. meterbase-0.1.0.dist-info/METADATA +867 -0
  37. meterbase-0.1.0.dist-info/RECORD +39 -0
  38. meterbase-0.1.0.dist-info/WHEEL +4 -0
  39. 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
@@ -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()
@@ -0,0 +1,4 @@
1
+ # Generated by scripts/unasync.py from
2
+ # src/meterbase/_async/__init__.py.
3
+ # Do not edit: change the async source, then run `make unasync`.
4
+
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