afp-sdk 0.5.4__py3-none-any.whl → 0.6.1__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 (49) hide show
  1. afp/__init__.py +4 -1
  2. afp/afp.py +11 -0
  3. afp/api/admin.py +1 -1
  4. afp/api/base.py +11 -2
  5. afp/api/margin_account.py +12 -148
  6. afp/api/product.py +302 -148
  7. afp/api/trading.py +10 -19
  8. afp/auth.py +14 -0
  9. afp/bindings/__init__.py +30 -16
  10. afp/bindings/admin_facet.py +890 -0
  11. afp/bindings/clearing_facet.py +356 -773
  12. afp/bindings/facade.py +9 -6
  13. afp/bindings/final_settlement_facet.py +258 -99
  14. afp/bindings/margin_account.py +524 -839
  15. afp/bindings/margin_account_facet.py +722 -0
  16. afp/bindings/margin_account_registry.py +184 -310
  17. afp/bindings/mark_price_tracker_facet.py +74 -16
  18. afp/bindings/product_registry.py +1577 -541
  19. afp/bindings/product_registry_facet.py +1467 -0
  20. afp/bindings/system_viewer.py +592 -369
  21. afp/bindings/types.py +223 -0
  22. afp/config.py +4 -0
  23. afp/constants.py +49 -6
  24. afp/decorators.py +25 -3
  25. afp/dtos.py +142 -0
  26. afp/exceptions.py +10 -0
  27. afp/exchange.py +10 -8
  28. afp/hashing.py +7 -5
  29. afp/ipfs.py +245 -0
  30. afp/json-schemas/bafyreiaw34o6l3rmatabzbds2i2myazdw2yolevcpsoyd2i2g3ms7wa2eq.json +1 -0
  31. afp/json-schemas/bafyreibnfg6nq74dvpkre5rakkccij7iadp5rxpim7omsatjnrpmj3y7v4.json +1 -0
  32. afp/json-schemas/bafyreicgr6dfo5yduixjkcifghiulskfegwojvuwodtouvivl362zndhxe.json +1 -0
  33. afp/json-schemas/bafyreicheoypx6synljushh7mq2572iyhlolf4nake2p5dwobgnj3r5eua.json +1 -0
  34. afp/json-schemas/bafyreid35a67db4sqh4fs6boddyt2xvscbqy6nqvsp5jjur56qhkw4ixre.json +1 -0
  35. afp/json-schemas/bafyreidzs7okcpqiss6ztftltyptqwnw5e5opsy5yntospekjha4kpykaa.json +1 -0
  36. afp/json-schemas/bafyreifcec2km7hxwq6oqzjlspni2mgipetjb7pqtaewh2efislzoctboi.json +1 -0
  37. afp/json-schemas/bafyreihn3oiaxffe4e2w7pwtreadpw3obfd7gqlogbcxm56jc2hzfvco74.json +1 -0
  38. afp/json-schemas/bafyreihur3dzwhja6uxsbcw6eeoj3xmmc4e3zkmyzpot5v5dleevxe5zam.json +1 -0
  39. afp/schemas.py +236 -177
  40. afp/types.py +169 -0
  41. afp/validators.py +218 -8
  42. {afp_sdk-0.5.4.dist-info → afp_sdk-0.6.1.dist-info}/METADATA +76 -11
  43. afp_sdk-0.6.1.dist-info/RECORD +50 -0
  44. afp/bindings/auctioneer_facet.py +0 -752
  45. afp/bindings/bankruptcy_facet.py +0 -391
  46. afp/bindings/trading_protocol.py +0 -1158
  47. afp_sdk-0.5.4.dist-info/RECORD +0 -37
  48. {afp_sdk-0.5.4.dist-info → afp_sdk-0.6.1.dist-info}/WHEEL +0 -0
  49. {afp_sdk-0.5.4.dist-info → afp_sdk-0.6.1.dist-info}/licenses/LICENSE +0 -0
afp/schemas.py CHANGED
@@ -1,128 +1,58 @@
1
- from datetime import datetime
2
- from decimal import Decimal
3
- from functools import partial
4
- from typing import Annotated, Any, Literal
5
-
6
- import inflection
7
- from pydantic import (
8
- AfterValidator,
9
- AliasGenerator,
10
- BaseModel,
11
- BeforeValidator,
12
- ConfigDict,
13
- Field,
14
- PlainSerializer,
15
- computed_field,
16
- )
17
-
18
- from . import validators
19
- from .enums import ListingState, OrderSide, OrderState, OrderType, TradeState
20
-
21
-
22
- # Use datetime internally but UNIX timestamp in client-server communication
23
- Timestamp = Annotated[
24
- datetime,
25
- BeforeValidator(validators.ensure_datetime),
26
- PlainSerializer(validators.ensure_timestamp, return_type=int, when_used="json"),
27
- ]
28
-
29
-
30
- class Model(BaseModel):
31
- model_config = ConfigDict(
32
- alias_generator=AliasGenerator(
33
- alias=partial(inflection.camelize, uppercase_first_letter=False),
34
- ),
35
- frozen=True,
36
- populate_by_name=True,
37
- )
38
-
39
- # Change the default value of by_alias to True
40
- def model_dump_json(self, by_alias: bool = True, **kwargs: Any) -> str:
41
- return super().model_dump_json(by_alias=by_alias, **kwargs)
42
-
43
-
44
- class PaginationFilter(Model):
45
- batch: Annotated[None | int, Field(gt=0, exclude=True)]
46
- batch_size: Annotated[None | int, Field(gt=0, exclude=True)]
47
- newest_first: Annotated[None | bool, Field(exclude=True)]
48
-
49
- @computed_field
50
- @property
51
- def page(self) -> None | int:
52
- return self.batch
53
-
54
- @computed_field
55
- @property
56
- def page_size(self) -> None | int:
57
- return self.batch_size
58
-
59
- @computed_field
60
- @property
61
- def sort(self) -> None | Literal["ASC", "DESC"]:
62
- match self.newest_first:
63
- case None:
64
- return None
65
- case True:
66
- return "DESC"
67
- case False:
68
- return "ASC"
69
-
70
-
71
- # Authentication
72
-
73
-
74
- class LoginSubmission(Model):
75
- message: str
76
- signature: str
77
-
78
-
79
- class ExchangeParameters(Model):
80
- trading_protocol_id: str
81
- maker_trading_fee_rate: Decimal
82
- taker_trading_fee_rate: Decimal
83
-
1
+ """AFP data structures."""
84
2
 
85
- # Admin API
86
-
87
-
88
- class ExchangeProductListingSubmission(Model):
89
- id: Annotated[str, AfterValidator(validators.validate_hexstr32)]
3
+ from decimal import Decimal
4
+ from itertools import chain
5
+ from typing import Annotated, Any, ClassVar, Literal, Self
90
6
 
7
+ from pydantic import AfterValidator, BeforeValidator, Field, model_validator
91
8
 
92
- class ExchangeProductUpdateSubmission(Model):
93
- listing_state: ListingState
9
+ from . import constants, validators
10
+ from .constants import schema_cids
11
+ from .enums import ListingState, OrderSide, OrderState, OrderType, TradeState
12
+ from .types import (
13
+ CID,
14
+ URL,
15
+ AliasedModel,
16
+ ISODate,
17
+ ISODateTime,
18
+ Model,
19
+ PinnedModel,
20
+ Timestamp,
21
+ )
94
22
 
95
23
 
96
24
  # Trading API
97
25
 
98
26
 
99
- class ExchangeProduct(Model):
27
+ class ExchangeProduct(AliasedModel):
100
28
  id: str
101
- symbol: str
29
+ symbol: Annotated[str, AfterValidator(validators.validate_all_caps)]
102
30
  tick_size: int
103
31
  collateral_asset: str
104
32
  listing_state: ListingState
33
+ min_price: Decimal
34
+ max_price: Decimal
105
35
 
106
36
  def __str__(self) -> str:
107
37
  return self.id
108
38
 
109
39
 
110
- class ExchangeProductFilter(PaginationFilter):
111
- pass
112
-
113
-
114
- class IntentData(Model):
40
+ class IntentData(AliasedModel):
115
41
  trading_protocol_id: str
116
42
  product_id: str
117
- limit_price: Annotated[Decimal, Field(gt=0)]
43
+ limit_price: Decimal
118
44
  quantity: Annotated[int, Field(gt=0)]
119
- max_trading_fee_rate: Annotated[Decimal, Field(ge=0)]
45
+ max_trading_fee_rate: Annotated[
46
+ Decimal,
47
+ Field(le=Decimal((2**32 - 1) / constants.FEE_RATE_MULTIPLIER)), # uint32
48
+ ]
120
49
  side: OrderSide
121
50
  good_until_time: Timestamp
122
51
  nonce: int
52
+ referral: Annotated[str, AfterValidator(validators.validate_address)]
123
53
 
124
54
 
125
- class Intent(Model):
55
+ class Intent(AliasedModel):
126
56
  hash: str
127
57
  margin_account_id: str
128
58
  intent_account_id: str
@@ -130,7 +60,7 @@ class Intent(Model):
130
60
  data: IntentData
131
61
 
132
62
 
133
- class Order(Model):
63
+ class Order(AliasedModel):
134
64
  id: str
135
65
  type: OrderType
136
66
  timestamp: Timestamp
@@ -139,35 +69,14 @@ class Order(Model):
139
69
  intent: Intent
140
70
 
141
71
 
142
- class OrderFilter(PaginationFilter):
143
- intent_account_id: str
144
- product_id: None | Annotated[str, AfterValidator(validators.validate_hexstr32)]
145
- type: None | OrderType
146
- states: Annotated[list[OrderState], Field(exclude=True)]
147
- side: None | OrderSide
148
- start: None | Timestamp
149
- end: None | Timestamp
150
-
151
- @computed_field
152
- @property
153
- def state(self) -> str | None:
154
- return ",".join(self.states) if self.states else None
155
-
156
-
157
- class OrderCancellationData(Model):
72
+ class OrderCancellationData(AliasedModel):
158
73
  intent_hash: Annotated[str, AfterValidator(validators.validate_hexstr32)]
159
74
  nonce: int
160
75
  intent_account_id: str
161
76
  signature: str
162
77
 
163
78
 
164
- class OrderSubmission(Model):
165
- type: OrderType
166
- intent: Intent | None = None
167
- cancellation_data: OrderCancellationData | None = None
168
-
169
-
170
- class Trade(Model):
79
+ class Trade(AliasedModel):
171
80
  # Convert ID from int to str for backward compatibility
172
81
  id: Annotated[str, BeforeValidator(str)]
173
82
  product_id: str
@@ -178,7 +87,7 @@ class Trade(Model):
178
87
  rejection_reason: str | None
179
88
 
180
89
 
181
- class OrderFill(Model):
90
+ class OrderFill(AliasedModel):
182
91
  order: Order
183
92
  trade: Trade
184
93
  quantity: int
@@ -186,32 +95,18 @@ class OrderFill(Model):
186
95
  trading_fee_rate: Decimal
187
96
 
188
97
 
189
- class OrderFillFilter(PaginationFilter):
190
- intent_account_id: str
191
- product_id: None | Annotated[str, AfterValidator(validators.validate_hexstr32)]
192
- intent_hash: None | Annotated[str, AfterValidator(validators.validate_hexstr32)]
193
- start: None | Timestamp
194
- end: None | Timestamp
195
- trade_states: Annotated[list[TradeState], Field(exclude=True)]
196
-
197
- @computed_field
198
- @property
199
- def trade_state(self) -> str | None:
200
- return ",".join(self.trade_states) if self.trade_states else None
201
-
202
-
203
- class MarketDepthItem(Model):
98
+ class MarketDepthItem(AliasedModel):
204
99
  price: Decimal
205
100
  quantity: int
206
101
 
207
102
 
208
- class MarketDepthData(Model):
103
+ class MarketDepthData(AliasedModel):
209
104
  product_id: str
210
105
  bids: list[MarketDepthItem]
211
106
  asks: list[MarketDepthItem]
212
107
 
213
108
 
214
- class OHLCVItem(Model):
109
+ class OHLCVItem(AliasedModel):
215
110
  timestamp: Timestamp
216
111
  open: Decimal
217
112
  high: Decimal
@@ -220,7 +115,7 @@ class OHLCVItem(Model):
220
115
  volume: int
221
116
 
222
117
 
223
- # Clearing API
118
+ # Margin Account API
224
119
 
225
120
 
226
121
  class Transaction(Model):
@@ -237,51 +132,215 @@ class Position(Model):
237
132
  pnl: Decimal
238
133
 
239
134
 
240
- # Builder API
135
+ # Product API
241
136
 
242
137
 
243
- class ProductSpecification(Model):
244
- id: str
245
- # Product Metadata
246
- builder_id: str
247
- symbol: str
248
- description: str
249
- # Orace Specification
138
+ class ExpirySpecification(AliasedModel):
139
+ earliest_fsp_submission_time: Annotated[
140
+ ISODateTime, Field(alias="earliestFSPSubmissionTime")
141
+ ]
142
+ tradeout_interval: Annotated[int, Field(ge=0)]
143
+
144
+
145
+ class OracleSpecification(AliasedModel):
250
146
  oracle_address: Annotated[str, AfterValidator(validators.validate_address)]
251
- fsv_decimals: Annotated[int, Field(ge=0, lt=256)] # uint8
147
+ fsv_decimals: Annotated[int, Field(ge=0, le=255)] # uint8
252
148
  fsp_alpha: Decimal
253
149
  fsp_beta: Decimal
254
150
  fsv_calldata: Annotated[str, AfterValidator(validators.validate_hexstr)]
255
- # Product
256
- start_time: Timestamp
257
- earliest_fsp_submission_time: Timestamp
151
+
152
+
153
+ class ProductMetadata(AliasedModel):
154
+ builder: Annotated[str, AfterValidator(validators.validate_address)]
155
+ symbol: Annotated[
156
+ str,
157
+ Field(pattern=r"^[A-Z0-9]{1,16}$", min_length=1, max_length=16),
158
+ ]
159
+ description: str
160
+
161
+
162
+ class BaseProduct(AliasedModel):
163
+ metadata: ProductMetadata
164
+ oracle_spec: OracleSpecification
258
165
  collateral_asset: Annotated[str, AfterValidator(validators.validate_address)]
259
- price_quotation: str
260
- tick_size: Annotated[int, Field(ge=0)]
261
- unit_value: Annotated[Decimal, Field(gt=0)]
262
- initial_margin_requirement: Annotated[Decimal, Field(gt=0)]
263
- maintenance_margin_requirement: Annotated[Decimal, Field(gt=0)]
264
- auction_bounty: Annotated[Decimal, Field(ge=0, le=1)]
265
- tradeout_interval: Annotated[int, Field(ge=0)]
266
- extended_metadata: str
166
+ start_time: ISODateTime
167
+ point_value: Decimal
168
+ price_decimals: Annotated[int, Field(ge=0, le=255)] # uint8
169
+ extended_metadata: CID | None = None
267
170
 
268
- def __str__(self) -> str:
269
- return self.id
270
171
 
172
+ class PredictionProductV1(AliasedModel):
173
+ base: BaseProduct
174
+ expiry_spec: ExpirySpecification
175
+ min_price: Decimal
176
+ max_price: Decimal
271
177
 
272
- # Liquidation API
178
+ @model_validator(mode="after")
179
+ def _cross_validate(self) -> Self:
180
+ validators.validate_price_limits(self.min_price, self.max_price)
181
+ validators.validate_time_limits(
182
+ self.base.start_time, self.expiry_spec.earliest_fsp_submission_time
183
+ )
184
+ return self
273
185
 
274
186
 
275
- class Bid(Model):
276
- product_id: Annotated[str, AfterValidator(validators.validate_hexstr32)]
277
- price: Annotated[Decimal, Field(gt=0)]
278
- quantity: Annotated[int, Field(gt=0)]
279
- side: OrderSide
187
+ # Extended metadata schemas
188
+
189
+
190
+ class ApiSpec(Model):
191
+ standard: Literal["JSONPath", "GraphQL"]
192
+ spec_variant: Literal["underlying-history", "product-fsv"] | None = None
193
+
194
+
195
+ class ApiSpecJSONPath(ApiSpec):
196
+ standard: Literal["JSONPath"] = "JSONPath" # type: ignore
197
+ url: URL
198
+ date_path: str
199
+ value_path: str
200
+ auth_param_location: Literal["query", "header", "none"] = "none"
201
+ auth_param_name: str | None = None
202
+ auth_param_prefix: str | None = None
203
+ continuation_token_param: str | None = None
204
+ continuation_token_path: str | None = None
205
+ date_format_custom: str | None = None
206
+ date_format_type: Literal["iso_8601", "unix_timestamp", "custom"] = "iso_8601"
207
+ headers: dict[str, str] | None = None
208
+ max_pages: Annotated[int | None, Field(ge=1)] = 10
209
+ timestamp_scale: Annotated[int | float, Field(ge=1)] = 1
210
+ timezone: Annotated[
211
+ str,
212
+ Field(pattern=r"^[A-Za-z][A-Za-z0-9_+-]*(/[A-Za-z][A-Za-z0-9_+-]*)*$"),
213
+ ] = "UTC"
214
+
215
+
216
+ class BaseCaseResolution(Model):
217
+ condition: Annotated[str, Field(min_length=1)]
218
+ fsp_resolution: Annotated[str, Field(min_length=1)]
219
+
220
+
221
+ class EdgeCase(Model):
222
+ condition: Annotated[str, Field(min_length=1)]
223
+ fsp_resolution: Annotated[str, Field(min_length=1)]
224
+
225
+
226
+ class OutcomeSpace(PinnedModel):
227
+ SCHEMA_CID: ClassVar[CID] = schema_cids.OUTCOME_SPACE_V020
228
+
229
+ fsp_type: Literal["scalar", "binary", "ternary"]
230
+ description: Annotated[str, Field(min_length=1)]
231
+ base_case: BaseCaseResolution
232
+ edge_cases: Annotated[list[EdgeCase], Field(default_factory=list)]
233
+
234
+
235
+ class OutcomeSpaceScalar(OutcomeSpace):
236
+ SCHEMA_CID: ClassVar[CID] = schema_cids.OUTCOME_SPACE_SCALAR_V020
237
+
238
+ fsp_type: Literal["scalar"] = "scalar" # type: ignore
239
+ units: Annotated[str, Field(min_length=1)]
240
+ source_name: Annotated[str, Field(min_length=1)]
241
+ source_uri: URL
242
+
243
+
244
+ class OutcomeSpaceTimeSeries(OutcomeSpaceScalar):
245
+ SCHEMA_CID: ClassVar[CID] = schema_cids.OUTCOME_SPACE_TIME_SERIES_V020
246
+
247
+ frequency: Literal[
248
+ "daily",
249
+ "weekly",
250
+ "fortnightly",
251
+ "semimonthly",
252
+ "monthly",
253
+ "quarterly",
254
+ "yearly",
255
+ ]
256
+ history_api_spec: ApiSpecJSONPath | ApiSpec | None = None
257
+
258
+
259
+ class TemporalObservation(Model):
260
+ reference_date: ISODate
261
+ release_date: ISODate
262
+
263
+
264
+ class OutcomePoint(PinnedModel):
265
+ SCHEMA_CID: ClassVar[CID] = schema_cids.OUTCOME_POINT_V020
266
+
267
+ fsp_type: Literal["scalar", "binary", "ternary"]
268
+
269
+
270
+ class OutcomePointTimeSeries(OutcomePoint):
271
+ SCHEMA_CID: ClassVar[CID] = schema_cids.OUTCOME_POINT_TIME_SERIES_V020
272
+
273
+ fsp_type: Literal["scalar"] = "scalar" # type: ignore
274
+ observation: TemporalObservation
275
+
276
+
277
+ class OutcomePointEvent(OutcomePoint):
278
+ SCHEMA_CID: ClassVar[CID] = schema_cids.OUTCOME_POINT_EVENT_V020
279
+
280
+ fsp_type: Literal["binary", "ternary"] # type: ignore
281
+ outcome: Annotated[str, Field(min_length=1)]
282
+
283
+
284
+ class OracleConfig(PinnedModel):
285
+ SCHEMA_CID: ClassVar[CID] = schema_cids.ORACLE_CONFIG_V020
286
+
287
+ description: Annotated[str, Field(min_length=1)]
288
+ project_url: URL | None = None
289
+
290
+
291
+ class OracleConfigPrototype1(OracleConfig):
292
+ SCHEMA_CID: ClassVar[CID] = schema_cids.ORACLE_CONFIG_PROTOTYPE1_V020
293
+
294
+ evaluation_api_spec: ApiSpecJSONPath | ApiSpec
295
+
296
+
297
+ class OracleFallback(PinnedModel):
298
+ SCHEMA_CID: ClassVar[CID] = schema_cids.ORACLE_FALLBACK_V020
299
+
300
+ fallback_time: ISODateTime
301
+ fallback_fsp: Decimal
302
+
280
303
 
304
+ class PredictionProduct(Model):
305
+ product: PredictionProductV1
306
+ outcome_space: OutcomeSpaceTimeSeries | OutcomeSpaceScalar | OutcomeSpace
307
+ outcome_point: OutcomePointEvent | OutcomePointTimeSeries | OutcomePoint
308
+ oracle_config: OracleConfigPrototype1 | OracleConfig
309
+ oracle_fallback: OracleFallback
281
310
 
282
- class AuctionData(Model):
283
- start_block: int
284
- margin_account_equity_at_initiation: Decimal
285
- maintenance_margin_used_at_initiation: Decimal
286
- margin_account_equity_now: Decimal
287
- maintenance_margin_used_now: Decimal
311
+ @model_validator(mode="after")
312
+ def _cross_validate(self) -> Self:
313
+ validators.validate_matching_fsp_types(
314
+ self.outcome_space.fsp_type, self.outcome_point.fsp_type
315
+ )
316
+ validators.validate_oracle_fallback_time(
317
+ self.oracle_fallback.fallback_time,
318
+ self.product.expiry_spec.earliest_fsp_submission_time,
319
+ )
320
+ validators.validate_oracle_fallback_fsp(
321
+ self.oracle_fallback.fallback_fsp,
322
+ self.product.min_price,
323
+ self.product.max_price,
324
+ )
325
+ validators.validate_outcome_space_template_variables(
326
+ [
327
+ self.outcome_space.base_case.condition,
328
+ self.outcome_space.base_case.fsp_resolution,
329
+ ]
330
+ + list(
331
+ chain.from_iterable(
332
+ [edge_case.condition, edge_case.fsp_resolution]
333
+ for edge_case in self.outcome_space.edge_cases
334
+ )
335
+ ),
336
+ self.outcome_point.model_dump(),
337
+ )
338
+ if isinstance(self.outcome_space, OutcomeSpaceTimeSeries) and isinstance(
339
+ self.outcome_point, OutcomePointTimeSeries
340
+ ):
341
+ validators.validate_symbol(
342
+ self.product.base.metadata.symbol,
343
+ self.outcome_space.frequency,
344
+ self.outcome_point.observation.release_date,
345
+ )
346
+ return self
afp/types.py ADDED
@@ -0,0 +1,169 @@
1
+ from datetime import date, datetime, UTC
2
+ from functools import partial
3
+ from typing import Annotated, Any, ClassVar, Self
4
+
5
+ import inflection
6
+ import multiformats
7
+ import rfc8785
8
+ from pydantic import (
9
+ AfterValidator,
10
+ AliasGenerator,
11
+ BaseModel,
12
+ BeforeValidator,
13
+ ConfigDict,
14
+ Field,
15
+ PlainSerializer,
16
+ model_validator,
17
+ )
18
+
19
+ from . import constants, validators
20
+
21
+ CID_MODEL_MAP: dict[str, type["PinnedModel"]] = {}
22
+
23
+
24
+ # Conversions
25
+
26
+
27
+ def ensure_py_datetime(value: datetime | int | float | str) -> datetime:
28
+ if isinstance(value, datetime):
29
+ return value.astimezone(UTC)
30
+ if isinstance(value, int) or isinstance(value, float):
31
+ return datetime.fromtimestamp(value, UTC)
32
+ return datetime.fromisoformat(value).astimezone(UTC)
33
+
34
+
35
+ def ensure_py_date(value: date | str) -> date:
36
+ if isinstance(value, date):
37
+ return value
38
+ return date.fromisoformat(value)
39
+
40
+
41
+ def ensure_timestamp(value: datetime) -> int:
42
+ return int(value.timestamp())
43
+
44
+
45
+ def ensure_iso_datetime(value: datetime) -> str:
46
+ assert value.tzinfo is UTC, f"{value} should be in UTC timezone"
47
+ return value.strftime("%Y-%m-%dT%H:%M:%SZ")
48
+
49
+
50
+ def ensure_iso_date(value: date) -> str:
51
+ return value.isoformat()
52
+
53
+
54
+ # Custom types
55
+
56
+
57
+ URL = Annotated[
58
+ str,
59
+ Field(min_length=1, max_length=2083),
60
+ BeforeValidator(validators.validate_url),
61
+ AfterValidator(validators.verify_url),
62
+ ]
63
+
64
+ CID = Annotated[
65
+ str,
66
+ Field(
67
+ pattern=r"^(Qm[1-9A-HJ-NP-Za-km-z]{44}|b[a-z2-7]{58,}|z[1-9A-HJ-NP-Za-km-z]{48,})$"
68
+ ),
69
+ ]
70
+
71
+ # Decode CIDs for serialization so that the DAG-CBOR encoder will convert them
72
+ # into IPLD Link format
73
+ IPLD_LINK = Annotated[
74
+ CID,
75
+ BeforeValidator( # Ensure the same encoding is used consistently
76
+ lambda cid: multiformats.CID.decode(str(cid)).encode(
77
+ constants.IPFS_CID_ENCODING
78
+ )
79
+ ),
80
+ PlainSerializer(multiformats.CID.decode, return_type=multiformats.CID),
81
+ ]
82
+
83
+ # Use datetime internally but UNIX timestamp in serialized format
84
+ Timestamp = Annotated[
85
+ datetime,
86
+ BeforeValidator(ensure_py_datetime),
87
+ AfterValidator(validators.validate_non_negative_timestamp),
88
+ PlainSerializer(ensure_timestamp, return_type=int),
89
+ ]
90
+
91
+ # Use datetime internally but ISO string in serialized format
92
+ ISODateTime = Annotated[
93
+ datetime,
94
+ BeforeValidator(ensure_py_datetime),
95
+ AfterValidator(validators.validate_non_negative_timestamp),
96
+ PlainSerializer(ensure_iso_datetime, return_type=str),
97
+ ]
98
+
99
+ # Use date internally but ISO string in serialized format
100
+ ISODate = Annotated[
101
+ date,
102
+ BeforeValidator(ensure_py_date),
103
+ PlainSerializer(ensure_iso_date, return_type=str),
104
+ ]
105
+
106
+
107
+ # Base models
108
+
109
+
110
+ class Model(BaseModel):
111
+ """Base immutable schema."""
112
+
113
+ model_config = ConfigDict(frozen=True)
114
+
115
+ # Always serialize/deserialize by alias
116
+
117
+ def model_dump(self, by_alias: bool = True, **kwargs: Any) -> dict[Any, Any]:
118
+ return super().model_dump(by_alias=by_alias, **kwargs)
119
+
120
+ def model_dump_json(self, by_alias: bool = True, **kwargs: Any) -> str:
121
+ return super().model_dump_json(by_alias=by_alias, **kwargs)
122
+
123
+ def model_dump_canonical_json(self, **kwargs: Any) -> str:
124
+ obj = self.model_dump(mode="json", **kwargs)
125
+ return rfc8785.dumps(obj).decode("utf-8")
126
+
127
+ @classmethod
128
+ def model_validate(cls, *args: Any, by_alias: bool = True, **kwargs: Any) -> Self:
129
+ return super().model_validate(*args, by_alias=by_alias, **kwargs)
130
+
131
+ @classmethod
132
+ def model_validate_json(
133
+ cls, *args: Any, by_alias: bool = True, **kwargs: Any
134
+ ) -> Self:
135
+ return super().model_validate_json(*args, by_alias=by_alias, **kwargs)
136
+
137
+
138
+ class AliasedModel(Model):
139
+ """Schema that converts property names from snake case to camel case for
140
+ serialization.
141
+ """
142
+
143
+ model_config = Model.model_config | ConfigDict(
144
+ alias_generator=AliasGenerator(
145
+ alias=partial(inflection.camelize, uppercase_first_letter=False),
146
+ ),
147
+ populate_by_name=True,
148
+ )
149
+
150
+
151
+ class PinnedModel(Model):
152
+ """Extended metadata schema that has an IPFS CID."""
153
+
154
+ SCHEMA_CID: ClassVar[CID]
155
+
156
+ def __init_subclass__(cls, **kwargs: Any):
157
+ super().__init_subclass__(**kwargs)
158
+ if "SCHEMA_CID" in cls.__dict__:
159
+ assert cls.SCHEMA_CID not in CID_MODEL_MAP, (
160
+ f"{cls.__name__} model does not have unique CID"
161
+ )
162
+ CID_MODEL_MAP[cls.SCHEMA_CID] = cls
163
+
164
+ @model_validator(mode="after")
165
+ def _ensure_schema_cid(self) -> Self:
166
+ assert "SCHEMA_CID" in self.__class__.__dict__, (
167
+ f"SCHEMA_CID is missing from {self.__class__.__name__} schema"
168
+ )
169
+ return self