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.
@@ -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
+ )