afp-sdk 0.5.4__py3-none-any.whl → 0.6.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 (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 +227 -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.0.dist-info}/METADATA +73 -10
  43. afp_sdk-0.6.0.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.0.dist-info}/WHEEL +0 -0
  49. {afp_sdk-0.5.4.dist-info → afp_sdk-0.6.0.dist-info}/licenses/LICENSE +0 -0
afp/schemas.py CHANGED
@@ -1,128 +1,57 @@
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
-
84
-
85
- # Admin API
1
+ """AFP data structures."""
86
2
 
3
+ from decimal import Decimal
4
+ from typing import Annotated, Any, ClassVar, Literal, Self
87
5
 
88
- class ExchangeProductListingSubmission(Model):
89
- id: Annotated[str, AfterValidator(validators.validate_hexstr32)]
90
-
6
+ from pydantic import AfterValidator, BeforeValidator, Field, model_validator
91
7
 
92
- class ExchangeProductUpdateSubmission(Model):
93
- listing_state: ListingState
8
+ from . import constants, validators
9
+ from .constants import schema_cids
10
+ from .enums import ListingState, OrderSide, OrderState, OrderType, TradeState
11
+ from .types import (
12
+ CID,
13
+ URL,
14
+ AliasedModel,
15
+ ISODate,
16
+ ISODateTime,
17
+ Model,
18
+ PinnedModel,
19
+ Timestamp,
20
+ )
94
21
 
95
22
 
96
23
  # Trading API
97
24
 
98
25
 
99
- class ExchangeProduct(Model):
26
+ class ExchangeProduct(AliasedModel):
100
27
  id: str
101
- symbol: str
28
+ symbol: Annotated[str, AfterValidator(validators.validate_all_caps)]
102
29
  tick_size: int
103
30
  collateral_asset: str
104
31
  listing_state: ListingState
32
+ min_price: Decimal
33
+ max_price: Decimal
105
34
 
106
35
  def __str__(self) -> str:
107
36
  return self.id
108
37
 
109
38
 
110
- class ExchangeProductFilter(PaginationFilter):
111
- pass
112
-
113
-
114
- class IntentData(Model):
39
+ class IntentData(AliasedModel):
115
40
  trading_protocol_id: str
116
41
  product_id: str
117
- limit_price: Annotated[Decimal, Field(gt=0)]
42
+ limit_price: Decimal
118
43
  quantity: Annotated[int, Field(gt=0)]
119
- max_trading_fee_rate: Annotated[Decimal, Field(ge=0)]
44
+ max_trading_fee_rate: Annotated[
45
+ Decimal,
46
+ Field(le=Decimal((2**32 - 1) / constants.FEE_RATE_MULTIPLIER)), # uint32
47
+ ]
120
48
  side: OrderSide
121
49
  good_until_time: Timestamp
122
50
  nonce: int
51
+ referral: Annotated[str, AfterValidator(validators.validate_address)]
123
52
 
124
53
 
125
- class Intent(Model):
54
+ class Intent(AliasedModel):
126
55
  hash: str
127
56
  margin_account_id: str
128
57
  intent_account_id: str
@@ -130,7 +59,7 @@ class Intent(Model):
130
59
  data: IntentData
131
60
 
132
61
 
133
- class Order(Model):
62
+ class Order(AliasedModel):
134
63
  id: str
135
64
  type: OrderType
136
65
  timestamp: Timestamp
@@ -139,35 +68,14 @@ class Order(Model):
139
68
  intent: Intent
140
69
 
141
70
 
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):
71
+ class OrderCancellationData(AliasedModel):
158
72
  intent_hash: Annotated[str, AfterValidator(validators.validate_hexstr32)]
159
73
  nonce: int
160
74
  intent_account_id: str
161
75
  signature: str
162
76
 
163
77
 
164
- class OrderSubmission(Model):
165
- type: OrderType
166
- intent: Intent | None = None
167
- cancellation_data: OrderCancellationData | None = None
168
-
169
-
170
- class Trade(Model):
78
+ class Trade(AliasedModel):
171
79
  # Convert ID from int to str for backward compatibility
172
80
  id: Annotated[str, BeforeValidator(str)]
173
81
  product_id: str
@@ -178,7 +86,7 @@ class Trade(Model):
178
86
  rejection_reason: str | None
179
87
 
180
88
 
181
- class OrderFill(Model):
89
+ class OrderFill(AliasedModel):
182
90
  order: Order
183
91
  trade: Trade
184
92
  quantity: int
@@ -186,32 +94,18 @@ class OrderFill(Model):
186
94
  trading_fee_rate: Decimal
187
95
 
188
96
 
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):
97
+ class MarketDepthItem(AliasedModel):
204
98
  price: Decimal
205
99
  quantity: int
206
100
 
207
101
 
208
- class MarketDepthData(Model):
102
+ class MarketDepthData(AliasedModel):
209
103
  product_id: str
210
104
  bids: list[MarketDepthItem]
211
105
  asks: list[MarketDepthItem]
212
106
 
213
107
 
214
- class OHLCVItem(Model):
108
+ class OHLCVItem(AliasedModel):
215
109
  timestamp: Timestamp
216
110
  open: Decimal
217
111
  high: Decimal
@@ -220,7 +114,7 @@ class OHLCVItem(Model):
220
114
  volume: int
221
115
 
222
116
 
223
- # Clearing API
117
+ # Margin Account API
224
118
 
225
119
 
226
120
  class Transaction(Model):
@@ -237,51 +131,207 @@ class Position(Model):
237
131
  pnl: Decimal
238
132
 
239
133
 
240
- # Builder API
134
+ # Product API
241
135
 
242
136
 
243
- class ProductSpecification(Model):
244
- id: str
245
- # Product Metadata
246
- builder_id: str
247
- symbol: str
248
- description: str
249
- # Orace Specification
137
+ class ExpirySpecification(AliasedModel):
138
+ earliest_fsp_submission_time: Annotated[
139
+ ISODateTime, Field(alias="earliestFSPSubmissionTime")
140
+ ]
141
+ tradeout_interval: Annotated[int, Field(ge=0)]
142
+
143
+
144
+ class OracleSpecification(AliasedModel):
250
145
  oracle_address: Annotated[str, AfterValidator(validators.validate_address)]
251
- fsv_decimals: Annotated[int, Field(ge=0, lt=256)] # uint8
146
+ fsv_decimals: Annotated[int, Field(ge=0, le=255)] # uint8
252
147
  fsp_alpha: Decimal
253
148
  fsp_beta: Decimal
254
149
  fsv_calldata: Annotated[str, AfterValidator(validators.validate_hexstr)]
255
- # Product
256
- start_time: Timestamp
257
- earliest_fsp_submission_time: Timestamp
150
+
151
+
152
+ class ProductMetadata(AliasedModel):
153
+ builder: Annotated[str, AfterValidator(validators.validate_address)]
154
+ symbol: Annotated[
155
+ str,
156
+ Field(pattern=r"^[A-Z0-9]{1,16}$", min_length=1, max_length=16),
157
+ ]
158
+ description: str
159
+
160
+
161
+ class BaseProduct(AliasedModel):
162
+ metadata: ProductMetadata
163
+ oracle_spec: OracleSpecification
258
164
  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
165
+ start_time: ISODateTime
166
+ point_value: Decimal
167
+ price_decimals: Annotated[int, Field(ge=0, le=255)] # uint8
168
+ extended_metadata: CID | None = None
267
169
 
268
- def __str__(self) -> str:
269
- return self.id
270
170
 
171
+ class PredictionProductV1(AliasedModel):
172
+ base: BaseProduct
173
+ expiry_spec: ExpirySpecification
174
+ min_price: Decimal
175
+ max_price: Decimal
271
176
 
272
- # Liquidation API
177
+ @model_validator(mode="after")
178
+ def _cross_validate(self) -> Self:
179
+ validators.validate_price_limits(self.min_price, self.max_price)
180
+ validators.validate_time_limits(
181
+ self.base.start_time, self.expiry_spec.earliest_fsp_submission_time
182
+ )
183
+ return self
273
184
 
274
185
 
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
186
+ # Extended metadata schemas
187
+
188
+
189
+ class ApiSpec(Model):
190
+ standard: Literal["JSONPath", "GraphQL"]
191
+ spec_variant: Literal["underlying-history", "product-fsv"] | None = None
192
+
193
+
194
+ class ApiSpecJSONPath(ApiSpec):
195
+ standard: Literal["JSONPath"] = "JSONPath" # type: ignore
196
+ url: URL
197
+ date_path: str
198
+ value_path: str
199
+ auth_param_location: Literal["query", "header", "none"] = "none"
200
+ auth_param_name: str | None = None
201
+ auth_param_prefix: str | None = None
202
+ continuation_token_param: str | None = None
203
+ continuation_token_path: str | None = None
204
+ date_format_custom: str | None = None
205
+ date_format_type: Literal["iso_8601", "unix_timestamp", "custom"] = "iso_8601"
206
+ headers: dict[str, str] | None = None
207
+ max_pages: Annotated[int | None, Field(ge=1)] = 10
208
+ timestamp_scale: Annotated[int | float, Field(ge=1)] = 1
209
+ timezone: Annotated[
210
+ str,
211
+ Field(pattern=r"^[A-Za-z][A-Za-z0-9_+-]*(/[A-Za-z][A-Za-z0-9_+-]*)*$"),
212
+ ] = "UTC"
213
+
214
+
215
+ class BaseCaseResolution(Model):
216
+ condition: Annotated[str, Field(min_length=1)]
217
+ fsp_resolution: Annotated[str, Field(min_length=1)]
218
+
219
+
220
+ class EdgeCase(Model):
221
+ condition: Annotated[str, Field(min_length=1)]
222
+ fsp_resolution: Annotated[str, Field(min_length=1)]
223
+
224
+
225
+ class OutcomeSpace(PinnedModel):
226
+ SCHEMA_CID: ClassVar[CID] = schema_cids.OUTCOME_SPACE_V020
227
+
228
+ fsp_type: Literal["scalar", "binary", "ternary"]
229
+ description: Annotated[str, Field(min_length=1)]
230
+ base_case: BaseCaseResolution
231
+ edge_cases: Annotated[list[EdgeCase], Field(default_factory=list)]
232
+
233
+
234
+ class OutcomeSpaceScalar(OutcomeSpace):
235
+ SCHEMA_CID: ClassVar[CID] = schema_cids.OUTCOME_SPACE_SCALAR_V020
236
+
237
+ fsp_type: Literal["scalar"] = "scalar" # type: ignore
238
+ units: Annotated[str, Field(min_length=1)]
239
+ source_name: Annotated[str, Field(min_length=1)]
240
+ source_uri: URL
241
+
242
+
243
+ class OutcomeSpaceTimeSeries(OutcomeSpaceScalar):
244
+ SCHEMA_CID: ClassVar[CID] = schema_cids.OUTCOME_SPACE_TIME_SERIES_V020
245
+
246
+ frequency: Literal[
247
+ "daily",
248
+ "weekly",
249
+ "fortnightly",
250
+ "semimonthly",
251
+ "monthly",
252
+ "quarterly",
253
+ "yearly",
254
+ ]
255
+ history_api_spec: ApiSpecJSONPath | ApiSpec | None = None
256
+
257
+
258
+ class TemporalObservation(Model):
259
+ reference_date: ISODate
260
+ release_date: ISODate
261
+
262
+
263
+ class OutcomePoint(PinnedModel):
264
+ SCHEMA_CID: ClassVar[CID] = schema_cids.OUTCOME_POINT_V020
265
+
266
+ fsp_type: Literal["scalar", "binary", "ternary"]
267
+
268
+
269
+ class OutcomePointTimeSeries(OutcomePoint):
270
+ SCHEMA_CID: ClassVar[CID] = schema_cids.OUTCOME_POINT_TIME_SERIES_V020
271
+
272
+ fsp_type: Literal["scalar"] = "scalar" # type: ignore
273
+ observation: TemporalObservation
274
+
275
+
276
+ class OutcomePointEvent(OutcomePoint):
277
+ SCHEMA_CID: ClassVar[CID] = schema_cids.OUTCOME_POINT_EVENT_V020
278
+
279
+ fsp_type: Literal["binary", "ternary"] # type: ignore
280
+ outcome: Annotated[str, Field(min_length=1)]
281
+
282
+
283
+ class OracleConfig(PinnedModel):
284
+ SCHEMA_CID: ClassVar[CID] = schema_cids.ORACLE_CONFIG_V020
285
+
286
+ description: Annotated[str, Field(min_length=1)]
287
+ project_url: URL | None = None
288
+
289
+
290
+ class OracleConfigPrototype1(OracleConfig):
291
+ SCHEMA_CID: ClassVar[CID] = schema_cids.ORACLE_CONFIG_PROTOTYPE1_V020
292
+
293
+ evaluation_api_spec: ApiSpecJSONPath | ApiSpec
294
+
295
+
296
+ class OracleFallback(PinnedModel):
297
+ SCHEMA_CID: ClassVar[CID] = schema_cids.ORACLE_FALLBACK_V020
298
+
299
+ fallback_time: ISODateTime
300
+ fallback_fsp: Decimal
301
+
280
302
 
303
+ class PredictionProduct(Model):
304
+ product: PredictionProductV1
305
+ outcome_space: OutcomeSpaceTimeSeries | OutcomeSpaceScalar | OutcomeSpace
306
+ outcome_point: OutcomePointEvent | OutcomePointTimeSeries | OutcomePoint
307
+ oracle_config: OracleConfigPrototype1 | OracleConfig
308
+ oracle_fallback: OracleFallback
281
309
 
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
310
+ @model_validator(mode="after")
311
+ def _cross_validate(self) -> Self:
312
+ validators.validate_matching_fsp_types(
313
+ self.outcome_space.fsp_type, self.outcome_point.fsp_type
314
+ )
315
+ validators.validate_oracle_fallback_time(
316
+ self.oracle_fallback.fallback_time,
317
+ self.product.expiry_spec.earliest_fsp_submission_time,
318
+ )
319
+ validators.validate_oracle_fallback_fsp(
320
+ self.oracle_fallback.fallback_fsp,
321
+ self.product.min_price,
322
+ self.product.max_price,
323
+ )
324
+ validators.validate_outcome_space_conditions(
325
+ self.outcome_space.base_case.condition,
326
+ [case.condition for case in self.outcome_space.edge_cases],
327
+ self.outcome_point.model_dump(),
328
+ )
329
+ if isinstance(self.outcome_space, OutcomeSpaceTimeSeries) and isinstance(
330
+ self.outcome_point, OutcomePointTimeSeries
331
+ ):
332
+ validators.validate_symbol(
333
+ self.product.base.metadata.symbol,
334
+ self.outcome_space.frequency,
335
+ self.outcome_point.observation.release_date,
336
+ )
337
+ 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