threecommon 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.
- threecommon/__init__.py +79 -0
- threecommon/_core/__init__.py +6 -0
- threecommon/_core/headers.py +41 -0
- threecommon/_core/http_client.py +424 -0
- threecommon/_core/parse.py +77 -0
- threecommon/_core/retry.py +80 -0
- threecommon/_core/telemetry.py +77 -0
- threecommon/_core/url.py +31 -0
- threecommon/_generated/__init__.py +8 -0
- threecommon/_generated/models.py +614 -0
- threecommon/api_version.py +15 -0
- threecommon/client.py +184 -0
- threecommon/config.py +140 -0
- threecommon/errors/__init__.py +35 -0
- threecommon/errors/base.py +81 -0
- threecommon/errors/classes.py +75 -0
- threecommon/events/__init__.py +28 -0
- threecommon/events/service.py +170 -0
- threecommon/events/types.py +124 -0
- threecommon/filters/__init__.py +51 -0
- threecommon/filters/builder.py +188 -0
- threecommon/filters/types.py +69 -0
- threecommon/helpers.py +19 -0
- threecommon/invoices/__init__.py +40 -0
- threecommon/invoices/service.py +266 -0
- threecommon/invoices/types.py +195 -0
- threecommon/pagination/__init__.py +12 -0
- threecommon/pagination/auto_paginator.py +98 -0
- threecommon/py.typed +0 -0
- threecommon/version.py +10 -0
- threecommon-0.1.0.dist-info/METADATA +399 -0
- threecommon-0.1.0.dist-info/RECORD +34 -0
- threecommon-0.1.0.dist-info/WHEEL +4 -0
- threecommon-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Sync and async events services.
|
|
2
|
+
|
|
3
|
+
Both services share the same wire shape and validation logic; the only
|
|
4
|
+
difference is which HTTP client they call.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
from urllib.parse import quote
|
|
11
|
+
|
|
12
|
+
from threecommon._core.http_client import Request
|
|
13
|
+
from threecommon.errors.classes import ValidationError
|
|
14
|
+
from threecommon.events.types import (
|
|
15
|
+
Event,
|
|
16
|
+
ListEventsResponse,
|
|
17
|
+
ListParams,
|
|
18
|
+
RetrieveParams,
|
|
19
|
+
UpdateBody,
|
|
20
|
+
)
|
|
21
|
+
from threecommon.pagination import AsyncIter, Iter
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from threecommon._core.http_client import AsyncHTTPClient, HTTPClient
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _encode_list_params(params: ListParams | None) -> dict[str, str] | None:
|
|
28
|
+
if params is None:
|
|
29
|
+
return None
|
|
30
|
+
raw = params.model_dump(by_alias=True, exclude_none=True)
|
|
31
|
+
if not raw:
|
|
32
|
+
return None
|
|
33
|
+
return {k: str(v) for k, v in raw.items()}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _encode_retrieve_params(params: RetrieveParams | None) -> dict[str, str] | None:
|
|
37
|
+
if params is None or params.fields is None:
|
|
38
|
+
return None
|
|
39
|
+
return {"fields": params.fields}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _require_id(method: str, event_id: str) -> None:
|
|
43
|
+
if not event_id:
|
|
44
|
+
msg = f"events.{method}: id must be a non-empty string"
|
|
45
|
+
raise ValidationError(code="missing_id", message=msg)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _path_for(event_id: str) -> str:
|
|
49
|
+
return f"/events/{quote(event_id, safe='')}"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ────────────────────────────────────────────────────────────────────────────
|
|
53
|
+
# Sync
|
|
54
|
+
# ────────────────────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class EventsService:
|
|
58
|
+
"""Sync events service — bound as ``client.events`` on [ThreeCommon]."""
|
|
59
|
+
|
|
60
|
+
__slots__ = ("_http",)
|
|
61
|
+
|
|
62
|
+
def __init__(self, http: HTTPClient) -> None:
|
|
63
|
+
self._http = http
|
|
64
|
+
|
|
65
|
+
def list(self, params: ListParams | None = None) -> ListEventsResponse:
|
|
66
|
+
"""List the authenticated host's events (one page).
|
|
67
|
+
|
|
68
|
+
For full iteration use [list_auto_paginate][EventsService.list_auto_paginate].
|
|
69
|
+
"""
|
|
70
|
+
body = self._http.request(
|
|
71
|
+
Request(method="GET", path="/events", query=_encode_list_params(params))
|
|
72
|
+
)
|
|
73
|
+
return ListEventsResponse.model_validate(body)
|
|
74
|
+
|
|
75
|
+
def retrieve(self, event_id: str, params: RetrieveParams | None = None) -> Event:
|
|
76
|
+
"""Retrieve a single event by ID."""
|
|
77
|
+
_require_id("retrieve", event_id)
|
|
78
|
+
body = self._http.request(
|
|
79
|
+
Request(method="GET", path=_path_for(event_id), query=_encode_retrieve_params(params))
|
|
80
|
+
)
|
|
81
|
+
return Event.model_validate(body["data"])
|
|
82
|
+
|
|
83
|
+
def update(self, event_id: str, body: UpdateBody) -> Event:
|
|
84
|
+
"""Update an event's basic fields. Only fields you provide change."""
|
|
85
|
+
_require_id("update", event_id)
|
|
86
|
+
if body is None:
|
|
87
|
+
raise ValidationError(
|
|
88
|
+
code="missing_body", message="events.update: body must be non-None"
|
|
89
|
+
)
|
|
90
|
+
payload = body.model_dump(by_alias=True, exclude_none=True)
|
|
91
|
+
response = self._http.request(
|
|
92
|
+
Request(method="PATCH", path=_path_for(event_id), body=payload)
|
|
93
|
+
)
|
|
94
|
+
return Event.model_validate(response["data"])
|
|
95
|
+
|
|
96
|
+
def list_auto_paginate(self, params: ListParams | None = None) -> Iter[Event]:
|
|
97
|
+
"""Iterate every event matching ``params``, paging automatically."""
|
|
98
|
+
start_page = params.page if params is not None and params.page is not None else 0
|
|
99
|
+
|
|
100
|
+
def fetch(page: int) -> tuple[list[Event], bool]:
|
|
101
|
+
page_params = (
|
|
102
|
+
params.model_copy(update={"page": page})
|
|
103
|
+
if params is not None
|
|
104
|
+
else ListParams(page=page)
|
|
105
|
+
)
|
|
106
|
+
body = self._http.request(
|
|
107
|
+
Request(method="GET", path="/events", query=_encode_list_params(page_params))
|
|
108
|
+
)
|
|
109
|
+
response = ListEventsResponse.model_validate(body)
|
|
110
|
+
return response.data, response.has_more
|
|
111
|
+
|
|
112
|
+
return Iter(fetch_page=fetch, start_page=start_page)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ────────────────────────────────────────────────────────────────────────────
|
|
116
|
+
# Async
|
|
117
|
+
# ────────────────────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class AsyncEventsService:
|
|
121
|
+
"""Async events service — bound as ``client.events`` on [AsyncThreeCommon]."""
|
|
122
|
+
|
|
123
|
+
__slots__ = ("_http",)
|
|
124
|
+
|
|
125
|
+
def __init__(self, http: AsyncHTTPClient) -> None:
|
|
126
|
+
self._http = http
|
|
127
|
+
|
|
128
|
+
async def list(self, params: ListParams | None = None) -> ListEventsResponse:
|
|
129
|
+
body = await self._http.request(
|
|
130
|
+
Request(method="GET", path="/events", query=_encode_list_params(params))
|
|
131
|
+
)
|
|
132
|
+
return ListEventsResponse.model_validate(body)
|
|
133
|
+
|
|
134
|
+
async def retrieve(self, event_id: str, params: RetrieveParams | None = None) -> Event:
|
|
135
|
+
_require_id("retrieve", event_id)
|
|
136
|
+
body = await self._http.request(
|
|
137
|
+
Request(method="GET", path=_path_for(event_id), query=_encode_retrieve_params(params))
|
|
138
|
+
)
|
|
139
|
+
return Event.model_validate(body["data"])
|
|
140
|
+
|
|
141
|
+
async def update(self, event_id: str, body: UpdateBody) -> Event:
|
|
142
|
+
_require_id("update", event_id)
|
|
143
|
+
if body is None:
|
|
144
|
+
raise ValidationError(
|
|
145
|
+
code="missing_body", message="events.update: body must be non-None"
|
|
146
|
+
)
|
|
147
|
+
payload = body.model_dump(by_alias=True, exclude_none=True)
|
|
148
|
+
response = await self._http.request(
|
|
149
|
+
Request(method="PATCH", path=_path_for(event_id), body=payload)
|
|
150
|
+
)
|
|
151
|
+
return Event.model_validate(response["data"])
|
|
152
|
+
|
|
153
|
+
def list_auto_paginate(self, params: ListParams | None = None) -> AsyncIter[Event]:
|
|
154
|
+
"""Async iterate every event matching ``params``."""
|
|
155
|
+
start_page = params.page if params is not None and params.page is not None else 0
|
|
156
|
+
http = self._http
|
|
157
|
+
|
|
158
|
+
async def fetch(page: int) -> tuple[list[Event], bool]:
|
|
159
|
+
page_params = (
|
|
160
|
+
params.model_copy(update={"page": page})
|
|
161
|
+
if params is not None
|
|
162
|
+
else ListParams(page=page)
|
|
163
|
+
)
|
|
164
|
+
body = await http.request(
|
|
165
|
+
Request(method="GET", path="/events", query=_encode_list_params(page_params))
|
|
166
|
+
)
|
|
167
|
+
response = ListEventsResponse.model_validate(body)
|
|
168
|
+
return response.data, response.has_more
|
|
169
|
+
|
|
170
|
+
return AsyncIter(fetch_page=fetch, start_page=start_page)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Public types for the events resource.
|
|
2
|
+
|
|
3
|
+
Hand-curated friendly aliases over the auto-generated OpenAPI types in
|
|
4
|
+
[threecommon._generated][]. Field names match the Python convention
|
|
5
|
+
(snake_case in the SDK; the wire payload preserves camelCase via Pydantic
|
|
6
|
+
field aliases).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
14
|
+
|
|
15
|
+
#: Lifecycle status of an event. Surface-mirrors the API enum; future
|
|
16
|
+
#: server-side additions will arrive as raw strings until the SDK is updated.
|
|
17
|
+
EventStatus = Literal[
|
|
18
|
+
"draft",
|
|
19
|
+
"open",
|
|
20
|
+
"closed",
|
|
21
|
+
"unpublished",
|
|
22
|
+
"cancelled",
|
|
23
|
+
"postponed",
|
|
24
|
+
"schedule",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _BaseModel(BaseModel):
|
|
29
|
+
"""Base class with our default ``ConfigDict``.
|
|
30
|
+
|
|
31
|
+
``populate_by_name=True`` lets customers construct via either snake_case
|
|
32
|
+
or camelCase. ``extra="ignore"`` keeps the SDK forward-compatible: new
|
|
33
|
+
server-side fields don't break older SDKs.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
model_config = ConfigDict(
|
|
37
|
+
populate_by_name=True,
|
|
38
|
+
extra="ignore",
|
|
39
|
+
str_strip_whitespace=False,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Event(_BaseModel):
|
|
44
|
+
"""One event as returned by the API.
|
|
45
|
+
|
|
46
|
+
Optional fields are populated only when the server returned them — list
|
|
47
|
+
responses with a ``fields`` filter omit unrequested values.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
id: str
|
|
51
|
+
name: str | None = None
|
|
52
|
+
type: str | None = None
|
|
53
|
+
schedule: str | None = None
|
|
54
|
+
start: str | None = None # ISO 8601
|
|
55
|
+
status: EventStatus | None = None
|
|
56
|
+
items_sold: int | None = Field(
|
|
57
|
+
default=None, serialization_alias="itemsSold", validation_alias="itemsSold"
|
|
58
|
+
)
|
|
59
|
+
revenue_cents: int | None = Field(
|
|
60
|
+
default=None, serialization_alias="revenueCents", validation_alias="revenueCents"
|
|
61
|
+
)
|
|
62
|
+
min_price_cents: int | None = Field(
|
|
63
|
+
default=None, serialization_alias="minPriceCents", validation_alias="minPriceCents"
|
|
64
|
+
)
|
|
65
|
+
max_price_cents: int | None = Field(
|
|
66
|
+
default=None, serialization_alias="maxPriceCents", validation_alias="maxPriceCents"
|
|
67
|
+
)
|
|
68
|
+
currency: str | None = None
|
|
69
|
+
is_public: bool | None = Field(
|
|
70
|
+
default=None, serialization_alias="isPublic", validation_alias="isPublic"
|
|
71
|
+
)
|
|
72
|
+
is_virtual: bool | None = Field(
|
|
73
|
+
default=None, serialization_alias="isVirtual", validation_alias="isVirtual"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ListEventsResponse(_BaseModel):
|
|
78
|
+
"""Successful response shape from ``GET /v1/events``."""
|
|
79
|
+
|
|
80
|
+
data: list[Event]
|
|
81
|
+
has_more: bool = Field(serialization_alias="hasMore", validation_alias="hasMore")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class ListParams(_BaseModel):
|
|
85
|
+
"""Query parameters accepted by ``GET /v1/events``.
|
|
86
|
+
|
|
87
|
+
All fields are optional; pass only what you need.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
page: int | None = None
|
|
91
|
+
page_size: int | None = Field(
|
|
92
|
+
default=None, serialization_alias="pageSize", validation_alias="pageSize"
|
|
93
|
+
)
|
|
94
|
+
status: EventStatus | None = None
|
|
95
|
+
search: str | None = None
|
|
96
|
+
start_before: str | None = Field(
|
|
97
|
+
default=None, serialization_alias="startBefore", validation_alias="startBefore"
|
|
98
|
+
)
|
|
99
|
+
start_after: str | None = Field(
|
|
100
|
+
default=None, serialization_alias="startAfter", validation_alias="startAfter"
|
|
101
|
+
)
|
|
102
|
+
sort_field: str | None = Field(
|
|
103
|
+
default=None, serialization_alias="sortField", validation_alias="sortField"
|
|
104
|
+
)
|
|
105
|
+
sort_direction: Literal["asc", "desc"] | None = Field(
|
|
106
|
+
default=None, serialization_alias="sortDirection", validation_alias="sortDirection"
|
|
107
|
+
)
|
|
108
|
+
filters: str | None = None
|
|
109
|
+
fields: str | None = None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class RetrieveParams(_BaseModel):
|
|
113
|
+
"""Query parameters accepted by ``GET /v1/events/{id}``."""
|
|
114
|
+
|
|
115
|
+
fields: str | None = None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class UpdateBody(_BaseModel):
|
|
119
|
+
"""Body shape accepted by ``PATCH /v1/events/{id}``.
|
|
120
|
+
|
|
121
|
+
Only fields you provide are changed.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
name: str | None = None
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Typed builder + wire types for the API's ``filters`` query parameter.
|
|
2
|
+
|
|
3
|
+
Every endpoint that accepts ``filters`` consumes this same shape, so the
|
|
4
|
+
builder lives in a shared package rather than per-resource. Build filters
|
|
5
|
+
with :func:`field`, combine groups with :func:`and_` / :func:`or_` /
|
|
6
|
+
:func:`combine`, and serialize before passing as the ``filters`` query
|
|
7
|
+
argument:
|
|
8
|
+
|
|
9
|
+
from threecommon import filters
|
|
10
|
+
|
|
11
|
+
f = filters.and_(
|
|
12
|
+
filters.field("status").is_any_of(["open"]),
|
|
13
|
+
filters.field("ticket_sum").is_greater_than(10),
|
|
14
|
+
)
|
|
15
|
+
client.events.list(filters=f.serialize())
|
|
16
|
+
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from threecommon.filters.builder import (
|
|
20
|
+
CombinedFilters,
|
|
21
|
+
FieldRef,
|
|
22
|
+
SerializableFilter,
|
|
23
|
+
and_,
|
|
24
|
+
combine,
|
|
25
|
+
field,
|
|
26
|
+
or_,
|
|
27
|
+
)
|
|
28
|
+
from threecommon.filters.types import (
|
|
29
|
+
FilterCondition,
|
|
30
|
+
FilterGroup,
|
|
31
|
+
FilterLogic,
|
|
32
|
+
FilterOperator,
|
|
33
|
+
FilterRange,
|
|
34
|
+
FilterValue,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
__all__ = (
|
|
38
|
+
"CombinedFilters",
|
|
39
|
+
"FieldRef",
|
|
40
|
+
"FilterCondition",
|
|
41
|
+
"FilterGroup",
|
|
42
|
+
"FilterLogic",
|
|
43
|
+
"FilterOperator",
|
|
44
|
+
"FilterRange",
|
|
45
|
+
"FilterValue",
|
|
46
|
+
"SerializableFilter",
|
|
47
|
+
"and_",
|
|
48
|
+
"combine",
|
|
49
|
+
"field",
|
|
50
|
+
"or_",
|
|
51
|
+
)
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Typed filter builder. Produces JSON payloads for the API's ``filters`` arg."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from dataclasses import asdict
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from threecommon.filters.types import (
|
|
11
|
+
FilterCondition,
|
|
12
|
+
FilterGroup,
|
|
13
|
+
FilterRange,
|
|
14
|
+
FilterValue,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class FieldRef:
|
|
19
|
+
"""Reference to a single field. Operator methods produce a [FilterCondition]."""
|
|
20
|
+
|
|
21
|
+
__slots__ = ("_name",)
|
|
22
|
+
|
|
23
|
+
def __init__(self, name: str) -> None:
|
|
24
|
+
if not name:
|
|
25
|
+
msg = "filters.field: name must be a non-empty string"
|
|
26
|
+
raise ValueError(msg)
|
|
27
|
+
self._name = name
|
|
28
|
+
|
|
29
|
+
# Existence
|
|
30
|
+
def is_empty(self) -> FilterCondition:
|
|
31
|
+
return FilterCondition(field=self._name, operator="is_empty")
|
|
32
|
+
|
|
33
|
+
def is_not_empty(self) -> FilterCondition:
|
|
34
|
+
return FilterCondition(field=self._name, operator="is_not_empty")
|
|
35
|
+
|
|
36
|
+
# Equality
|
|
37
|
+
def is_equal_to(self, value: str | int | float | bool) -> FilterCondition:
|
|
38
|
+
return FilterCondition(field=self._name, operator="is_equal_to", value=value)
|
|
39
|
+
|
|
40
|
+
def is_not_equal_to(self, value: str | int | float | bool) -> FilterCondition:
|
|
41
|
+
return FilterCondition(field=self._name, operator="is_not_equal_to", value=value)
|
|
42
|
+
|
|
43
|
+
# Set membership
|
|
44
|
+
def is_equal_to_any_of(self, values: Iterable[str | int | float]) -> FilterCondition:
|
|
45
|
+
return FilterCondition(field=self._name, operator="is_equal_to_any_of", value=list(values))
|
|
46
|
+
|
|
47
|
+
def is_not_equal_to_any_of(self, values: Iterable[str | int | float]) -> FilterCondition:
|
|
48
|
+
return FilterCondition(
|
|
49
|
+
field=self._name, operator="is_not_equal_to_any_of", value=list(values)
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def is_any_of(self, values: Iterable[str | int | float]) -> FilterCondition:
|
|
53
|
+
return FilterCondition(field=self._name, operator="is_any_of", value=list(values))
|
|
54
|
+
|
|
55
|
+
def is_none_of(self, values: Iterable[str | int | float]) -> FilterCondition:
|
|
56
|
+
return FilterCondition(field=self._name, operator="is_none_of", value=list(values))
|
|
57
|
+
|
|
58
|
+
# Substring
|
|
59
|
+
def contains(self, value: str) -> FilterCondition:
|
|
60
|
+
return FilterCondition(field=self._name, operator="contains", value=value)
|
|
61
|
+
|
|
62
|
+
def contains_exactly(self, value: str) -> FilterCondition:
|
|
63
|
+
return FilterCondition(field=self._name, operator="contains_exactly", value=value)
|
|
64
|
+
|
|
65
|
+
# Date
|
|
66
|
+
def is_before(self, value: str) -> FilterCondition:
|
|
67
|
+
return FilterCondition(field=self._name, operator="is_before", value=value)
|
|
68
|
+
|
|
69
|
+
def is_after(self, value: str) -> FilterCondition:
|
|
70
|
+
return FilterCondition(field=self._name, operator="is_after", value=value)
|
|
71
|
+
|
|
72
|
+
# Numeric
|
|
73
|
+
def is_greater_than(self, value: int | float) -> FilterCondition:
|
|
74
|
+
return FilterCondition(field=self._name, operator="is_greater_than", value=value)
|
|
75
|
+
|
|
76
|
+
def is_greater_than_or_equal_to(self, value: int | float) -> FilterCondition:
|
|
77
|
+
return FilterCondition(
|
|
78
|
+
field=self._name, operator="is_greater_than_or_equal_to", value=value
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def is_less_than(self, value: int | float) -> FilterCondition:
|
|
82
|
+
return FilterCondition(field=self._name, operator="is_less_than", value=value)
|
|
83
|
+
|
|
84
|
+
def is_less_than_or_equal_to(self, value: int | float) -> FilterCondition:
|
|
85
|
+
return FilterCondition(field=self._name, operator="is_less_than_or_equal_to", value=value)
|
|
86
|
+
|
|
87
|
+
# Range
|
|
88
|
+
def is_between(self, value: FilterRange) -> FilterCondition:
|
|
89
|
+
return FilterCondition(field=self._name, operator="is_between", value=value)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def field(name: str) -> FieldRef:
|
|
93
|
+
"""Reference a field by name. See [FieldRef] for available operators."""
|
|
94
|
+
return FieldRef(name)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class SerializableFilter:
|
|
98
|
+
"""A [FilterGroup] augmented with JSON-string convenience methods.
|
|
99
|
+
|
|
100
|
+
Returned by :func:`and_` / :func:`or_`. Can be nested inside another
|
|
101
|
+
group because it exposes the underlying [group][SerializableFilter.group].
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
__slots__ = ("group",)
|
|
105
|
+
|
|
106
|
+
def __init__(self, group: FilterGroup) -> None:
|
|
107
|
+
self.group = group
|
|
108
|
+
|
|
109
|
+
def to_filters(self) -> list[FilterGroup]:
|
|
110
|
+
"""Return ``[group]`` — the wire-format expects an array of groups."""
|
|
111
|
+
return [self.group]
|
|
112
|
+
|
|
113
|
+
def serialize(self) -> str:
|
|
114
|
+
"""JSON-string form, ready to assign to the ``filters`` query param."""
|
|
115
|
+
return json.dumps([_dump(self.group)], separators=(",", ":"))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class CombinedFilters:
|
|
119
|
+
"""Multiple top-level groups joined implicitly with AND.
|
|
120
|
+
|
|
121
|
+
Most callers want :func:`and_` / :func:`or_` instead — use :func:`combine`
|
|
122
|
+
only when you specifically need an array of independent groups.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
__slots__ = ("groups",)
|
|
126
|
+
|
|
127
|
+
def __init__(self, groups: list[FilterGroup]) -> None:
|
|
128
|
+
if not groups:
|
|
129
|
+
msg = "filters.combine: at least one group is required"
|
|
130
|
+
raise ValueError(msg)
|
|
131
|
+
self.groups = groups
|
|
132
|
+
|
|
133
|
+
def to_filters(self) -> list[FilterGroup]:
|
|
134
|
+
return list(self.groups)
|
|
135
|
+
|
|
136
|
+
def serialize(self) -> str:
|
|
137
|
+
return json.dumps([_dump(g) for g in self.groups], separators=(",", ":"))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def and_(*items: FilterCondition | FilterGroup | SerializableFilter) -> SerializableFilter:
|
|
141
|
+
"""Combine items with AND. At least one is required."""
|
|
142
|
+
return _make("and", items)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def or_(*items: FilterCondition | FilterGroup | SerializableFilter) -> SerializableFilter:
|
|
146
|
+
"""Combine items with OR. At least one is required."""
|
|
147
|
+
return _make("or", items)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def combine(*groups: FilterGroup | SerializableFilter) -> CombinedFilters:
|
|
151
|
+
"""Combine multiple top-level groups."""
|
|
152
|
+
flattened = [g.group if isinstance(g, SerializableFilter) else g for g in groups]
|
|
153
|
+
return CombinedFilters(flattened)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _make(
|
|
157
|
+
logic: str,
|
|
158
|
+
items: tuple[FilterCondition | FilterGroup | SerializableFilter, ...],
|
|
159
|
+
) -> SerializableFilter:
|
|
160
|
+
if not items:
|
|
161
|
+
msg = f"filters.{logic}_: at least one condition or group is required"
|
|
162
|
+
raise ValueError(msg)
|
|
163
|
+
flattened: list[FilterCondition | FilterGroup] = []
|
|
164
|
+
for it in items:
|
|
165
|
+
if isinstance(it, SerializableFilter):
|
|
166
|
+
flattened.append(it.group)
|
|
167
|
+
else:
|
|
168
|
+
flattened.append(it)
|
|
169
|
+
return SerializableFilter(FilterGroup(logic=logic, conditions=tuple(flattened))) # type: ignore[arg-type]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _dump(node: FilterCondition | FilterGroup) -> dict[str, Any]:
|
|
173
|
+
"""Recursively convert a condition/group into the JSON-shape dict."""
|
|
174
|
+
if isinstance(node, FilterGroup):
|
|
175
|
+
return {
|
|
176
|
+
"logic": node.logic,
|
|
177
|
+
"conditions": [_dump(c) for c in node.conditions],
|
|
178
|
+
}
|
|
179
|
+
payload: dict[str, Any] = {"field": node.field, "operator": node.operator}
|
|
180
|
+
if node.value is not None:
|
|
181
|
+
payload["value"] = _dump_value(node.value)
|
|
182
|
+
return payload
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _dump_value(value: FilterValue) -> Any:
|
|
186
|
+
if isinstance(value, FilterRange):
|
|
187
|
+
return asdict(value)
|
|
188
|
+
return value
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Wire types for the API's ``filters`` query parameter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Literal, Union
|
|
7
|
+
|
|
8
|
+
#: Boolean connector for a [FilterGroup].
|
|
9
|
+
FilterLogic = Literal["and", "or"]
|
|
10
|
+
|
|
11
|
+
#: Full set of operators supported by the API.
|
|
12
|
+
FilterOperator = Literal[
|
|
13
|
+
"is_empty",
|
|
14
|
+
"is_not_empty",
|
|
15
|
+
"is_equal_to",
|
|
16
|
+
"is_not_equal_to",
|
|
17
|
+
"is_equal_to_any_of",
|
|
18
|
+
"is_not_equal_to_any_of",
|
|
19
|
+
"is_any_of",
|
|
20
|
+
"is_none_of",
|
|
21
|
+
"contains",
|
|
22
|
+
"contains_exactly",
|
|
23
|
+
"is_before",
|
|
24
|
+
"is_after",
|
|
25
|
+
"is_greater_than",
|
|
26
|
+
"is_greater_than_or_equal_to",
|
|
27
|
+
"is_less_than",
|
|
28
|
+
"is_less_than_or_equal_to",
|
|
29
|
+
"is_between",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class FilterRange:
|
|
35
|
+
"""Range envelope used by ``is_between``.
|
|
36
|
+
|
|
37
|
+
``start`` and ``end`` must agree on type (both numeric or both ISO date strings).
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
start: int | float | str
|
|
41
|
+
end: int | float | str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
#: Every value the wire format accepts.
|
|
45
|
+
FilterValue = Union[
|
|
46
|
+
str,
|
|
47
|
+
int,
|
|
48
|
+
float,
|
|
49
|
+
bool,
|
|
50
|
+
"list[str | int | float]",
|
|
51
|
+
FilterRange,
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True, slots=True)
|
|
56
|
+
class FilterCondition:
|
|
57
|
+
"""Single ``field operator value?`` triple."""
|
|
58
|
+
|
|
59
|
+
field: str
|
|
60
|
+
operator: FilterOperator
|
|
61
|
+
value: FilterValue | None = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True, slots=True)
|
|
65
|
+
class FilterGroup:
|
|
66
|
+
"""Logical group of conditions or nested groups."""
|
|
67
|
+
|
|
68
|
+
logic: FilterLogic
|
|
69
|
+
conditions: tuple[FilterCondition | FilterGroup, ...]
|
threecommon/helpers.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Small utility helpers exposed at the package root."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TypeVar
|
|
6
|
+
|
|
7
|
+
T = TypeVar("T")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def not_none(value: T | None, name: str) -> T:
|
|
11
|
+
"""Return ``value`` if non-None, else raise ``ValueError`` naming the field.
|
|
12
|
+
|
|
13
|
+
Used internally by service methods to surface a clean error when a
|
|
14
|
+
required keyword argument is the literal ``None``.
|
|
15
|
+
"""
|
|
16
|
+
if value is None:
|
|
17
|
+
msg = f"{name} must not be None"
|
|
18
|
+
raise ValueError(msg)
|
|
19
|
+
return value
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Invoices resource — sync and async clients plus public types.
|
|
2
|
+
|
|
3
|
+
Most callers reach this module through
|
|
4
|
+
[ThreeCommon.invoices][threecommon.ThreeCommon] /
|
|
5
|
+
[AsyncThreeCommon.invoices][threecommon.AsyncThreeCommon]; importing the
|
|
6
|
+
service classes directly is supported for advanced wiring.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from threecommon.invoices.service import AsyncInvoicesService, InvoicesService
|
|
10
|
+
from threecommon.invoices.types import (
|
|
11
|
+
CreateBody,
|
|
12
|
+
Invoice,
|
|
13
|
+
InvoiceCurrency,
|
|
14
|
+
InvoiceLineItem,
|
|
15
|
+
InvoicePayment,
|
|
16
|
+
InvoiceStatus,
|
|
17
|
+
ListInvoicesResponse,
|
|
18
|
+
ListParams,
|
|
19
|
+
PaymentBody,
|
|
20
|
+
RetrieveParams,
|
|
21
|
+
UpdateBody,
|
|
22
|
+
VoidBody,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__all__ = (
|
|
26
|
+
"AsyncInvoicesService",
|
|
27
|
+
"CreateBody",
|
|
28
|
+
"Invoice",
|
|
29
|
+
"InvoiceCurrency",
|
|
30
|
+
"InvoiceLineItem",
|
|
31
|
+
"InvoicePayment",
|
|
32
|
+
"InvoiceStatus",
|
|
33
|
+
"InvoicesService",
|
|
34
|
+
"ListInvoicesResponse",
|
|
35
|
+
"ListParams",
|
|
36
|
+
"PaymentBody",
|
|
37
|
+
"RetrieveParams",
|
|
38
|
+
"UpdateBody",
|
|
39
|
+
"VoidBody",
|
|
40
|
+
)
|