"""Versioned public API models and opaque cursor contract.""" from __future__ import annotations import base64,json,re from typing import Any,Literal from pydantic import BaseModel,ConfigDict,Field REVISION=re.compile(r"^[0-9a-f]{40}$") class StrictModel(BaseModel): model_config=ConfigDict(extra="forbid") class ErrorBody(StrictModel): code:str;message:str;details:list[dict[str,Any]]=Field(default_factory=list);dataset_revision:str|None=None class ErrorResponse(StrictModel): error:ErrorBody class PageMeta(StrictModel): limit:int=Field(ge=1,le=100);next_cursor:str|None=None;dataset_revision:str;returned:int=Field(ge=0,le=100) class CompanySummary(StrictModel): issuer_id:str;exchange:Literal["ASX","NZX"];ticker:str;name:str|None=None;sector:str|None=None;industry:str|None=None;as_of_date:str;extraction_coverage_status:Literal["current","backfill_in_progress","pending","unknown"]="unknown";extraction_as_of_date:str|None=None;needs_review:bool;basic_shares:int|None=None;fd_ratio:float|None=None;worst_case_dilution_pct:float|None=None;expiry_pct_lt_3m:float|None=None;expiry_pct_lt_6m:float|None=None;expiry_pct_lt_12m:float|None=None;cash_runway_months:float|None=None class DilutionInstrument(StrictModel): instrument_id:str;ticker:str;instrument_type:str;quantity:int|None=None;series_name:str|None=None;asx_code:str|None=None;exercise_price_aud:str|None=None;expiry:str|None=None;holder_type:str|None=None;vesting_condition:str|None=None;status:str;as_of_date:str;document_ids:list[str]=Field(default_factory=list) class CompanySnapshot(StrictModel): dataset_revision:str;published_at:str|None=None;source_watermark:dict[str,Any]|None=None;dataset_citation:str;found:bool;company:CompanySummary|None=None;instruments:list[DilutionInstrument]=Field(default_factory=list);returned:int=Field(ge=0,le=100) class DocumentSummary(StrictModel): document_id:str;exchange:Literal["ASX","NZX"];ticker:str;source_document_id:str;announcement_date:str;announced_at:str|None=None;title:str;doc_type:str|None=None;doc_type_source:str|None=None;doc_type_confidence:float|None=None;raw_artifact_key:str|None=None;raw_size_bytes:int|None=None;markdown_artifact_key:str|None=None;markdown_size_bytes:int|None=None;publication_state:str;source_link:str|None=None class CompanyPage(StrictModel): items:list[CompanySummary];page:PageMeta class DocumentPage(StrictModel): items:list[DocumentSummary];page:PageMeta class SearchHit(StrictModel): document_id:str;ticker:str;doc_type:str|None=None;announcement_date:str;title:str;source_link:str;snippet:str=Field(max_length=600);score:float class SearchPage(StrictModel): items:list[SearchHit];page:PageMeta;query:str class EventItem(StrictModel): event_id:str;instrument_id:str|None=None;instrument_type:str|None=None;event_type:str|None=None;quantity_delta:int|None=None;effective_date:str|None=None;document_id:str|None=None;doc_type:str|None=None;status:str|None=None class EventPageMeta(StrictModel): limit:int=Field(ge=1,le=500);next_cursor:str|None=None;dataset_revision:str;returned:int=Field(ge=0,le=500) class EventPage(StrictModel): items:list[EventItem];page:EventPageMeta class DatasetStatus(StrictModel): service:str;ready:bool;status:str;build_sha:str;dataset_revision:str;schema_version:int;cache_artifacts:int;cache_bytes:int;documents_total:int|None=None;documents_last_24h:int|None=None;companies_total:int|None=None class CursorError(ValueError): def __init__(self,code,message):super().__init__(message);self.code=code def encode_cursor(revision:str,kind:str,values:list[Any])->str: if not REVISION.fullmatch(revision):raise ValueError("invalid revision") raw=json.dumps({"v":1,"revision":revision,"kind":kind,"values":values},sort_keys=True,separators=(",", ":")).encode() return base64.urlsafe_b64encode(raw).decode().rstrip("=") def decode_cursor(value:str,revision:str,kind:str)->list[Any]: if not value or len(value)>1024:raise CursorError("invalid_cursor","Cursor is empty or too long") try: raw=base64.b64decode(value+"="*(-len(value)%4),altchars=b"-_",validate=True);payload=json.loads(raw) except Exception as error:raise CursorError("invalid_cursor","Cursor is not valid base64url JSON") from error if set(payload)!={"v","revision","kind","values"} or payload["v"]!=1 or not isinstance(payload["values"],list):raise CursorError("invalid_cursor","Cursor shape is invalid") if payload["kind"]!=kind:raise CursorError("invalid_cursor","Cursor belongs to another endpoint") if payload["revision"]!=revision:raise CursorError("stale_cursor","Cursor Dataset revision is no longer active") return payload["values"]