Summary
Model.from_resource() constructs models by passing the raw JSON dict straight
into the dataclass constructor — it never casts values to the declared field
types. Fields declared as schema types (e.g. created_at: schema.DateTime,
id: schema.CampusID) therefore come back as plain builtins (str) after
parsing an API response, violating the model's own type contract.
Current behaviour
# campus/model/base.py
@classmethod
def from_resource(cls, resource):
return cls(**{
field.name: resource[field.name]
for field in cls.fields().values()
if field.metadata.get("resource", True)
})
json.loads produces plain str; nothing wraps it, so:
c = Client.from_resource({"id": ..., "created_at": "2026-09-29T10:00:00+00:00", ...})
type(c.created_at) # <class 'str'>, not schema.DateTime
isinstance(c.created_at, schema.DateTime) # False
c.created_at.to_datetime() # AttributeError — emulated method unavailable
This matters because DateTime is deliberately a str subclass with emulated
datetime helpers (to_datetime(), utcnow(), ...) — consumers of parsed models
currently get none of them and cannot distinguish a parsed model from a plain dict.
Observed impact
campus-cli assumed created_at was a datetime and called .isoformat() on it —
every live client new / client get crashed with
'str' object has no attribute 'isoformat' (fixed defensively on the CLI side in
campus-cli PR #12). The declared-but-unenforced field type is what made that bug
easy to write and hard to spot.
Proposed fix
In from_resource() (or a field-level coercion hook), cast values to their
declared schema types when the declared type is a schema class — e.g.
cls(value) for DateTime/CampusID string subclasses — leaving genuinely
primitive fields untouched. Alternatively, if coercion is out of scope, document
prominently that from_resource models carry plain primitives so the annotations
aren't read as runtime guarantees.
Acceptance criteria
Summary
Model.from_resource()constructs models by passing the raw JSON dict straightinto the dataclass constructor — it never casts values to the declared field
types. Fields declared as schema types (e.g.
created_at: schema.DateTime,id: schema.CampusID) therefore come back as plain builtins (str) afterparsing an API response, violating the model's own type contract.
Current behaviour
json.loadsproduces plainstr; nothing wraps it, so:This matters because
DateTimeis deliberately astrsubclass with emulateddatetime helpers (
to_datetime(),utcnow(), ...) — consumers of parsed modelscurrently get none of them and cannot distinguish a parsed model from a plain dict.
Observed impact
campus-cli assumed
created_atwas adatetimeand called.isoformat()on it —every live
client new/client getcrashed with'str' object has no attribute 'isoformat'(fixed defensively on the CLI side incampus-cli PR #12). The declared-but-unenforced field type is what made that bug
easy to write and hard to spot.
Proposed fix
In
from_resource()(or a field-level coercion hook), cast values to theirdeclared schema types when the declared type is a schema class — e.g.
cls(value)forDateTime/CampusIDstring subclasses — leaving genuinelyprimitive fields untouched. Alternatively, if coercion is out of scope, document
prominently that
from_resourcemodels carry plain primitives so the annotationsaren't read as runtime guarantees.
Acceptance criteria
Client.from_resource(...).created_atis an instance ofschema.DateTimeand supports the emulated helpers (
to_datetime())CampusID)