Coverage for polar/kit/schemas.py: 89%
101 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 12:42 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 12:42 +0000
1import dataclasses
2import json
3from collections.abc import Sequence
4from datetime import datetime
5from typing import Annotated, Any, Literal, cast, get_args, overload
7from pydantic import (
8 UUID4,
9 AfterValidator,
10 BaseModel,
11 ConfigDict,
12 Field,
13 GetCoreSchemaHandler,
14 GetJsonSchemaHandler,
15 HttpUrl,
16 PlainSerializer,
17)
18from pydantic.json_schema import JsonSchemaValue
19from pydantic_core import CoreSchema, core_schema
20from slugify import slugify
23class Schema(BaseModel):
24 model_config = ConfigDict(from_attributes=True)
27class IDSchema(Schema):
28 id: UUID4 = Field(..., description="The ID of the object.")
30 model_config = ConfigDict(
31 # IMPORTANT: this ensures FastAPI doesn't generate `-Input` for output schemas
32 json_schema_mode_override="serialization",
33 )
36class TimestampedSchema(Schema):
37 created_at: datetime = Field(description="Creation timestamp of the object.")
38 modified_at: datetime | None = Field(
39 description="Last modification timestamp of the object."
40 )
43def empty_str_to_none(value: str | None) -> str | None:
44 if isinstance(value, str):
45 stripped_value = value.strip()
46 if stripped_value == "":
47 return None
48 return stripped_value
49 return value
52EmptyStrToNoneValidator = AfterValidator(empty_str_to_none)
53EmptyStrToNone = Annotated[str | None, EmptyStrToNoneValidator]
56def _validate_slug(value: str) -> str:
57 slugified = slugify(value)
58 if slugified != value:
59 raise ValueError(
60 "The slug can only contain ASCII letters, numbers and hyphens."
61 )
62 return value
65SlugValidator = AfterValidator(_validate_slug)
67UUID4ToStr = Annotated[UUID4, PlainSerializer(lambda v: str(v), return_type=str)]
68HttpUrlToStr = Annotated[HttpUrl, PlainSerializer(lambda v: str(v), return_type=str)]
71@dataclasses.dataclass(slots=True)
72class ClassName:
73 """
74 Used as an annotation metadata, it allows us to customize the name generated
75 by Pydantic for a type; in particular, a long union.
77 It does **nothing** on its own, but it can be used by other classes.
79 Currently, it's used by `ListResource` to generate a shorter name for the
80 OpenAPI schema, when we list a resource having a long union type.
81 """
83 name: str
85 def __hash__(self) -> int:
86 return hash(type(self.name))
89@dataclasses.dataclass(slots=True)
90class MergeJSONSchema:
91 json_schema: JsonSchemaValue
92 mode: Literal["validation", "serialization"] | None = None
94 def __get_pydantic_json_schema__(
95 self, core_schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler
96 ) -> JsonSchemaValue:
97 mode = self.mode or handler.mode
98 json_schema = handler(core_schema)
99 if mode != handler.mode: 99 ↛ 100line 99 didn't jump to line 100 because the condition on line 99 was never true
100 return json_schema
101 return {**json_schema, **self.json_schema}
103 def __hash__(self) -> int:
104 return hash(type(self.mode))
107@dataclasses.dataclass(slots=True)
108class SetSchemaReference:
109 ref_name: str
111 def __get_pydantic_core_schema__(
112 self, source_type: Any, handler: GetCoreSchemaHandler
113 ) -> CoreSchema:
114 schema = handler(source_type)
115 schema["ref"] = self.ref_name # type: ignore
116 return schema
118 def __hash__(self) -> int:
119 return hash(type(self.ref_name))
122@dataclasses.dataclass(slots=True)
123class SelectorWidget:
124 resource_root: str
125 resource_name: str
126 display_property: str
128 def __get_pydantic_json_schema__(
129 self, core_schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler
130 ) -> JsonSchemaValue:
131 json_schema = handler(core_schema)
132 return {**json_schema, **self._get_extra_attributes()}
134 def _get_extra_attributes(self) -> dict[str, Any]:
135 return {
136 "x-polar-selector-widget": {
137 "resourceRoot": self.resource_root,
138 "resourceName": self.resource_name,
139 "displayProperty": self.display_property,
140 }
141 }
143 def __hash__(self) -> int:
144 return hash(json.dumps(self._get_extra_attributes()))
147class MultipleQueryFilter[Q](Sequence[Q]):
148 """
149 Custom type to handle query filters that can be either
150 a single value or a list of values.
152 By customizing the schema generation, we can make it accept
153 either a scalar or a list of values for the query parameter.
155 At runtime, we make sure that the value is always a list.
156 """
158 def __init__(self, v: Sequence[Q]):
159 self.v = v
161 @overload
162 def __getitem__(self, s: int) -> Q: ... 162 ↛ anywhereline 162 didn't jump anywhere: it always raised an exception.
164 @overload
165 def __getitem__(self, s: slice) -> Sequence[Q]: ... 165 ↛ anywhereline 165 didn't jump anywhere: it always raised an exception.
167 def __getitem__(self, s: int | slice) -> Q | Sequence[Q]:
168 return self.v[s]
170 def __len__(self) -> int:
171 return len(self.v)
173 @classmethod
174 def __get_pydantic_core_schema__(
175 cls, source: Any, handler: GetCoreSchemaHandler
176 ) -> core_schema.CoreSchema:
177 args = get_args(source)
178 if len(args) == 0: 178 ↛ 179line 178 didn't jump to line 179 because the condition on line 178 was never true
179 raise TypeError("QueryFilter requires at least one type argument")
181 generic_type = args[0]
182 sequence_schema = handler.generate_schema(Sequence[generic_type]) # type: ignore
183 scalar_schema = handler.generate_schema(generic_type)
184 union_schema = core_schema.union_schema([scalar_schema, sequence_schema])
186 return core_schema.no_info_after_validator_function(
187 cls._scalar_to_sequence, handler(union_schema)
188 )
190 @classmethod
191 def _scalar_to_sequence(cls, v: Q | Sequence[Q]) -> Sequence[Q]:
192 if isinstance(v, Sequence) and not isinstance(v, str): 192 ↛ 194line 192 didn't jump to line 194 because the condition on line 192 was always true
193 return v
194 return [cast(Q, v)] # type: ignore[redundant-cast]
197ORGANIZATION_ID_EXAMPLE = "1dbfc517-0bbf-4301-9ba8-555ca42b9737"
198PRODUCT_ID_EXAMPLE = "d8dd2de1-21b7-4a41-8bc3-ce909c0cfe23"
199PRICE_ID_EXAMPLE = "196ca717-4d84-4d28-a1b8-777255797dbc"
200BENEFIT_ID_EXAMPLE = "397a17aa-15cf-4cb4-9333-18040203cf98"
201CUSTOMER_ID_EXAMPLE = "992fae2a-2a17-4b7a-8d9e-e287cf90131b"
202SUBSCRIPTION_ID_EXAMPLE = "e5149aae-e521-42b9-b24c-abb3d71eea2e"
203BENEFIT_GRANT_ID_EXAMPLE = "d322132c-a9d0-4e0d-b8d3-d81ad021a3a9"
204METER_ID_EXAMPLE = "d498a884-e2cd-4d3e-8002-f536468a8b22"
205CHECKOUT_ID_EXAMPLE = "e4b478fa-cd25-4253-9f1f-8a41e6370ede"