afp-sdk 0.5.3__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 +19 -22
  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 +14 -0
  27. afp/exchange.py +13 -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 +228 -176
  40. afp/types.py +169 -0
  41. afp/validators.py +218 -8
  42. {afp_sdk-0.5.3.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.3.dist-info/RECORD +0 -37
  48. {afp_sdk-0.5.3.dist-info → afp_sdk-0.6.0.dist-info}/WHEEL +0 -0
  49. {afp_sdk-0.5.3.dist-info → afp_sdk-0.6.0.dist-info}/licenses/LICENSE +0 -0
afp/schemas.py CHANGED
@@ -1,127 +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
- trading_fee_rate: Decimal
82
-
83
-
84
- # Admin API
1
+ """AFP data structures."""
85
2
 
3
+ from decimal import Decimal
4
+ from typing import Annotated, Any, ClassVar, Literal, Self
86
5
 
87
- class ExchangeProductListingSubmission(Model):
88
- id: Annotated[str, AfterValidator(validators.validate_hexstr32)]
89
-
6
+ from pydantic import AfterValidator, BeforeValidator, Field, model_validator
90
7
 
91
- class ExchangeProductUpdateSubmission(Model):
92
- 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
+ )
93
21
 
94
22
 
95
23
  # Trading API
96
24
 
97
25
 
98
- class ExchangeProduct(Model):
26
+ class ExchangeProduct(AliasedModel):
99
27
  id: str
100
- symbol: str
28
+ symbol: Annotated[str, AfterValidator(validators.validate_all_caps)]
101
29
  tick_size: int
102
30
  collateral_asset: str
103
31
  listing_state: ListingState
32
+ min_price: Decimal
33
+ max_price: Decimal
104
34
 
105
35
  def __str__(self) -> str:
106
36
  return self.id
107
37
 
108
38
 
109
- class ExchangeProductFilter(PaginationFilter):
110
- pass
111
-
112
-
113
- class IntentData(Model):
39
+ class IntentData(AliasedModel):
114
40
  trading_protocol_id: str
115
41
  product_id: str
116
- limit_price: Annotated[Decimal, Field(gt=0)]
42
+ limit_price: Decimal
117
43
  quantity: Annotated[int, Field(gt=0)]
118
- 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
+ ]
119
48
  side: OrderSide
120
49
  good_until_time: Timestamp
121
50
  nonce: int
51
+ referral: Annotated[str, AfterValidator(validators.validate_address)]
122
52
 
123
53
 
124
- class Intent(Model):
54
+ class Intent(AliasedModel):
125
55
  hash: str
126
56
  margin_account_id: str
127
57
  intent_account_id: str
@@ -129,7 +59,7 @@ class Intent(Model):
129
59
  data: IntentData
130
60
 
131
61
 
132
- class Order(Model):
62
+ class Order(AliasedModel):
133
63
  id: str
134
64
  type: OrderType
135
65
  timestamp: Timestamp
@@ -138,35 +68,14 @@ class Order(Model):
138
68
  intent: Intent
139
69
 
140
70
 
141
- class OrderFilter(PaginationFilter):
142
- intent_account_id: str
143
- product_id: None | Annotated[str, AfterValidator(validators.validate_hexstr32)]
144
- type: None | OrderType
145
- states: Annotated[list[OrderState], Field(exclude=True)]
146
- side: None | OrderSide
147
- start: None | Timestamp
148
- end: None | Timestamp
149
-
150
- @computed_field
151
- @property
152
- def state(self) -> str | None:
153
- return ",".join(self.states) if self.states else None
154
-
155
-
156
- class OrderCancellationData(Model):
71
+ class OrderCancellationData(AliasedModel):
157
72
  intent_hash: Annotated[str, AfterValidator(validators.validate_hexstr32)]
158
73
  nonce: int
159
74
  intent_account_id: str
160
75
  signature: str
161
76
 
162
77
 
163
- class OrderSubmission(Model):
164
- type: OrderType
165
- intent: Intent | None = None
166
- cancellation_data: OrderCancellationData | None = None
167
-
168
-
169
- class Trade(Model):
78
+ class Trade(AliasedModel):
170
79
  # Convert ID from int to str for backward compatibility
171
80
  id: Annotated[str, BeforeValidator(str)]
172
81
  product_id: str
@@ -177,39 +86,26 @@ class Trade(Model):
177
86
  rejection_reason: str | None
178
87
 
179
88
 
180
- class OrderFill(Model):
89
+ class OrderFill(AliasedModel):
181
90
  order: Order
182
91
  trade: Trade
183
92
  quantity: int
184
93
  price: Decimal
94
+ trading_fee_rate: Decimal
185
95
 
186
96
 
187
- class OrderFillFilter(PaginationFilter):
188
- intent_account_id: str
189
- product_id: None | Annotated[str, AfterValidator(validators.validate_hexstr32)]
190
- intent_hash: None | Annotated[str, AfterValidator(validators.validate_hexstr32)]
191
- start: None | Timestamp
192
- end: None | Timestamp
193
- trade_states: Annotated[list[TradeState], Field(exclude=True)]
194
-
195
- @computed_field
196
- @property
197
- def trade_state(self) -> str | None:
198
- return ",".join(self.trade_states) if self.trade_states else None
199
-
200
-
201
- class MarketDepthItem(Model):
97
+ class MarketDepthItem(AliasedModel):
202
98
  price: Decimal
203
99
  quantity: int
204
100
 
205
101
 
206
- class MarketDepthData(Model):
102
+ class MarketDepthData(AliasedModel):
207
103
  product_id: str
208
104
  bids: list[MarketDepthItem]
209
105
  asks: list[MarketDepthItem]
210
106
 
211
107
 
212
- class OHLCVItem(Model):
108
+ class OHLCVItem(AliasedModel):
213
109
  timestamp: Timestamp
214
110
  open: Decimal
215
111
  high: Decimal
@@ -218,7 +114,7 @@ class OHLCVItem(Model):
218
114
  volume: int
219
115
 
220
116
 
221
- # Clearing API
117
+ # Margin Account API
222
118
 
223
119
 
224
120
  class Transaction(Model):
@@ -235,51 +131,207 @@ class Position(Model):
235
131
  pnl: Decimal
236
132
 
237
133
 
238
- # Builder API
134
+ # Product API
239
135
 
240
136
 
241
- class ProductSpecification(Model):
242
- id: str
243
- # Product Metadata
244
- builder_id: str
245
- symbol: str
246
- description: str
247
- # 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):
248
145
  oracle_address: Annotated[str, AfterValidator(validators.validate_address)]
249
- fsv_decimals: Annotated[int, Field(ge=0, lt=256)] # uint8
146
+ fsv_decimals: Annotated[int, Field(ge=0, le=255)] # uint8
250
147
  fsp_alpha: Decimal
251
148
  fsp_beta: Decimal
252
149
  fsv_calldata: Annotated[str, AfterValidator(validators.validate_hexstr)]
253
- # Product
254
- start_time: Timestamp
255
- 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
256
164
  collateral_asset: Annotated[str, AfterValidator(validators.validate_address)]
257
- price_quotation: str
258
- tick_size: Annotated[int, Field(ge=0)]
259
- unit_value: Annotated[Decimal, Field(gt=0)]
260
- initial_margin_requirement: Annotated[Decimal, Field(gt=0)]
261
- maintenance_margin_requirement: Annotated[Decimal, Field(gt=0)]
262
- auction_bounty: Annotated[Decimal, Field(ge=0, le=1)]
263
- tradeout_interval: Annotated[int, Field(ge=0)]
264
- 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
265
169
 
266
- def __str__(self) -> str:
267
- return self.id
268
170
 
171
+ class PredictionProductV1(AliasedModel):
172
+ base: BaseProduct
173
+ expiry_spec: ExpirySpecification
174
+ min_price: Decimal
175
+ max_price: Decimal
269
176
 
270
- # 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
271
184
 
272
185
 
273
- class Bid(Model):
274
- product_id: Annotated[str, AfterValidator(validators.validate_hexstr32)]
275
- price: Annotated[Decimal, Field(gt=0)]
276
- quantity: Annotated[int, Field(gt=0)]
277
- 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
+
278
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
279
309
 
280
- class AuctionData(Model):
281
- start_block: int
282
- margin_account_equity_at_initiation: Decimal
283
- maintenance_margin_used_at_initiation: Decimal
284
- margin_account_equity_now: Decimal
285
- 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