diff --git a/.gitignore b/.gitignore index bedf293..d0e3b5b 100644 --- a/.gitignore +++ b/.gitignore @@ -84,3 +84,7 @@ site/ *.tmp tmp/ temp/ + +# Node.js (for schema bundling) +node_modules/ +package-lock.json diff --git a/README.md b/README.md index 8c86eb9..475fe6a 100644 --- a/README.md +++ b/README.md @@ -250,6 +250,68 @@ pip install pre-commit pre-commit install ``` +### Schema Synchronization + +This project includes tooling to detect drift between your Pydantic models and the official [Beacon v2 JSON schemas](https://github.com/ga4gh-beacon/beacon-v2). + +#### Prerequisites + +```bash +npm install @apidevtools/json-schema-ref-parser +``` + +#### Sync and Compare + +Run the sync script to download schemas and compare against your models: + +```bash +# Download latest release and compare +./scripts/sync_beacon_schemas.sh + +# Use a specific version +./scripts/sync_beacon_schemas.sh --version v2.1.0 + +# Clean cached schemas and re-download +./scripts/sync_beacon_schemas.sh --clean +``` + +Or run just the comparison (if schemas are already downloaded): + +```bash +uv run python scripts/compare_models.py +``` + +#### What It Does + +1. **Downloads** Beacon v2 release artifacts from GitHub +2. **Bundles** JSON schemas (resolves all `$ref` references) +3. **Compares** schema fields against `src/beacon_api/models/` +4. **Reports** missing fields, extra fields, and coverage + +#### Output + +The comparison script reports: +- **Missing fields** - Fields in the schema but not in your model +- **Extra fields** - Custom fields you've added (not in schema) +- **Field counts** - Coverage summary per model + +Downloaded schemas are cached in `tmp/` (gitignored): + +``` +tmp/ +├── beacon-v2-schemas/ # Downloaded release artifacts +└── bundled_schemas/ # Resolved JSON schemas +``` + +#### Workflow for Updating Models + +1. Run `./scripts/sync_beacon_schemas.sh` +2. Review the comparison report for missing fields +3. Add missing fields to `src/beacon_api/models/` as needed +4. Re-run to verify coverage + +This approach keeps you in control while ensuring your models stay aligned with the upstream specification. + ## API Endpoints ### Info diff --git a/pyproject.toml b/pyproject.toml index 11ddcd7..32dd05c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ dev = [ "httpx>=0.27.0", "ruff>=0.7.0", "mypy>=1.13.0", + "datamodel-code-generator>=0.53.0", ] [build-system] diff --git a/scripts/bundle_schemas.js b/scripts/bundle_schemas.js new file mode 100644 index 0000000..e7798ed --- /dev/null +++ b/scripts/bundle_schemas.js @@ -0,0 +1,171 @@ +#!/usr/bin/env node +/** + * Bundle Beacon v2 JSON schemas for Pydantic model generation. + * + * Strategy: + * - Entity models: Copy pre-dereferenced schemas from beacon-v2/bin/deref_schemas/ + * - Framework schemas: Bundle using json-schema-ref-parser (these resolve correctly) + * + * Usage: + * npm install @apidevtools/json-schema-ref-parser + * node scripts/bundle_schemas.js + */ + +const $RefParser = require('@apidevtools/json-schema-ref-parser'); +const fs = require('fs'); +const path = require('path'); + +// Configuration +// BEACON_V2_ROOT can be overridden via environment variable +const BEACON_V2_ROOT = process.env.BEACON_V2_ROOT + ? path.resolve(process.env.BEACON_V2_ROOT) + : path.resolve(__dirname, '../tmp/beacon-v2-schemas'); +const OUTPUT_DIR = path.resolve(__dirname, '../tmp/bundled_schemas'); + +const FRAMEWORK_DIR = path.join(BEACON_V2_ROOT, 'framework', 'json'); +const DEREF_SCHEMAS_DIR = path.join(BEACON_V2_ROOT, 'bin', 'deref_schemas'); + +// Pre-dereferenced entity schemas (just copy these) +const ENTITY_SCHEMAS = { + 'individual': path.join(DEREF_SCHEMAS_DIR, 'individuals', 'defaultSchema.json'), + 'biosample': path.join(DEREF_SCHEMAS_DIR, 'biosamples', 'defaultSchema.json'), + 'cohort': path.join(DEREF_SCHEMAS_DIR, 'cohorts', 'defaultSchema.json'), + 'dataset': path.join(DEREF_SCHEMAS_DIR, 'datasets', 'defaultSchema.json'), + 'run': path.join(DEREF_SCHEMAS_DIR, 'runs', 'defaultSchema.json'), + 'analysis': path.join(DEREF_SCHEMAS_DIR, 'analyses', 'defaultSchema.json'), + 'genomicVariation': path.join(DEREF_SCHEMAS_DIR, 'genomicVariations', 'defaultSchema.json'), +}; + +// Framework schemas (need bundling) +const FRAMEWORK_SCHEMAS = { + // Common + 'common': path.join(FRAMEWORK_DIR, 'common', 'beaconCommonComponents.json'), + 'ontologyTerm': path.join(FRAMEWORK_DIR, 'common', 'ontologyTerm.json'), + + // Requests + 'requestBody': path.join(FRAMEWORK_DIR, 'requests', 'beaconRequestBody.json'), + 'requestMeta': path.join(FRAMEWORK_DIR, 'requests', 'beaconRequestMeta.json'), + 'filteringTerms': path.join(FRAMEWORK_DIR, 'requests', 'filteringTerms.json'), + + // Responses + 'booleanResponse': path.join(FRAMEWORK_DIR, 'responses', 'beaconBooleanResponse.json'), + 'countResponse': path.join(FRAMEWORK_DIR, 'responses', 'beaconCountResponse.json'), + 'resultsetsResponse': path.join(FRAMEWORK_DIR, 'responses', 'beaconResultsetsResponse.json'), + 'collectionsResponse': path.join(FRAMEWORK_DIR, 'responses', 'beaconCollectionsResponse.json'), + 'infoResponse': path.join(FRAMEWORK_DIR, 'responses', 'beaconInfoResponse.json'), + 'errorResponse': path.join(FRAMEWORK_DIR, 'responses', 'beaconErrorResponse.json'), + 'filteringTermsResponse': path.join(FRAMEWORK_DIR, 'responses', 'beaconFilteringTermsResponse.json'), + 'mapResponse': path.join(FRAMEWORK_DIR, 'responses', 'beaconMapResponse.json'), + + // Response Sections + 'responseMeta': path.join(FRAMEWORK_DIR, 'responses', 'sections', 'beaconResponseMeta.json'), + 'resultsets': path.join(FRAMEWORK_DIR, 'responses', 'sections', 'beaconResultsets.json'), +}; + +/** + * Copy a pre-dereferenced schema file + */ +function copySchema(name, sourcePath) { + console.log(`Copying ${name}...`); + + if (!fs.existsSync(sourcePath)) { + console.warn(` WARNING: Schema file not found: ${sourcePath}`); + return false; + } + + const outputPath = path.join(OUTPUT_DIR, `${name}.json`); + fs.copyFileSync(sourcePath, outputPath); + + const stats = fs.statSync(outputPath); + console.log(` -> ${outputPath} (${(stats.size / 1024).toFixed(1)} KB)`); + return true; +} + +/** + * Bundle a framework schema using json-schema-ref-parser + */ +async function bundleSchema(name, schemaPath) { + console.log(`Bundling ${name}...`); + + if (!fs.existsSync(schemaPath)) { + console.warn(` WARNING: Schema file not found: ${schemaPath}`); + return false; + } + + try { + const schema = await $RefParser.dereference(schemaPath, { + dereference: { + circular: 'ignore' + } + }); + + const outputPath = path.join(OUTPUT_DIR, `${name}.json`); + fs.writeFileSync(outputPath, JSON.stringify(schema, null, 2)); + + const stats = fs.statSync(outputPath); + console.log(` -> ${outputPath} (${(stats.size / 1024).toFixed(1)} KB)`); + return true; + } catch (error) { + console.error(` ERROR bundling ${name}: ${error.message}`); + return false; + } +} + +async function main() { + console.log('Beacon v2 Schema Bundler'); + console.log('========================\n'); + console.log(`Beacon v2 root: ${BEACON_V2_ROOT}`); + console.log(`Output directory: ${OUTPUT_DIR}\n`); + + // Check beacon-v2 exists + if (!fs.existsSync(BEACON_V2_ROOT)) { + console.error(`ERROR: Beacon v2 directory not found: ${BEACON_V2_ROOT}`); + process.exit(1); + } + + // Check deref_schemas exists + if (!fs.existsSync(DEREF_SCHEMAS_DIR)) { + console.error(`ERROR: Pre-dereferenced schemas not found: ${DEREF_SCHEMAS_DIR}`); + console.error('Make sure your beacon-v2 clone includes the bin/deref_schemas directory.'); + process.exit(1); + } + + // Create output directory + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); + + let successful = 0; + let failed = 0; + + // Copy pre-dereferenced entity schemas + console.log('--- Entity Schemas (pre-dereferenced) ---\n'); + for (const [name, schemaPath] of Object.entries(ENTITY_SCHEMAS)) { + if (copySchema(name, schemaPath)) { + successful++; + } else { + failed++; + } + } + + // Bundle framework schemas + console.log('\n--- Framework Schemas (bundling) ---\n'); + for (const [name, schemaPath] of Object.entries(FRAMEWORK_SCHEMAS)) { + if (await bundleSchema(name, schemaPath)) { + successful++; + } else { + failed++; + } + } + + // Summary + console.log('\n========================'); + console.log('Summary:'); + console.log(` Successful: ${successful}`); + console.log(` Failed: ${failed}`); + console.log(`\nBundled schemas written to: ${OUTPUT_DIR}`); + console.log('\nNext step: Run the Python model generator:'); + console.log(' uv run python scripts/generate_from_bundled.py'); + + process.exit(failed > 0 ? 1 : 0); +} + +main().catch(console.error); diff --git a/scripts/compare_models.py b/scripts/compare_models.py new file mode 100644 index 0000000..967241e --- /dev/null +++ b/scripts/compare_models.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +"""Compare hand-written models against Beacon v2 JSON schemas. + +This script identifies drift between your implementation and the upstream schema: +- Missing fields in your models +- Extra fields not in schema (custom additions) +- Type mismatches + +Usage: + # First ensure schemas are downloaded and bundled: + ./scripts/sync_beacon_schemas.sh + + # Then run comparison: + uv run python scripts/compare_models.py +""" + +from __future__ import annotations + +import ast +import json +import logging +import re +from dataclasses import dataclass +from pathlib import Path + +logging.basicConfig(level=logging.INFO, format="%(message)s") +logger = logging.getLogger(__name__) + +# Paths +BUNDLED_DIR = Path(__file__).parent.parent / "tmp" / "bundled_schemas" +MODELS_DIR = Path(__file__).parent.parent / "src" / "beacon_api" / "models" + +# Map schema files to model classes +# Format: "schema.json": ("model_file.py", "ClassName") +SCHEMA_TO_MODEL = { + # Entity models + "individual.json": ("entities.py", "Individual"), + "biosample.json": ("entities.py", "Biosample"), + "cohort.json": ("entities.py", "Cohort"), + "dataset.json": ("entities.py", "Dataset"), + "run.json": ("entities.py", "Run"), + "analysis.json": ("entities.py", "Analysis"), + "genomicVariation.json": ("entities.py", "GenomicVariation"), + # Request models + "requestBody.json": ("request.py", "BeaconRequestBody"), + "filteringTerms.json": ("request.py", "FilteringTerm"), + # Response models + "booleanResponse.json": ("response.py", "BeaconBooleanResponse"), + "countResponse.json": ("response.py", "BeaconCountResponse"), + "resultsetsResponse.json": ("response.py", "BeaconResultsetsResponse"), + "infoResponse.json": ("response.py", "BeaconInfoResponse"), + "errorResponse.json": ("common.py", "BeaconError"), + "responseMeta.json": ("response.py", "BeaconResponseMeta"), +} + + +@dataclass +class FieldInfo: + """Information about a field.""" + + name: str + type_hint: str | None = None + required: bool = False + description: str | None = None + + +@dataclass +class ComparisonResult: + """Result of comparing a model against schema.""" + + model_name: str + schema_file: str + missing_fields: list[FieldInfo] # In schema but not in model + extra_fields: list[str] # In model but not in schema + schema_field_count: int + model_field_count: int + + +def extract_schema_fields(schema_path: Path) -> dict[str, FieldInfo]: + """Extract fields from a JSON schema.""" + with open(schema_path) as f: + schema = json.load(f) + + fields = {} + properties = schema.get("properties", {}) + required = set(schema.get("required", [])) + + for name, prop in properties.items(): + # Get type info + type_hint = prop.get("type") + if not type_hint and "$ref" in prop: + type_hint = f"$ref:{prop['$ref']}" + if not type_hint and "oneOf" in prop: + type_hint = "oneOf[...]" + if not type_hint and "anyOf" in prop: + type_hint = "anyOf[...]" + + fields[name] = FieldInfo( + name=name, + type_hint=type_hint, + required=name in required, + description=prop.get("description"), + ) + + return fields + + +def extract_model_fields(model_path: Path, class_name: str) -> dict[str, str]: + """Extract fields from a Pydantic model using AST parsing.""" + with open(model_path) as f: + source = f.read() + + tree = ast.parse(source) + fields = {} + + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef) and node.name == class_name: + for item in node.body: + # Handle annotated assignments: field: Type = ... + if isinstance(item, ast.AnnAssign) and isinstance( + item.target, ast.Name + ): + field_name = item.target.id + # Get type annotation as string + type_hint = ast.unparse(item.annotation) + fields[field_name] = type_hint + + return fields + + +def normalize_field_name(name: str) -> str: + """Normalize field name for comparison (camelCase to snake_case). + + Handles consecutive capitals correctly: + - APIResponse -> api_response + - HTTPSConnection -> https_connection + - XMLHttpRequest -> xml_http_request + - camelCase -> camel_case + - getUserID -> get_user_id + """ + # First: Insert underscore before uppercase letter followed by lowercase + # (handles: APIResponse -> API_Response, camelCase -> camel_Case) + s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name) + # Second: Insert underscore between lowercase/digit and uppercase + # (handles: getUserID -> get_User_ID after first pass) + s2 = re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1) + return s2.lower() + + +def compare_model( + schema_path: Path, model_path: Path, class_name: str +) -> ComparisonResult: + """Compare a single model against its schema.""" + schema_fields = extract_schema_fields(schema_path) + model_fields = extract_model_fields(model_path, class_name) + + # Normalize schema field names for comparison + schema_normalized = {normalize_field_name(k): v for k, v in schema_fields.items()} + + # Find missing fields (in schema but not in model) + missing = [] + for norm_name, field_info in schema_normalized.items(): + if norm_name not in model_fields: + missing.append(field_info) + + # Find extra fields (in model but not in schema) + extra = [] + schema_norm_names = set(schema_normalized.keys()) + for field_name in model_fields: + if field_name not in schema_norm_names and not field_name.startswith("model_"): + extra.append(field_name) + + return ComparisonResult( + model_name=class_name, + schema_file=schema_path.name, + missing_fields=missing, + extra_fields=extra, + schema_field_count=len(schema_fields), + model_field_count=len(model_fields), + ) + + +def print_report(results: list[ComparisonResult]) -> None: + """Print comparison report.""" + print("\n" + "=" * 60) + print("BEACON MODEL COMPARISON REPORT") + print("=" * 60) + + total_missing = 0 + total_extra = 0 + + for result in results: + print(f"\n{result.model_name}") + print("-" * 40) + print(f"Schema fields: {result.schema_field_count}") + print(f"Model fields: {result.model_field_count}") + + if result.missing_fields: + print(f"\n MISSING ({len(result.missing_fields)} fields not in your model):") + for field in sorted(result.missing_fields, key=lambda f: f.name): + req = " [REQUIRED]" if field.required else "" + print(f" - {field.name}: {field.type_hint}{req}") + total_missing += len(result.missing_fields) + else: + print("\n ✓ No missing fields") + + if result.extra_fields: + print(f"\n EXTRA ({len(result.extra_fields)} custom fields in your model):") + for field in sorted(result.extra_fields): + print(f" + {field}") + total_extra += len(result.extra_fields) + + # Summary + print("\n" + "=" * 60) + print("SUMMARY") + print("=" * 60) + print(f"Total missing fields: {total_missing}") + print(f"Total extra fields: {total_extra}") + + if total_missing > 0: + print("\n⚠ Your models are missing fields from the upstream schema.") + print(" Review the MISSING fields above and add them if needed.") + + +def main() -> int: + """Run the comparison.""" + if not BUNDLED_DIR.exists(): + logger.error(f"Bundled schemas not found: {BUNDLED_DIR}") + logger.error("Run './scripts/sync_beacon_schemas.sh' first.") + return 1 + + results = [] + + for schema_file, (model_file, class_name) in SCHEMA_TO_MODEL.items(): + schema_path = BUNDLED_DIR / schema_file + model_path = MODELS_DIR / model_file + + if not schema_path.exists(): + logger.warning(f"Schema not found: {schema_path}") + continue + + if not model_path.exists(): + logger.warning(f"Model not found: {model_path}") + continue + + result = compare_model(schema_path, model_path, class_name) + results.append(result) + + print_report(results) + return 0 + + +if __name__ == "__main__": + exit(main()) diff --git a/scripts/sync_beacon_schemas.sh b/scripts/sync_beacon_schemas.sh new file mode 100755 index 0000000..660590a --- /dev/null +++ b/scripts/sync_beacon_schemas.sh @@ -0,0 +1,179 @@ +#!/usr/bin/env bash +# +# Sync Beacon v2 schemas and compare against current models for drift detection. +# +# Usage: +# ./scripts/sync_beacon_schemas.sh [--version VERSION] [--clean] +# +# Options: +# --version VERSION Specific release tag (default: latest) +# --clean Remove downloaded schemas and re-download +# +# This script: +# 1. Downloads beacon-v2 release from GitHub +# 2. Bundles schemas (resolves $ref) +# 3. Compares against src/beacon_api/models/ to detect drift +# + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" + +# Directories +TMP_DIR="$PROJECT_ROOT/tmp" +SCHEMAS_DIR="$TMP_DIR/beacon-v2-schemas" +BUNDLED_DIR="$TMP_DIR/bundled_schemas" + +# GitHub release info +GITHUB_REPO="ga4gh-beacon/beacon-v2" +DEFAULT_VERSION="latest" + +# Parse arguments +VERSION="$DEFAULT_VERSION" +CLEAN=false + +while [[ $# -gt 0 ]]; do + case $1 in + --version) + VERSION="$2" + shift 2 + ;; + --clean) + CLEAN=true + shift + ;; + -h|--help) + head -17 "$0" | tail -15 + exit 0 + ;; + *) + echo "Unknown option: $1" + exit 1 + ;; + esac +done + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +log_info() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +log_warn() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Clean if requested +if [[ "$CLEAN" == true ]]; then + log_info "Cleaning tmp directory..." + rm -rf "$TMP_DIR" +fi + +# Create directories +mkdir -p "$TMP_DIR" "$SCHEMAS_DIR" "$BUNDLED_DIR" + +# Get release info +get_release_info() { + local version="$1" + if [[ "$version" == "latest" ]]; then + curl -s "https://api.github.com/repos/$GITHUB_REPO/releases/latest" + else + curl -s "https://api.github.com/repos/$GITHUB_REPO/releases/tags/$version" + fi +} + +# Download and extract release +download_schemas() { + local version="$1" + + log_info "Fetching release info for: $version" + local release_info + release_info=$(get_release_info "$version") + + local tag_name + tag_name=$(echo "$release_info" | jq -r '.tag_name') + + if [[ -z "$tag_name" ]]; then + log_error "Could not find release: $version" + exit 1 + fi + + log_info "Found release: $tag_name" + + # Check if already downloaded + local version_file="$SCHEMAS_DIR/.version" + if [[ -f "$version_file" ]] && [[ "$(cat "$version_file")" == "$tag_name" ]]; then + log_info "Schemas already downloaded for $tag_name" + return 0 + fi + + # Download tarball + local tarball_url="https://github.com/$GITHUB_REPO/archive/refs/tags/$tag_name.tar.gz" + local tarball_path="$TMP_DIR/beacon-v2-$tag_name.tar.gz" + + log_info "Downloading: $tarball_url" + curl -L -o "$tarball_path" "$tarball_url" + + # Extract + log_info "Extracting schemas..." + rm -rf "$SCHEMAS_DIR"/* + tar -xzf "$tarball_path" -C "$SCHEMAS_DIR" --strip-components=1 + + # Save version + echo "$tag_name" > "$version_file" + + # Cleanup tarball + rm -f "$tarball_path" + + log_info "Schemas extracted to: $SCHEMAS_DIR" +} + +# Bundle schemas +bundle_schemas() { + log_info "Bundling schemas..." + + # Update the bundler to use downloaded schemas + export BEACON_V2_ROOT="$SCHEMAS_DIR" + + cd "$PROJECT_ROOT" + node scripts/bundle_schemas.js +} + +# Compare models +compare_models() { + log_info "Comparing models against schema..." + echo "" + + cd "$PROJECT_ROOT" + uv run python scripts/compare_models.py +} + +# Main +main() { + echo "========================================" + echo "Beacon v2 Schema Sync" + echo "========================================" + echo "" + + download_schemas "$VERSION" + echo "" + + bundle_schemas + echo "" + + compare_models + + echo "" + log_info "Done!" +} + +main diff --git a/src/beacon_api/api/__init__.py b/src/beacon_api/api/__init__.py index f0f9e7a..0edf804 100644 --- a/src/beacon_api/api/__init__.py +++ b/src/beacon_api/api/__init__.py @@ -3,15 +3,21 @@ from beacon_api.api.analyses import router as analyses_router from beacon_api.api.biosamples import router as biosamples_router from beacon_api.api.cohorts import router as cohorts_router +from beacon_api.api.configuration import router as configuration_router from beacon_api.api.datasets import router as datasets_router +from beacon_api.api.entry_types import router as entry_types_router from beacon_api.api.genomic_variations import router as g_variations_router from beacon_api.api.individuals import router as individuals_router from beacon_api.api.info import router as info_router +from beacon_api.api.map import router as map_router from beacon_api.api.monitor import router as monitor_router from beacon_api.api.runs import router as runs_router __all__ = [ "info_router", + "configuration_router", + "map_router", + "entry_types_router", "individuals_router", "biosamples_router", "g_variations_router", diff --git a/src/beacon_api/api/analyses.py b/src/beacon_api/api/analyses.py index c7c3b80..90293a0 100644 --- a/src/beacon_api/api/analyses.py +++ b/src/beacon_api/api/analyses.py @@ -11,13 +11,16 @@ PaginationSkip, create_request_body_from_params, ) -from beacon_api.models.request import BeaconRequestBody, RequestedGranularity -from beacon_api.models.response import ( - BeaconResponseMeta, - BeaconResultsetsResponse, - BeaconSummaryResults, - ResultsetInstance, +from beacon_api.api.response_utils import ( + build_meta, + build_received_request_summary, + build_resultset_response, + build_summary, + filters_to_strings, + schema_for_entity, ) +from beacon_api.models.request import BeaconRequestBody, RequestedGranularity +from beacon_api.models.response import BeaconResultsetsResponse router = APIRouter(prefix="/analyses", tags=["analyses"]) @@ -53,70 +56,55 @@ async def list_analyses( ) analyses = await service.query(request_body) - meta = BeaconResponseMeta( - beacon_id="beacon-skeleton", - api_version="v2.0", + requested_schemas = schema_for_entity("analysis") + received_request_summary = build_received_request_summary( + requested_granularity="record", + filters=filters_to_strings(request_body.filters), + pagination={"skip": skip, "limit": limit}, + requested_schemas=requested_schemas, + ) + meta = build_meta( returned_granularity="record", - received_request_summary={ - "requested_granularity": "record", - "filters": ( - [f.model_dump() for f in request_body.filters] - if request_body.filters - else [] - ), - "pagination": {"skip": skip, "limit": limit}, - }, + received_request_summary=received_request_summary, + returned_schemas=requested_schemas, ) - - result_set = ResultsetInstance( - id="analyses", - set_type="analysis", - exists=len(analyses) > 0, - result_count=len(analyses), + response = build_resultset_response( + entity_type="analysis", results=[a.model_dump() for a in analyses], ) - - summary = BeaconSummaryResults( + summary = build_summary( exists=len(analyses) > 0, num_total_results=len(analyses), ) return { - "meta": meta.model_dump(), - "response_summary": summary.model_dump(), - "response": [result_set.model_dump()], - "info": None, - "beacon_error": None, + "meta": meta.model_dump(by_alias=True, exclude_none=True), + "responseSummary": summary.model_dump(by_alias=True, exclude_none=True), + "response": response.model_dump(by_alias=True, exclude_none=True), } except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) from None except NotImplementedError: # Return empty but valid response for unimplemented services - meta = BeaconResponseMeta( - beacon_id="beacon-skeleton", - api_version="v2.0", - returned_granularity="record", - received_request_summary={ - "requested_granularity": "record", - "filters": [], - "pagination": {"skip": skip, "limit": limit}, - }, + requested_schemas = schema_for_entity("analysis") + received_request_summary = build_received_request_summary( + requested_granularity="record", + filters=[], + pagination={"skip": skip, "limit": limit}, + requested_schemas=requested_schemas, ) - result_set = ResultsetInstance( - id="analyses", - set_type="analysis", - exists=False, - result_count=0, - results=[], + meta = build_meta( + returned_granularity="record", + received_request_summary=received_request_summary, + returned_schemas=requested_schemas, ) - summary = BeaconSummaryResults(exists=False, num_total_results=0) + response = build_resultset_response(entity_type="analysis", results=[]) + summary = build_summary(exists=False, num_total_results=0) return { - "meta": meta.model_dump(), - "response_summary": summary.model_dump(), - "response": [result_set.model_dump()], - "info": None, - "beacon_error": None, + "meta": meta.model_dump(by_alias=True, exclude_none=True), + "responseSummary": summary.model_dump(by_alias=True, exclude_none=True), + "response": response.model_dump(by_alias=True, exclude_none=True), } @@ -147,93 +135,78 @@ async def query_analyses( granularity = request_body.meta.requested_granularity # Create response metadata - meta = BeaconResponseMeta( - beacon_id="beacon-skeleton", - api_version="v2.0", + requested_schemas = schema_for_entity("analysis") + received_request_summary = build_received_request_summary( + requested_granularity=granularity.value, + filters=filters_to_strings(request_body.filters), + pagination=request_body.meta.pagination or {"skip": 0, "limit": 0}, + requested_schemas=requested_schemas, + include_resultset_responses=request_body.meta.include_resultset_responses, + ) + meta = build_meta( returned_granularity=granularity.value, - received_request_summary={ - "requested_granularity": granularity.value, - "filters": ( - [f.model_dump() for f in request_body.filters] - if request_body.filters - else [] - ), - }, + received_request_summary=received_request_summary, + returned_schemas=requested_schemas, ) if granularity == RequestedGranularity.BOOLEAN: exists = await service.exists(request_body) - summary = BeaconSummaryResults(exists=exists) + summary = build_summary(exists=exists) return { - "meta": meta.model_dump(), - "response_summary": summary.model_dump(), - "info": None, - "beacon_error": None, + "meta": meta.model_dump(by_alias=True, exclude_none=True), + "responseSummary": summary.model_dump(by_alias=True, exclude_none=True), } elif granularity == RequestedGranularity.COUNT: count = await service.count(request_body) - summary = BeaconSummaryResults(exists=count > 0, num_total_results=count) + summary = build_summary(exists=count > 0, num_total_results=count) return { - "meta": meta.model_dump(), - "response_summary": summary.model_dump(), - "info": None, - "beacon_error": None, + "meta": meta.model_dump(by_alias=True, exclude_none=True), + "responseSummary": summary.model_dump(by_alias=True, exclude_none=True), } else: # RECORD analyses = await service.query(request_body) - result_set = ResultsetInstance( - id="analyses", - set_type="analysis", - exists=len(analyses) > 0, - result_count=len(analyses), + response = build_resultset_response( + entity_type="analysis", results=[a.model_dump() for a in analyses], ) - summary = BeaconSummaryResults( + summary = build_summary( exists=len(analyses) > 0, num_total_results=len(analyses), ) return { - "meta": meta.model_dump(), - "response_summary": summary.model_dump(), - "response": [result_set.model_dump()], + "meta": meta.model_dump(by_alias=True, exclude_none=True), + "responseSummary": summary.model_dump(by_alias=True, exclude_none=True), + "response": response.model_dump(by_alias=True, exclude_none=True), } except NotImplementedError: # Return empty but valid response for unimplemented services (beacon-verifier compliance) - meta = BeaconResponseMeta( - beacon_id="beacon-skeleton", - api_version="v2.0", + requested_schemas = schema_for_entity("analysis") + received_request_summary = build_received_request_summary( + requested_granularity=request_body.meta.requested_granularity.value, + filters=filters_to_strings(request_body.filters), + pagination=request_body.meta.pagination or {"skip": 0, "limit": 0}, + requested_schemas=requested_schemas, + include_resultset_responses=request_body.meta.include_resultset_responses, + ) + meta = build_meta( returned_granularity=request_body.meta.requested_granularity.value, - received_request_summary={ - "requested_granularity": request_body.meta.requested_granularity.value, - "filters": ( - [f.model_dump() for f in request_body.filters] - if request_body.filters - else [] - ), - }, + received_request_summary=received_request_summary, + returned_schemas=requested_schemas, ) - summary = BeaconSummaryResults(exists=False, num_total_results=0) + summary = build_summary(exists=False, num_total_results=0) if request_body.meta.requested_granularity == RequestedGranularity.RECORD: - result_set = ResultsetInstance( - id="analyses", - set_type="analysis", - exists=False, - result_count=0, - results=[], - ) + response = build_resultset_response(entity_type="analysis", results=[]) return { - "meta": meta.model_dump(), - "response_summary": summary.model_dump(), - "response": [result_set.model_dump()], + "meta": meta.model_dump(by_alias=True, exclude_none=True), + "responseSummary": summary.model_dump(by_alias=True, exclude_none=True), + "response": response.model_dump(by_alias=True, exclude_none=True), } else: return { - "meta": meta.model_dump(), - "response_summary": summary.model_dump(), - "info": None, - "beacon_error": None, + "meta": meta.model_dump(by_alias=True, exclude_none=True), + "responseSummary": summary.model_dump(by_alias=True, exclude_none=True), } diff --git a/src/beacon_api/api/biosamples.py b/src/beacon_api/api/biosamples.py index b7f4f7b..a4a4610 100644 --- a/src/beacon_api/api/biosamples.py +++ b/src/beacon_api/api/biosamples.py @@ -11,16 +11,19 @@ PaginationSkip, create_request_body_from_params, ) +from beacon_api.api.response_utils import ( + build_meta, + build_received_request_summary, + build_resultset_response, + build_summary, + filters_to_strings, + schema_for_entity, +) from beacon_api.models.request import ( BeaconRequestBody, RequestedGranularity, ) -from beacon_api.models.response import ( - BeaconResponseMeta, - BeaconResultsetsResponse, - BeaconSummaryResults, - ResultsetInstance, -) +from beacon_api.models.response import BeaconResultsetsResponse router = APIRouter(prefix="/biosamples", tags=["biosamples"]) @@ -60,70 +63,55 @@ async def list_biosamples( ) biosamples = await service.query(request_body) - meta = BeaconResponseMeta( - beacon_id="beacon-skeleton", - api_version="v2.0", + requested_schemas = schema_for_entity("biosample") + received_request_summary = build_received_request_summary( + requested_granularity="record", + filters=filters_to_strings(request_body.filters), + pagination={"skip": skip, "limit": limit}, + requested_schemas=requested_schemas, + ) + meta = build_meta( returned_granularity="record", - received_request_summary={ - "requested_granularity": "record", - "filters": ( - [f.model_dump() for f in request_body.filters] - if request_body.filters - else [] - ), - "pagination": {"skip": skip, "limit": limit}, - }, + received_request_summary=received_request_summary, + returned_schemas=requested_schemas, ) - - result_set = ResultsetInstance( - id="biosamples", - set_type="biosample", - exists=len(biosamples) > 0, - result_count=len(biosamples), + response = build_resultset_response( + entity_type="biosample", results=[bs.model_dump() for bs in biosamples], ) - - summary = BeaconSummaryResults( + summary = build_summary( exists=len(biosamples) > 0, num_total_results=len(biosamples), ) return { - "meta": meta.model_dump(), - "response_summary": summary.model_dump(), - "response": [result_set.model_dump()], - "info": None, - "beacon_error": None, + "meta": meta.model_dump(by_alias=True, exclude_none=True), + "responseSummary": summary.model_dump(by_alias=True, exclude_none=True), + "response": response.model_dump(by_alias=True, exclude_none=True), } except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) from None except NotImplementedError: # Return empty but valid response for unimplemented services - meta = BeaconResponseMeta( - beacon_id="beacon-skeleton", - api_version="v2.0", - returned_granularity="record", - received_request_summary={ - "requested_granularity": "record", - "filters": [], - "pagination": {"skip": skip, "limit": limit}, - }, + requested_schemas = schema_for_entity("biosample") + received_request_summary = build_received_request_summary( + requested_granularity="record", + filters=[], + pagination={"skip": skip, "limit": limit}, + requested_schemas=requested_schemas, ) - result_set = ResultsetInstance( - id="biosamples", - set_type="biosample", - exists=False, - result_count=0, - results=[], + meta = build_meta( + returned_granularity="record", + received_request_summary=received_request_summary, + returned_schemas=requested_schemas, ) - summary = BeaconSummaryResults(exists=False, num_total_results=0) + response = build_resultset_response(entity_type="biosample", results=[]) + summary = build_summary(exists=False, num_total_results=0) return { - "meta": meta.model_dump(), - "response_summary": summary.model_dump(), - "response": [result_set.model_dump()], - "info": None, - "beacon_error": None, + "meta": meta.model_dump(by_alias=True, exclude_none=True), + "responseSummary": summary.model_dump(by_alias=True, exclude_none=True), + "response": response.model_dump(by_alias=True, exclude_none=True), } @@ -150,33 +138,29 @@ async def get_biosample( if biosample is None: raise HTTPException(status_code=404, detail="Biosample not found") - meta = BeaconResponseMeta( - beacon_id="beacon-skeleton", - api_version="v2.0", + requested_schemas = schema_for_entity("biosample") + received_request_summary = build_received_request_summary( + requested_granularity="record", + filters=[], + pagination={"skip": 0, "limit": 1}, + requested_schemas=requested_schemas, + ) + meta = build_meta( returned_granularity="record", - received_request_summary={ - "requested_granularity": "record", - "filters": [], - "requested_id": biosample_id, - }, + received_request_summary=received_request_summary, + returned_schemas=requested_schemas, ) - result_set = ResultsetInstance( - id="biosamples", - set_type="biosample", - exists=True, - result_count=1, + response = build_resultset_response( + entity_type="biosample", results=[biosample.model_dump()], ) - - summary = BeaconSummaryResults(exists=True, num_total_results=1) + summary = build_summary(exists=True, num_total_results=1) return { - "meta": meta.model_dump(), - "response_summary": summary.model_dump(), - "response": [result_set.model_dump()], - "info": None, - "beacon_error": None, + "meta": meta.model_dump(by_alias=True, exclude_none=True), + "responseSummary": summary.model_dump(by_alias=True, exclude_none=True), + "response": response.model_dump(by_alias=True, exclude_none=True), } except NotImplementedError: @@ -210,93 +194,78 @@ async def query_biosamples( try: granularity = request_body.meta.requested_granularity - meta = BeaconResponseMeta( - beacon_id="beacon-skeleton", - api_version="v2.0", + requested_schemas = schema_for_entity("biosample") + received_request_summary = build_received_request_summary( + requested_granularity=granularity.value, + filters=filters_to_strings(request_body.filters), + pagination=request_body.meta.pagination or {"skip": 0, "limit": 0}, + requested_schemas=requested_schemas, + include_resultset_responses=request_body.meta.include_resultset_responses, + ) + meta = build_meta( returned_granularity=granularity.value, - received_request_summary={ - "requested_granularity": granularity.value, - "filters": ( - [f.model_dump() for f in request_body.filters] - if request_body.filters - else [] - ), - }, + received_request_summary=received_request_summary, + returned_schemas=requested_schemas, ) if granularity == RequestedGranularity.BOOLEAN: exists = await service.exists(request_body) - summary = BeaconSummaryResults(exists=exists) + summary = build_summary(exists=exists) return { - "meta": meta.model_dump(), - "response_summary": summary.model_dump(), - "info": None, - "beacon_error": None, + "meta": meta.model_dump(by_alias=True, exclude_none=True), + "responseSummary": summary.model_dump(by_alias=True, exclude_none=True), } elif granularity == RequestedGranularity.COUNT: count = await service.count(request_body) - summary = BeaconSummaryResults(exists=count > 0, num_total_results=count) + summary = build_summary(exists=count > 0, num_total_results=count) return { - "meta": meta.model_dump(), - "response_summary": summary.model_dump(), - "info": None, - "beacon_error": None, + "meta": meta.model_dump(by_alias=True, exclude_none=True), + "responseSummary": summary.model_dump(by_alias=True, exclude_none=True), } else: # RECORD biosamples = await service.query(request_body) - result_set = ResultsetInstance( - id="biosamples", - set_type="biosample", - exists=len(biosamples) > 0, - result_count=len(biosamples), + response = build_resultset_response( + entity_type="biosample", results=[bs.model_dump() for bs in biosamples], ) - summary = BeaconSummaryResults( + summary = build_summary( exists=len(biosamples) > 0, num_total_results=len(biosamples), ) return { - "meta": meta.model_dump(), - "response_summary": summary.model_dump(), - "response": [result_set.model_dump()], + "meta": meta.model_dump(by_alias=True, exclude_none=True), + "responseSummary": summary.model_dump(by_alias=True, exclude_none=True), + "response": response.model_dump(by_alias=True, exclude_none=True), } except NotImplementedError: # Return empty but valid response for unimplemented services (beacon-verifier compliance) - meta = BeaconResponseMeta( - beacon_id="beacon-skeleton", - api_version="v2.0", + requested_schemas = schema_for_entity("biosample") + received_request_summary = build_received_request_summary( + requested_granularity=request_body.meta.requested_granularity.value, + filters=filters_to_strings(request_body.filters), + pagination=request_body.meta.pagination or {"skip": 0, "limit": 0}, + requested_schemas=requested_schemas, + include_resultset_responses=request_body.meta.include_resultset_responses, + ) + meta = build_meta( returned_granularity=request_body.meta.requested_granularity.value, - received_request_summary={ - "requested_granularity": request_body.meta.requested_granularity.value, - "filters": ( - [f.model_dump() for f in request_body.filters] - if request_body.filters - else [] - ), - }, + received_request_summary=received_request_summary, + returned_schemas=requested_schemas, ) - summary = BeaconSummaryResults(exists=False, num_total_results=0) + summary = build_summary(exists=False, num_total_results=0) if request_body.meta.requested_granularity == RequestedGranularity.RECORD: - result_set = ResultsetInstance( - id="biosamples", - set_type="biosample", - exists=False, - result_count=0, - results=[], - ) + response = build_resultset_response(entity_type="biosample", results=[]) return { - "meta": meta.model_dump(), - "response_summary": summary.model_dump(), - "response": [result_set.model_dump()], + "meta": meta.model_dump(by_alias=True, exclude_none=True), + "responseSummary": summary.model_dump(by_alias=True, exclude_none=True), + "response": response.model_dump(by_alias=True, exclude_none=True), } else: return { - "meta": meta.model_dump(), - "response_summary": summary.model_dump(), - "info": None, - "beacon_error": None, + "meta": meta.model_dump(by_alias=True, exclude_none=True), + "responseSummary": summary.model_dump(by_alias=True, exclude_none=True), } diff --git a/src/beacon_api/api/cohorts.py b/src/beacon_api/api/cohorts.py index 7541114..a66b59e 100644 --- a/src/beacon_api/api/cohorts.py +++ b/src/beacon_api/api/cohorts.py @@ -11,18 +11,25 @@ PaginationSkip, create_request_body_from_params, ) +from beacon_api.api.response_utils import ( + build_collections_response, + build_meta, + build_received_request_summary, + build_resultset_response, + build_summary, + filters_to_strings, + schema_for_entity, +) from beacon_api.models.request import BeaconRequestBody, RequestedGranularity from beacon_api.models.response import ( - BeaconResponseMeta, + BeaconCollectionsResponse, BeaconResultsetsResponse, - BeaconSummaryResults, - ResultsetInstance, ) router = APIRouter(prefix="/cohorts", tags=["cohorts"]) -@router.get("", response_model=BeaconResultsetsResponse) +@router.get("", response_model=BeaconCollectionsResponse) async def list_cohorts( service: CohortServiceDep, skip: PaginationSkip = 0, @@ -39,7 +46,7 @@ async def list_cohorts( filters: Beacon v2 filters in JSON or comma-separated format (optional) Returns: - BeaconResultsetsResponse with cohort records + BeaconCollectionsResponse with cohort collections Raises: HTTPException: 400 if filters are invalid @@ -53,70 +60,54 @@ async def list_cohorts( ) cohorts = await service.query(request_body) - meta = BeaconResponseMeta( - beacon_id="beacon-skeleton", - api_version="v2.0", + requested_schemas = schema_for_entity("cohort") + received_request_summary = build_received_request_summary( + requested_granularity="record", + filters=filters_to_strings(request_body.filters), + pagination={"skip": skip, "limit": limit}, + requested_schemas=requested_schemas, + ) + meta = build_meta( returned_granularity="record", - received_request_summary={ - "requested_granularity": "record", - "filters": ( - [f.model_dump() for f in request_body.filters] - if request_body.filters - else [] - ), - "pagination": {"skip": skip, "limit": limit}, - }, + received_request_summary=received_request_summary, + returned_schemas=requested_schemas, ) - - result_set = ResultsetInstance( - id="cohorts", - set_type="cohort", - exists=len(cohorts) > 0, - result_count=len(cohorts), - results=[c.model_dump() for c in cohorts], + response = build_collections_response( + collections=[c.model_dump() for c in cohorts], ) - - summary = BeaconSummaryResults( + summary = build_summary( exists=len(cohorts) > 0, num_total_results=len(cohorts), ) return { - "meta": meta.model_dump(), - "response_summary": summary.model_dump(), - "response": [result_set.model_dump()], - "info": None, - "beacon_error": None, + "meta": meta.model_dump(by_alias=True, exclude_none=True), + "responseSummary": summary.model_dump(by_alias=True, exclude_none=True), + "response": response.model_dump(by_alias=True, exclude_none=True), } except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) from None except NotImplementedError: # Return empty but valid response for unimplemented services - meta = BeaconResponseMeta( - beacon_id="beacon-skeleton", - api_version="v2.0", - returned_granularity="record", - received_request_summary={ - "requested_granularity": "record", - "filters": [], - "pagination": {"skip": skip, "limit": limit}, - }, + requested_schemas = schema_for_entity("cohort") + received_request_summary = build_received_request_summary( + requested_granularity="record", + filters=[], + pagination={"skip": skip, "limit": limit}, + requested_schemas=requested_schemas, ) - result_set = ResultsetInstance( - id="cohorts", - set_type="cohort", - exists=False, - result_count=0, - results=[], + meta = build_meta( + returned_granularity="record", + received_request_summary=received_request_summary, + returned_schemas=requested_schemas, ) - summary = BeaconSummaryResults(exists=False, num_total_results=0) + response = build_collections_response(collections=[]) + summary = build_summary(exists=False, num_total_results=0) return { - "meta": meta.model_dump(), - "response_summary": summary.model_dump(), - "response": [result_set.model_dump()], - "info": None, - "beacon_error": None, + "meta": meta.model_dump(by_alias=True, exclude_none=True), + "responseSummary": summary.model_dump(by_alias=True, exclude_none=True), + "response": response.model_dump(by_alias=True, exclude_none=True), } @@ -147,93 +138,78 @@ async def query_cohorts( granularity = request_body.meta.requested_granularity # Create response metadata - meta = BeaconResponseMeta( - beacon_id="beacon-skeleton", - api_version="v2.0", + requested_schemas = schema_for_entity("cohort") + received_request_summary = build_received_request_summary( + requested_granularity=granularity.value, + filters=filters_to_strings(request_body.filters), + pagination=request_body.meta.pagination or {"skip": 0, "limit": 0}, + requested_schemas=requested_schemas, + include_resultset_responses=request_body.meta.include_resultset_responses, + ) + meta = build_meta( returned_granularity=granularity.value, - received_request_summary={ - "requested_granularity": granularity.value, - "filters": ( - [f.model_dump() for f in request_body.filters] - if request_body.filters - else [] - ), - }, + received_request_summary=received_request_summary, + returned_schemas=requested_schemas, ) if granularity == RequestedGranularity.BOOLEAN: exists = await service.exists(request_body) - summary = BeaconSummaryResults(exists=exists) + summary = build_summary(exists=exists) return { - "meta": meta.model_dump(), - "response_summary": summary.model_dump(), - "info": None, - "beacon_error": None, + "meta": meta.model_dump(by_alias=True, exclude_none=True), + "responseSummary": summary.model_dump(by_alias=True, exclude_none=True), } elif granularity == RequestedGranularity.COUNT: count = await service.count(request_body) - summary = BeaconSummaryResults(exists=count > 0, num_total_results=count) + summary = build_summary(exists=count > 0, num_total_results=count) return { - "meta": meta.model_dump(), - "response_summary": summary.model_dump(), - "info": None, - "beacon_error": None, + "meta": meta.model_dump(by_alias=True, exclude_none=True), + "responseSummary": summary.model_dump(by_alias=True, exclude_none=True), } else: # RECORD cohorts = await service.query(request_body) - result_set = ResultsetInstance( - id="cohorts", - set_type="cohort", - exists=len(cohorts) > 0, - result_count=len(cohorts), + response = build_resultset_response( + entity_type="cohort", results=[c.model_dump() for c in cohorts], ) - summary = BeaconSummaryResults( + summary = build_summary( exists=len(cohorts) > 0, num_total_results=len(cohorts), ) return { - "meta": meta.model_dump(), - "response_summary": summary.model_dump(), - "response": [result_set.model_dump()], + "meta": meta.model_dump(by_alias=True, exclude_none=True), + "responseSummary": summary.model_dump(by_alias=True, exclude_none=True), + "response": response.model_dump(by_alias=True, exclude_none=True), } except NotImplementedError: # Return empty but valid response for unimplemented services (beacon-verifier compliance) - meta = BeaconResponseMeta( - beacon_id="beacon-skeleton", - api_version="v2.0", + requested_schemas = schema_for_entity("cohort") + received_request_summary = build_received_request_summary( + requested_granularity=request_body.meta.requested_granularity.value, + filters=filters_to_strings(request_body.filters), + pagination=request_body.meta.pagination or {"skip": 0, "limit": 0}, + requested_schemas=requested_schemas, + include_resultset_responses=request_body.meta.include_resultset_responses, + ) + meta = build_meta( returned_granularity=request_body.meta.requested_granularity.value, - received_request_summary={ - "requested_granularity": request_body.meta.requested_granularity.value, - "filters": ( - [f.model_dump() for f in request_body.filters] - if request_body.filters - else [] - ), - }, + received_request_summary=received_request_summary, + returned_schemas=requested_schemas, ) - summary = BeaconSummaryResults(exists=False, num_total_results=0) + summary = build_summary(exists=False, num_total_results=0) if request_body.meta.requested_granularity == RequestedGranularity.RECORD: - result_set = ResultsetInstance( - id="cohorts", - set_type="cohort", - exists=False, - result_count=0, - results=[], - ) + response = build_resultset_response(entity_type="cohort", results=[]) return { - "meta": meta.model_dump(), - "response_summary": summary.model_dump(), - "response": [result_set.model_dump()], + "meta": meta.model_dump(by_alias=True, exclude_none=True), + "responseSummary": summary.model_dump(by_alias=True, exclude_none=True), + "response": response.model_dump(by_alias=True, exclude_none=True), } else: return { - "meta": meta.model_dump(), - "response_summary": summary.model_dump(), - "info": None, - "beacon_error": None, + "meta": meta.model_dump(by_alias=True, exclude_none=True), + "responseSummary": summary.model_dump(by_alias=True, exclude_none=True), } diff --git a/src/beacon_api/api/configuration.py b/src/beacon_api/api/configuration.py new file mode 100644 index 0000000..695151a --- /dev/null +++ b/src/beacon_api/api/configuration.py @@ -0,0 +1,21 @@ +"""Beacon configuration endpoint.""" + +from fastapi import APIRouter + +from beacon_api.core.beacon_catalog import get_configuration +from beacon_api.core.config import get_settings + +router = APIRouter(prefix="/configuration", tags=["configuration"]) + + +@router.get("") +async def get_configuration_endpoint() -> dict: + """Return Beacon configuration information.""" + settings = get_settings() + meta = { + "beaconId": settings.beacon_id, + "apiVersion": settings.api_version, + "returnedSchemas": [], + } + response = get_configuration(settings) + return {"meta": meta, "response": response} diff --git a/src/beacon_api/api/datasets.py b/src/beacon_api/api/datasets.py index 10859f5..cbd606d 100644 --- a/src/beacon_api/api/datasets.py +++ b/src/beacon_api/api/datasets.py @@ -11,18 +11,25 @@ PaginationSkip, create_request_body_from_params, ) +from beacon_api.api.response_utils import ( + build_collections_response, + build_meta, + build_received_request_summary, + build_resultset_response, + build_summary, + filters_to_strings, + schema_for_entity, +) from beacon_api.models.request import BeaconRequestBody, RequestedGranularity from beacon_api.models.response import ( - BeaconResponseMeta, + BeaconCollectionsResponse, BeaconResultsetsResponse, - BeaconSummaryResults, - ResultsetInstance, ) router = APIRouter(prefix="/datasets", tags=["datasets"]) -@router.get("", response_model=BeaconResultsetsResponse) +@router.get("", response_model=BeaconCollectionsResponse) async def list_datasets( service: DatasetServiceDep, skip: PaginationSkip = 0, @@ -39,7 +46,7 @@ async def list_datasets( filters: Beacon v2 filters in JSON or comma-separated format (optional) Returns: - BeaconResultsetsResponse with dataset records + BeaconCollectionsResponse with dataset collections Raises: HTTPException: 400 if filters are invalid @@ -53,70 +60,54 @@ async def list_datasets( ) datasets = await service.query(request_body) - meta = BeaconResponseMeta( - beacon_id="beacon-skeleton", - api_version="v2.0", + requested_schemas = schema_for_entity("dataset") + received_request_summary = build_received_request_summary( + requested_granularity="record", + filters=filters_to_strings(request_body.filters), + pagination={"skip": skip, "limit": limit}, + requested_schemas=requested_schemas, + ) + meta = build_meta( returned_granularity="record", - received_request_summary={ - "requested_granularity": "record", - "filters": ( - [f.model_dump() for f in request_body.filters] - if request_body.filters - else [] - ), - "pagination": {"skip": skip, "limit": limit}, - }, + received_request_summary=received_request_summary, + returned_schemas=requested_schemas, ) - - result_set = ResultsetInstance( - id="datasets", - set_type="dataset", - exists=len(datasets) > 0, - result_count=len(datasets), - results=[d.model_dump() for d in datasets], + response = build_collections_response( + collections=[d.model_dump() for d in datasets], ) - - summary = BeaconSummaryResults( + summary = build_summary( exists=len(datasets) > 0, num_total_results=len(datasets), ) return { - "meta": meta.model_dump(), - "response_summary": summary.model_dump(), - "response": [result_set.model_dump()], - "info": None, - "beacon_error": None, + "meta": meta.model_dump(by_alias=True, exclude_none=True), + "responseSummary": summary.model_dump(by_alias=True, exclude_none=True), + "response": response.model_dump(by_alias=True, exclude_none=True), } except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) from None except NotImplementedError: # Return empty but valid response for unimplemented services - meta = BeaconResponseMeta( - beacon_id="beacon-skeleton", - api_version="v2.0", - returned_granularity="record", - received_request_summary={ - "requested_granularity": "record", - "filters": [], - "pagination": {"skip": skip, "limit": limit}, - }, + requested_schemas = schema_for_entity("dataset") + received_request_summary = build_received_request_summary( + requested_granularity="record", + filters=[], + pagination={"skip": skip, "limit": limit}, + requested_schemas=requested_schemas, ) - result_set = ResultsetInstance( - id="datasets", - set_type="dataset", - exists=False, - result_count=0, - results=[], + meta = build_meta( + returned_granularity="record", + received_request_summary=received_request_summary, + returned_schemas=requested_schemas, ) - summary = BeaconSummaryResults(exists=False, num_total_results=0) + response = build_collections_response(collections=[]) + summary = build_summary(exists=False, num_total_results=0) return { - "meta": meta.model_dump(), - "response_summary": summary.model_dump(), - "response": [result_set.model_dump()], - "info": None, - "beacon_error": None, + "meta": meta.model_dump(by_alias=True, exclude_none=True), + "responseSummary": summary.model_dump(by_alias=True, exclude_none=True), + "response": response.model_dump(by_alias=True, exclude_none=True), } @@ -147,93 +138,78 @@ async def query_datasets( granularity = request_body.meta.requested_granularity # Create response metadata - meta = BeaconResponseMeta( - beacon_id="beacon-skeleton", - api_version="v2.0", + requested_schemas = schema_for_entity("dataset") + received_request_summary = build_received_request_summary( + requested_granularity=granularity.value, + filters=filters_to_strings(request_body.filters), + pagination=request_body.meta.pagination or {"skip": 0, "limit": 0}, + requested_schemas=requested_schemas, + include_resultset_responses=request_body.meta.include_resultset_responses, + ) + meta = build_meta( returned_granularity=granularity.value, - received_request_summary={ - "requested_granularity": granularity.value, - "filters": ( - [f.model_dump() for f in request_body.filters] - if request_body.filters - else [] - ), - }, + received_request_summary=received_request_summary, + returned_schemas=requested_schemas, ) if granularity == RequestedGranularity.BOOLEAN: exists = await service.exists(request_body) - summary = BeaconSummaryResults(exists=exists) + summary = build_summary(exists=exists) return { - "meta": meta.model_dump(), - "response_summary": summary.model_dump(), - "info": None, - "beacon_error": None, + "meta": meta.model_dump(by_alias=True, exclude_none=True), + "responseSummary": summary.model_dump(by_alias=True, exclude_none=True), } elif granularity == RequestedGranularity.COUNT: count = await service.count(request_body) - summary = BeaconSummaryResults(exists=count > 0, num_total_results=count) + summary = build_summary(exists=count > 0, num_total_results=count) return { - "meta": meta.model_dump(), - "response_summary": summary.model_dump(), - "info": None, - "beacon_error": None, + "meta": meta.model_dump(by_alias=True, exclude_none=True), + "responseSummary": summary.model_dump(by_alias=True, exclude_none=True), } else: # RECORD datasets = await service.query(request_body) - result_set = ResultsetInstance( - id="datasets", - set_type="dataset", - exists=len(datasets) > 0, - result_count=len(datasets), + response = build_resultset_response( + entity_type="dataset", results=[d.model_dump() for d in datasets], ) - summary = BeaconSummaryResults( + summary = build_summary( exists=len(datasets) > 0, num_total_results=len(datasets), ) return { - "meta": meta.model_dump(), - "response_summary": summary.model_dump(), - "response": [result_set.model_dump()], + "meta": meta.model_dump(by_alias=True, exclude_none=True), + "responseSummary": summary.model_dump(by_alias=True, exclude_none=True), + "response": response.model_dump(by_alias=True, exclude_none=True), } except NotImplementedError: # Return empty but valid response for unimplemented services (beacon-verifier compliance) - meta = BeaconResponseMeta( - beacon_id="beacon-skeleton", - api_version="v2.0", + requested_schemas = schema_for_entity("dataset") + received_request_summary = build_received_request_summary( + requested_granularity=request_body.meta.requested_granularity.value, + filters=filters_to_strings(request_body.filters), + pagination=request_body.meta.pagination or {"skip": 0, "limit": 0}, + requested_schemas=requested_schemas, + include_resultset_responses=request_body.meta.include_resultset_responses, + ) + meta = build_meta( returned_granularity=request_body.meta.requested_granularity.value, - received_request_summary={ - "requested_granularity": request_body.meta.requested_granularity.value, - "filters": ( - [f.model_dump() for f in request_body.filters] - if request_body.filters - else [] - ), - }, + received_request_summary=received_request_summary, + returned_schemas=requested_schemas, ) - summary = BeaconSummaryResults(exists=False, num_total_results=0) + summary = build_summary(exists=False, num_total_results=0) if request_body.meta.requested_granularity == RequestedGranularity.RECORD: - result_set = ResultsetInstance( - id="datasets", - set_type="dataset", - exists=False, - result_count=0, - results=[], - ) + response = build_resultset_response(entity_type="dataset", results=[]) return { - "meta": meta.model_dump(), - "response_summary": summary.model_dump(), - "response": [result_set.model_dump()], + "meta": meta.model_dump(by_alias=True, exclude_none=True), + "responseSummary": summary.model_dump(by_alias=True, exclude_none=True), + "response": response.model_dump(by_alias=True, exclude_none=True), } else: return { - "meta": meta.model_dump(), - "response_summary": summary.model_dump(), - "info": None, - "beacon_error": None, + "meta": meta.model_dump(by_alias=True, exclude_none=True), + "responseSummary": summary.model_dump(by_alias=True, exclude_none=True), } diff --git a/src/beacon_api/api/entry_types.py b/src/beacon_api/api/entry_types.py new file mode 100644 index 0000000..f1ddeda --- /dev/null +++ b/src/beacon_api/api/entry_types.py @@ -0,0 +1,21 @@ +"""Beacon entry types endpoint.""" + +from fastapi import APIRouter + +from beacon_api.core.beacon_catalog import get_entry_types_response +from beacon_api.core.config import get_settings + +router = APIRouter(prefix="/entry_types", tags=["entry_types"]) + + +@router.get("") +async def get_entry_types_endpoint() -> dict: + """Return Beacon entry types information.""" + settings = get_settings() + meta = { + "beaconId": settings.beacon_id, + "apiVersion": settings.api_version, + "returnedSchemas": [], + } + response = get_entry_types_response() + return {"meta": meta, "response": response} diff --git a/src/beacon_api/api/genomic_variations.py b/src/beacon_api/api/genomic_variations.py index f479313..785c87f 100644 --- a/src/beacon_api/api/genomic_variations.py +++ b/src/beacon_api/api/genomic_variations.py @@ -11,16 +11,19 @@ PaginationSkip, create_request_body_from_params, ) +from beacon_api.api.response_utils import ( + build_meta, + build_received_request_summary, + build_resultset_response, + build_summary, + filters_to_strings, + schema_for_entity, +) from beacon_api.models.request import ( BeaconRequestBody, RequestedGranularity, ) -from beacon_api.models.response import ( - BeaconResponseMeta, - BeaconResultsetsResponse, - BeaconSummaryResults, - ResultsetInstance, -) +from beacon_api.models.response import BeaconResultsetsResponse router = APIRouter(prefix="/g_variants", tags=["genomic_variations"]) @@ -56,70 +59,55 @@ async def list_variations( ) variations = await service.query(request_body) - meta = BeaconResponseMeta( - beacon_id="beacon-skeleton", - api_version="v2.0", + requested_schemas = schema_for_entity("genomicVariation") + received_request_summary = build_received_request_summary( + requested_granularity="record", + filters=filters_to_strings(request_body.filters), + pagination={"skip": skip, "limit": limit}, + requested_schemas=requested_schemas, + ) + meta = build_meta( returned_granularity="record", - received_request_summary={ - "requested_granularity": "record", - "filters": ( - [f.model_dump() for f in request_body.filters] - if request_body.filters - else [] - ), - "pagination": {"skip": skip, "limit": limit}, - }, + received_request_summary=received_request_summary, + returned_schemas=requested_schemas, ) - - result_set = ResultsetInstance( - id="g_variants", - set_type="genomic_variation", - exists=len(variations) > 0, - result_count=len(variations), + response = build_resultset_response( + entity_type="genomicVariation", results=[v.model_dump() for v in variations], ) - - summary = BeaconSummaryResults( + summary = build_summary( exists=len(variations) > 0, num_total_results=len(variations), ) return { - "meta": meta.model_dump(), - "response_summary": summary.model_dump(), - "response": [result_set.model_dump()], - "info": None, - "beacon_error": None, + "meta": meta.model_dump(by_alias=True, exclude_none=True), + "responseSummary": summary.model_dump(by_alias=True, exclude_none=True), + "response": response.model_dump(by_alias=True, exclude_none=True), } except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) from None except NotImplementedError: # Return empty but valid response for unimplemented services - meta = BeaconResponseMeta( - beacon_id="beacon-skeleton", - api_version="v2.0", - returned_granularity="record", - received_request_summary={ - "requested_granularity": "record", - "filters": [], - "pagination": {"skip": skip, "limit": limit}, - }, + requested_schemas = schema_for_entity("genomicVariation") + received_request_summary = build_received_request_summary( + requested_granularity="record", + filters=[], + pagination={"skip": skip, "limit": limit}, + requested_schemas=requested_schemas, ) - result_set = ResultsetInstance( - id="g_variants", - set_type="genomic_variation", - exists=False, - result_count=0, - results=[], + meta = build_meta( + returned_granularity="record", + received_request_summary=received_request_summary, + returned_schemas=requested_schemas, ) - summary = BeaconSummaryResults(exists=False, num_total_results=0) + response = build_resultset_response(entity_type="genomicVariation", results=[]) + summary = build_summary(exists=False, num_total_results=0) return { - "meta": meta.model_dump(), - "response_summary": summary.model_dump(), - "response": [result_set.model_dump()], - "info": None, - "beacon_error": None, + "meta": meta.model_dump(by_alias=True, exclude_none=True), + "responseSummary": summary.model_dump(by_alias=True, exclude_none=True), + "response": response.model_dump(by_alias=True, exclude_none=True), } @@ -148,89 +136,77 @@ async def query_genomic_variations( """ try: granularity = request_body.meta.requested_granularity - meta = BeaconResponseMeta( - beacon_id="beacon-skeleton", - api_version="v2.0", + requested_schemas = schema_for_entity("genomicVariation") + received_request_summary = build_received_request_summary( + requested_granularity=granularity.value, + filters=filters_to_strings(request_body.filters), + pagination=request_body.meta.pagination or {"skip": 0, "limit": 0}, + requested_schemas=requested_schemas, + include_resultset_responses=request_body.meta.include_resultset_responses, + ) + meta = build_meta( returned_granularity=granularity.value, - received_request_summary={ - "requested_granularity": granularity.value, - "filters": ( - [f.model_dump() for f in request_body.filters] - if request_body.filters - else [] - ), - }, + received_request_summary=received_request_summary, + returned_schemas=requested_schemas, ) if granularity == RequestedGranularity.BOOLEAN: exists = await service.exists(request_body) - summary = BeaconSummaryResults(exists=exists) + summary = build_summary(exists=exists) return { - "meta": meta.model_dump(), - "response_summary": summary.model_dump(), - "info": None, - "beacon_error": None, + "meta": meta.model_dump(by_alias=True, exclude_none=True), + "responseSummary": summary.model_dump(by_alias=True, exclude_none=True), } elif granularity == RequestedGranularity.COUNT: count = await service.count(request_body) - summary = BeaconSummaryResults(exists=count > 0, num_total_results=count) + summary = build_summary(exists=count > 0, num_total_results=count) return { - "meta": meta.model_dump(), - "response_summary": summary.model_dump(), - "info": None, - "beacon_error": None, + "meta": meta.model_dump(by_alias=True, exclude_none=True), + "responseSummary": summary.model_dump(by_alias=True, exclude_none=True), } else: # RECORD variations = await service.query(request_body) - result_set = ResultsetInstance( - id="g_variants", - set_type="genomic_variation", - exists=len(variations) > 0, - result_count=len(variations), + response = build_resultset_response( + entity_type="genomicVariation", results=[v.model_dump() for v in variations], ) - summary = BeaconSummaryResults( - exists=len(variations) > 0, num_total_results=len(variations) + summary = build_summary( + exists=len(variations) > 0, + num_total_results=len(variations), ) return { - "meta": meta.model_dump(), - "response_summary": summary.model_dump(), - "response": [result_set.model_dump()], + "meta": meta.model_dump(by_alias=True, exclude_none=True), + "responseSummary": summary.model_dump(by_alias=True, exclude_none=True), + "response": response.model_dump(by_alias=True, exclude_none=True), } except NotImplementedError: # Return empty but valid response for unimplemented services (beacon-verifier compliance) - meta = BeaconResponseMeta( - beacon_id="beacon-skeleton", - api_version="v2.0", + requested_schemas = schema_for_entity("genomicVariation") + received_request_summary = build_received_request_summary( + requested_granularity=request_body.meta.requested_granularity.value, + filters=filters_to_strings(request_body.filters), + pagination=request_body.meta.pagination or {"skip": 0, "limit": 0}, + requested_schemas=requested_schemas, + include_resultset_responses=request_body.meta.include_resultset_responses, + ) + meta = build_meta( returned_granularity=request_body.meta.requested_granularity.value, - received_request_summary={ - "requested_granularity": request_body.meta.requested_granularity.value, - "filters": ( - [f.model_dump() for f in request_body.filters] - if request_body.filters - else [] - ), - }, + received_request_summary=received_request_summary, + returned_schemas=requested_schemas, ) - summary = BeaconSummaryResults(exists=False, num_total_results=0) + summary = build_summary(exists=False, num_total_results=0) if request_body.meta.requested_granularity == RequestedGranularity.RECORD: - result_set = ResultsetInstance( - id="g_variants", - set_type="genomic_variation", - exists=False, - result_count=0, - results=[], + response = build_resultset_response( + entity_type="genomicVariation", results=[] ) return { - "meta": meta.model_dump(), - "response_summary": summary.model_dump(), - "response": [result_set.model_dump()], + "meta": meta.model_dump(by_alias=True, exclude_none=True), + "responseSummary": summary.model_dump(by_alias=True, exclude_none=True), + "response": response.model_dump(by_alias=True, exclude_none=True), } else: return { - "meta": meta.model_dump(), - "response_summary": summary.model_dump(), - "info": None, - "beacon_error": None, + "meta": meta.model_dump(by_alias=True, exclude_none=True), + "responseSummary": summary.model_dump(by_alias=True, exclude_none=True), } diff --git a/src/beacon_api/api/individuals.py b/src/beacon_api/api/individuals.py index 49c932e..b94eca0 100644 --- a/src/beacon_api/api/individuals.py +++ b/src/beacon_api/api/individuals.py @@ -11,16 +11,19 @@ PaginationSkip, create_request_body_from_params, ) +from beacon_api.api.response_utils import ( + build_meta, + build_received_request_summary, + build_resultset_response, + build_summary, + filters_to_strings, + schema_for_entity, +) from beacon_api.models.request import ( BeaconRequestBody, RequestedGranularity, ) -from beacon_api.models.response import ( - BeaconResponseMeta, - BeaconResultsetsResponse, - BeaconSummaryResults, - ResultsetInstance, -) +from beacon_api.models.response import BeaconResultsetsResponse router = APIRouter(prefix="/individuals", tags=["individuals"]) @@ -60,70 +63,55 @@ async def list_individuals( ) individuals = await service.query(request_body) - meta = BeaconResponseMeta( - beacon_id="beacon-skeleton", - api_version="v2.0", + requested_schemas = schema_for_entity("individual") + received_request_summary = build_received_request_summary( + requested_granularity="record", + filters=filters_to_strings(request_body.filters), + pagination={"skip": skip, "limit": limit}, + requested_schemas=requested_schemas, + ) + meta = build_meta( returned_granularity="record", - received_request_summary={ - "requested_granularity": "record", - "filters": ( - [f.model_dump() for f in request_body.filters] - if request_body.filters - else [] - ), - "pagination": {"skip": skip, "limit": limit}, - }, + received_request_summary=received_request_summary, + returned_schemas=requested_schemas, ) - - result_set = ResultsetInstance( - id="individuals", - set_type="individual", - exists=len(individuals) > 0, - result_count=len(individuals), + response = build_resultset_response( + entity_type="individual", results=[ind.model_dump() for ind in individuals], ) - - summary = BeaconSummaryResults( + summary = build_summary( exists=len(individuals) > 0, num_total_results=len(individuals), ) return { - "meta": meta.model_dump(), - "response_summary": summary.model_dump(), - "response": [result_set.model_dump()], - "info": None, - "beacon_error": None, + "meta": meta.model_dump(by_alias=True, exclude_none=True), + "responseSummary": summary.model_dump(by_alias=True, exclude_none=True), + "response": response.model_dump(by_alias=True, exclude_none=True), } except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) from None except NotImplementedError: # Return empty but valid response for unimplemented services - meta = BeaconResponseMeta( - beacon_id="beacon-skeleton", - api_version="v2.0", - returned_granularity="record", - received_request_summary={ - "requested_granularity": "record", - "filters": [], - "pagination": {"skip": skip, "limit": limit}, - }, + requested_schemas = schema_for_entity("individual") + received_request_summary = build_received_request_summary( + requested_granularity="record", + filters=[], + pagination={"skip": skip, "limit": limit}, + requested_schemas=requested_schemas, ) - result_set = ResultsetInstance( - id="individuals", - set_type="individual", - exists=False, - result_count=0, - results=[], + meta = build_meta( + returned_granularity="record", + received_request_summary=received_request_summary, + returned_schemas=requested_schemas, ) - summary = BeaconSummaryResults(exists=False, num_total_results=0) + response = build_resultset_response(entity_type="individual", results=[]) + summary = build_summary(exists=False, num_total_results=0) return { - "meta": meta.model_dump(), - "response_summary": summary.model_dump(), - "response": [result_set.model_dump()], - "info": None, - "beacon_error": None, + "meta": meta.model_dump(by_alias=True, exclude_none=True), + "responseSummary": summary.model_dump(by_alias=True, exclude_none=True), + "response": response.model_dump(by_alias=True, exclude_none=True), } @@ -150,33 +138,29 @@ async def get_individual( if individual is None: raise HTTPException(status_code=404, detail="Individual not found") - meta = BeaconResponseMeta( - beacon_id="beacon-skeleton", - api_version="v2.0", + requested_schemas = schema_for_entity("individual") + received_request_summary = build_received_request_summary( + requested_granularity="record", + filters=[], + pagination={"skip": 0, "limit": 1}, + requested_schemas=requested_schemas, + ) + meta = build_meta( returned_granularity="record", - received_request_summary={ - "requested_granularity": "record", - "filters": [], - "requested_id": individual_id, - }, + received_request_summary=received_request_summary, + returned_schemas=requested_schemas, ) - result_set = ResultsetInstance( - id="individuals", - set_type="individual", - exists=True, - result_count=1, + response = build_resultset_response( + entity_type="individual", results=[individual.model_dump()], ) - - summary = BeaconSummaryResults(exists=True, num_total_results=1) + summary = build_summary(exists=True, num_total_results=1) return { - "meta": meta.model_dump(), - "response_summary": summary.model_dump(), - "response": [result_set.model_dump()], - "info": None, - "beacon_error": None, + "meta": meta.model_dump(by_alias=True, exclude_none=True), + "responseSummary": summary.model_dump(by_alias=True, exclude_none=True), + "response": response.model_dump(by_alias=True, exclude_none=True), } except NotImplementedError: @@ -211,93 +195,79 @@ async def query_individuals( granularity = request_body.meta.requested_granularity # Create response metadata - meta = BeaconResponseMeta( - beacon_id="beacon-skeleton", - api_version="v2.0", + requested_schemas = schema_for_entity("individual") + received_request_summary = build_received_request_summary( + requested_granularity=granularity.value, + filters=filters_to_strings(request_body.filters), + pagination=request_body.meta.pagination or {"skip": 0, "limit": 0}, + requested_schemas=requested_schemas, + include_resultset_responses=request_body.meta.include_resultset_responses, + ) + meta = build_meta( returned_granularity=granularity.value, - received_request_summary={ - "requested_granularity": granularity.value, - "filters": ( - [f.model_dump() for f in request_body.filters] - if request_body.filters - else [] - ), - }, + received_request_summary=received_request_summary, + returned_schemas=requested_schemas, + test_mode=False, ) if granularity == RequestedGranularity.BOOLEAN: exists = await service.exists(request_body) - summary = BeaconSummaryResults(exists=exists) + summary = build_summary(exists=exists) return { - "meta": meta.model_dump(), - "response_summary": summary.model_dump(), - "info": None, - "beacon_error": None, + "meta": meta.model_dump(by_alias=True, exclude_none=True), + "responseSummary": summary.model_dump(by_alias=True, exclude_none=True), } elif granularity == RequestedGranularity.COUNT: count = await service.count(request_body) - summary = BeaconSummaryResults(exists=count > 0, num_total_results=count) + summary = build_summary(exists=count > 0, num_total_results=count) return { - "meta": meta.model_dump(), - "response_summary": summary.model_dump(), - "info": None, - "beacon_error": None, + "meta": meta.model_dump(by_alias=True, exclude_none=True), + "responseSummary": summary.model_dump(by_alias=True, exclude_none=True), } else: # RECORD individuals = await service.query(request_body) - result_set = ResultsetInstance( - id="individuals", - set_type="individual", - exists=len(individuals) > 0, - result_count=len(individuals), + response = build_resultset_response( + entity_type="individual", results=[ind.model_dump() for ind in individuals], ) - summary = BeaconSummaryResults( + summary = build_summary( exists=len(individuals) > 0, num_total_results=len(individuals), ) return { - "meta": meta.model_dump(), - "response_summary": summary.model_dump(), - "response": [result_set.model_dump()], + "meta": meta.model_dump(by_alias=True, exclude_none=True), + "responseSummary": summary.model_dump(by_alias=True, exclude_none=True), + "response": response.model_dump(by_alias=True, exclude_none=True), } except NotImplementedError: # Return empty but valid response for unimplemented services (beacon-verifier compliance) - meta = BeaconResponseMeta( - beacon_id="beacon-skeleton", - api_version="v2.0", + requested_schemas = schema_for_entity("individual") + received_request_summary = build_received_request_summary( + requested_granularity=request_body.meta.requested_granularity.value, + filters=filters_to_strings(request_body.filters), + pagination=request_body.meta.pagination or {"skip": 0, "limit": 0}, + requested_schemas=requested_schemas, + include_resultset_responses=request_body.meta.include_resultset_responses, + ) + meta = build_meta( returned_granularity=request_body.meta.requested_granularity.value, - received_request_summary={ - "requested_granularity": request_body.meta.requested_granularity.value, - "filters": ( - [f.model_dump() for f in request_body.filters] - if request_body.filters - else [] - ), - }, + received_request_summary=received_request_summary, + returned_schemas=requested_schemas, ) - summary = BeaconSummaryResults(exists=False, num_total_results=0) + summary = build_summary(exists=False, num_total_results=0) if request_body.meta.requested_granularity == RequestedGranularity.RECORD: - result_set = ResultsetInstance( - id="individuals", - set_type="individual", - exists=False, - result_count=0, - results=[], - ) + response = build_resultset_response(entity_type="individual", results=[]) return { - "meta": meta.model_dump(), - "response_summary": summary.model_dump(), - "response": [result_set.model_dump()], + "meta": meta.model_dump(by_alias=True, exclude_none=True), + "responseSummary": summary.model_dump(by_alias=True, exclude_none=True), + "response": response.model_dump(by_alias=True, exclude_none=True), } else: return { - "meta": meta.model_dump(), - "response_summary": summary.model_dump(), - "info": None, - "beacon_error": None, + "meta": meta.model_dump(by_alias=True, exclude_none=True), + "responseSummary": summary.model_dump(by_alias=True, exclude_none=True), } diff --git a/src/beacon_api/api/info.py b/src/beacon_api/api/info.py index feaed38..f9b39e6 100644 --- a/src/beacon_api/api/info.py +++ b/src/beacon_api/api/info.py @@ -63,4 +63,7 @@ async def get_beacon_info() -> dict[str, Any]: "returnedSchemas": [], } - return {"meta": meta, "response": response.model_dump(by_alias=True)} + return { + "meta": meta, + "response": response.model_dump(by_alias=True, exclude_none=True), + } diff --git a/src/beacon_api/api/map.py b/src/beacon_api/api/map.py new file mode 100644 index 0000000..6aba161 --- /dev/null +++ b/src/beacon_api/api/map.py @@ -0,0 +1,21 @@ +"""Beacon map endpoint.""" + +from fastapi import APIRouter, Request + +from beacon_api.core.beacon_catalog import get_map_response +from beacon_api.core.config import get_settings + +router = APIRouter(prefix="/map", tags=["map"]) + + +@router.get("") +async def get_map_endpoint(request: Request) -> dict: + """Return Beacon map information.""" + settings = get_settings() + meta = { + "beaconId": settings.beacon_id, + "apiVersion": settings.api_version, + "returnedSchemas": [], + } + response = get_map_response(str(request.base_url)) + return {"meta": meta, "response": response} diff --git a/src/beacon_api/api/response_utils.py b/src/beacon_api/api/response_utils.py new file mode 100644 index 0000000..05b7a0d --- /dev/null +++ b/src/beacon_api/api/response_utils.py @@ -0,0 +1,111 @@ +"""Helpers for building Beacon v2 compliant responses.""" + +from __future__ import annotations + +from typing import Any + +from beacon_api.core.beacon_catalog import get_schema_reference +from beacon_api.core.config import get_settings +from beacon_api.models.common import SchemaReference +from beacon_api.models.response import ( + BeaconCollectionsResponseBody, + BeaconResponseMeta, + BeaconResultsetsResponseBody, + BeaconSummaryResults, + ResultsetInstance, +) + + +def build_received_request_summary( + *, + requested_granularity: str, + filters: list[str], + pagination: dict[str, int], + requested_schemas: list[SchemaReference], + include_resultset_responses: str | None = None, + request_parameters: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Build the receivedRequestSummary section with required fields.""" + settings = get_settings() + summary: dict[str, Any] = { + "apiVersion": settings.api_version, + "requestedSchemas": [s.model_dump(by_alias=True) for s in requested_schemas], + "pagination": pagination, + "requestedGranularity": requested_granularity, + "filters": filters, + } + if include_resultset_responses is not None: + summary["includeResultsetResponses"] = include_resultset_responses + if request_parameters is not None: + summary["requestParameters"] = request_parameters + return summary + + +def build_meta( + *, + returned_granularity: str, + received_request_summary: dict[str, Any], + returned_schemas: list[SchemaReference], + test_mode: bool = False, +) -> BeaconResponseMeta: + """Build Beacon response metadata.""" + settings = get_settings() + return BeaconResponseMeta( + beacon_id=settings.beacon_id, + api_version=settings.api_version, + returned_granularity=returned_granularity, + received_request_summary=received_request_summary, + returned_schemas=returned_schemas, + test_mode=test_mode, + ) + + +def build_resultset_response( + *, + entity_type: str, + results: list[dict[str, Any]], +) -> BeaconResultsetsResponseBody: + """Build a resultSets response wrapper for record-level responses.""" + result_set = ResultsetInstance( + id=entity_type, + set_type=entity_type, + exists=len(results) > 0, + result_count=len(results), + results=results, + ) + return BeaconResultsetsResponseBody(result_sets=[result_set]) + + +def build_collections_response( + *, + collections: list[dict[str, Any]], +) -> BeaconCollectionsResponseBody: + """Build a collections response wrapper for collection endpoints (cohorts, datasets).""" + return BeaconCollectionsResponseBody(collections=collections) + + +def build_summary( + exists: bool, num_total_results: int | None = None +) -> BeaconSummaryResults: + """Build a response summary.""" + return BeaconSummaryResults(exists=exists, num_total_results=num_total_results) + + +def schema_for_entity(entity_type: str) -> list[SchemaReference]: + """Return schema list for an entity type.""" + return [get_schema_reference(entity_type)] + + +def filters_to_strings(filters: list[Any] | None) -> list[str]: + """Convert filter models to a list of string identifiers.""" + if not filters: + return [] + filter_strings: list[str] = [] + for f in filters: + if isinstance(f, dict): + value = f.get("id") + else: + value = getattr(f, "id", None) + if value: + filter_strings.append(str(value)) + return filter_strings diff --git a/src/beacon_api/api/runs.py b/src/beacon_api/api/runs.py index 405fb0d..897db7a 100644 --- a/src/beacon_api/api/runs.py +++ b/src/beacon_api/api/runs.py @@ -11,13 +11,16 @@ PaginationSkip, create_request_body_from_params, ) -from beacon_api.models.request import BeaconRequestBody, RequestedGranularity -from beacon_api.models.response import ( - BeaconResponseMeta, - BeaconResultsetsResponse, - BeaconSummaryResults, - ResultsetInstance, +from beacon_api.api.response_utils import ( + build_meta, + build_received_request_summary, + build_resultset_response, + build_summary, + filters_to_strings, + schema_for_entity, ) +from beacon_api.models.request import BeaconRequestBody, RequestedGranularity +from beacon_api.models.response import BeaconResultsetsResponse router = APIRouter(prefix="/runs", tags=["runs"]) @@ -53,70 +56,55 @@ async def list_runs( ) runs = await service.query(request_body) - meta = BeaconResponseMeta( - beacon_id="beacon-skeleton", - api_version="v2.0", + requested_schemas = schema_for_entity("run") + received_request_summary = build_received_request_summary( + requested_granularity="record", + filters=filters_to_strings(request_body.filters), + pagination={"skip": skip, "limit": limit}, + requested_schemas=requested_schemas, + ) + meta = build_meta( returned_granularity="record", - received_request_summary={ - "requested_granularity": "record", - "filters": ( - [f.model_dump() for f in request_body.filters] - if request_body.filters - else [] - ), - "pagination": {"skip": skip, "limit": limit}, - }, + received_request_summary=received_request_summary, + returned_schemas=requested_schemas, ) - - result_set = ResultsetInstance( - id="runs", - set_type="run", - exists=len(runs) > 0, - result_count=len(runs), + response = build_resultset_response( + entity_type="run", results=[r.model_dump() for r in runs], ) - - summary = BeaconSummaryResults( + summary = build_summary( exists=len(runs) > 0, num_total_results=len(runs), ) return { - "meta": meta.model_dump(), - "response_summary": summary.model_dump(), - "response": [result_set.model_dump()], - "info": None, - "beacon_error": None, + "meta": meta.model_dump(by_alias=True, exclude_none=True), + "responseSummary": summary.model_dump(by_alias=True, exclude_none=True), + "response": response.model_dump(by_alias=True, exclude_none=True), } except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) from None except NotImplementedError: # Return empty but valid response for unimplemented services - meta = BeaconResponseMeta( - beacon_id="beacon-skeleton", - api_version="v2.0", - returned_granularity="record", - received_request_summary={ - "requested_granularity": "record", - "filters": [], - "pagination": {"skip": skip, "limit": limit}, - }, + requested_schemas = schema_for_entity("run") + received_request_summary = build_received_request_summary( + requested_granularity="record", + filters=[], + pagination={"skip": skip, "limit": limit}, + requested_schemas=requested_schemas, ) - result_set = ResultsetInstance( - id="runs", - set_type="run", - exists=False, - result_count=0, - results=[], + meta = build_meta( + returned_granularity="record", + received_request_summary=received_request_summary, + returned_schemas=requested_schemas, ) - summary = BeaconSummaryResults(exists=False, num_total_results=0) + response = build_resultset_response(entity_type="run", results=[]) + summary = build_summary(exists=False, num_total_results=0) return { - "meta": meta.model_dump(), - "response_summary": summary.model_dump(), - "response": [result_set.model_dump()], - "info": None, - "beacon_error": None, + "meta": meta.model_dump(by_alias=True, exclude_none=True), + "responseSummary": summary.model_dump(by_alias=True, exclude_none=True), + "response": response.model_dump(by_alias=True, exclude_none=True), } @@ -147,93 +135,78 @@ async def query_runs( granularity = request_body.meta.requested_granularity # Create response metadata - meta = BeaconResponseMeta( - beacon_id="beacon-skeleton", - api_version="v2.0", + requested_schemas = schema_for_entity("run") + received_request_summary = build_received_request_summary( + requested_granularity=granularity.value, + filters=filters_to_strings(request_body.filters), + pagination=request_body.meta.pagination or {"skip": 0, "limit": 0}, + requested_schemas=requested_schemas, + include_resultset_responses=request_body.meta.include_resultset_responses, + ) + meta = build_meta( returned_granularity=granularity.value, - received_request_summary={ - "requested_granularity": granularity.value, - "filters": ( - [f.model_dump() for f in request_body.filters] - if request_body.filters - else [] - ), - }, + received_request_summary=received_request_summary, + returned_schemas=requested_schemas, ) if granularity == RequestedGranularity.BOOLEAN: exists = await service.exists(request_body) - summary = BeaconSummaryResults(exists=exists) + summary = build_summary(exists=exists) return { - "meta": meta.model_dump(), - "response_summary": summary.model_dump(), - "info": None, - "beacon_error": None, + "meta": meta.model_dump(by_alias=True, exclude_none=True), + "responseSummary": summary.model_dump(by_alias=True, exclude_none=True), } elif granularity == RequestedGranularity.COUNT: count = await service.count(request_body) - summary = BeaconSummaryResults(exists=count > 0, num_total_results=count) + summary = build_summary(exists=count > 0, num_total_results=count) return { - "meta": meta.model_dump(), - "response_summary": summary.model_dump(), - "info": None, - "beacon_error": None, + "meta": meta.model_dump(by_alias=True, exclude_none=True), + "responseSummary": summary.model_dump(by_alias=True, exclude_none=True), } else: # RECORD runs = await service.query(request_body) - result_set = ResultsetInstance( - id="runs", - set_type="run", - exists=len(runs) > 0, - result_count=len(runs), + response = build_resultset_response( + entity_type="run", results=[r.model_dump() for r in runs], ) - summary = BeaconSummaryResults( + summary = build_summary( exists=len(runs) > 0, num_total_results=len(runs), ) return { - "meta": meta.model_dump(), - "response_summary": summary.model_dump(), - "response": [result_set.model_dump()], + "meta": meta.model_dump(by_alias=True, exclude_none=True), + "responseSummary": summary.model_dump(by_alias=True, exclude_none=True), + "response": response.model_dump(by_alias=True, exclude_none=True), } except NotImplementedError: # Return empty but valid response for unimplemented services (beacon-verifier compliance) - meta = BeaconResponseMeta( - beacon_id="beacon-skeleton", - api_version="v2.0", + requested_schemas = schema_for_entity("run") + received_request_summary = build_received_request_summary( + requested_granularity=request_body.meta.requested_granularity.value, + filters=filters_to_strings(request_body.filters), + pagination=request_body.meta.pagination or {"skip": 0, "limit": 0}, + requested_schemas=requested_schemas, + include_resultset_responses=request_body.meta.include_resultset_responses, + ) + meta = build_meta( returned_granularity=request_body.meta.requested_granularity.value, - received_request_summary={ - "requested_granularity": request_body.meta.requested_granularity.value, - "filters": ( - [f.model_dump() for f in request_body.filters] - if request_body.filters - else [] - ), - }, + received_request_summary=received_request_summary, + returned_schemas=requested_schemas, ) - summary = BeaconSummaryResults(exists=False, num_total_results=0) + summary = build_summary(exists=False, num_total_results=0) if request_body.meta.requested_granularity == RequestedGranularity.RECORD: - result_set = ResultsetInstance( - id="runs", - set_type="run", - exists=False, - result_count=0, - results=[], - ) + response = build_resultset_response(entity_type="run", results=[]) return { - "meta": meta.model_dump(), - "response_summary": summary.model_dump(), - "response": [result_set.model_dump()], + "meta": meta.model_dump(by_alias=True, exclude_none=True), + "responseSummary": summary.model_dump(by_alias=True, exclude_none=True), + "response": response.model_dump(by_alias=True, exclude_none=True), } else: return { - "meta": meta.model_dump(), - "response_summary": summary.model_dump(), - "info": None, - "beacon_error": None, + "meta": meta.model_dump(by_alias=True, exclude_none=True), + "responseSummary": summary.model_dump(by_alias=True, exclude_none=True), } diff --git a/src/beacon_api/core/beacon_catalog.py b/src/beacon_api/core/beacon_catalog.py new file mode 100644 index 0000000..5781085 --- /dev/null +++ b/src/beacon_api/core/beacon_catalog.py @@ -0,0 +1,136 @@ +"""Beacon catalog helpers for configuration, entry types, and endpoint maps.""" + +from __future__ import annotations + +from typing import Any + +from beacon_api.core.config import Settings +from beacon_api.models.common import SchemaReference + +ENTRY_TYPES: dict[str, dict[str, str]] = { + "individual": { + "name": "Individual", + "schema": "tmp/bundled_schemas/individual.json", + "root_path": "/api/individuals", + "single_path": "/api/individuals/{id}", + }, + "biosample": { + "name": "Biosample", + "schema": "tmp/bundled_schemas/biosample.json", + "root_path": "/api/biosamples", + "single_path": "/api/biosamples/{id}", + }, + "genomicVariation": { + "name": "Genomic Variation", + "schema": "tmp/bundled_schemas/genomicVariation.json", + "root_path": "/api/g_variants", + "single_path": "/api/g_variants/{id}", + }, + "analysis": { + "name": "Analysis", + "schema": "tmp/bundled_schemas/analysis.json", + "root_path": "/api/analyses", + "single_path": None, + }, + "cohort": { + "name": "Cohort", + "schema": "tmp/bundled_schemas/cohort.json", + "root_path": "/api/cohorts", + "single_path": None, + }, + "dataset": { + "name": "Dataset", + "schema": "tmp/bundled_schemas/dataset.json", + "root_path": "/api/datasets", + "single_path": None, + }, + "run": { + "name": "Run", + "schema": "tmp/bundled_schemas/run.json", + "root_path": "/api/runs", + "single_path": None, + }, +} + + +def get_schema_reference(entity_type: str) -> SchemaReference: + """Return the schema reference for an entity type.""" + entry = ENTRY_TYPES[entity_type] + return SchemaReference(entity_type=entity_type, schema=entry["schema"]) + + +def get_entry_types() -> dict[str, Any]: + """Return entry type definitions for configuration and entry_types endpoints.""" + entry_types: dict[str, Any] = {} + for entry_type, entry in ENTRY_TYPES.items(): + entry_types[entry_type] = { + "id": entry_type, + "name": entry["name"], + "partOfSpecification": "Beacon v2.0", + "description": f"{entry['name']} entry type for Beacon v2", + "defaultSchema": { + "id": entry_type, + "name": entry["name"], + "referenceToSchemaDefinition": entry["schema"], + "schemaVersion": "2.0.0", + }, + "ontologyTermForThisType": { + "id": f"CUSTOM:{entry_type}", + "label": entry["name"], + }, + } + return entry_types + + +def get_configuration(settings: Settings) -> dict[str, Any]: + """Return the Beacon configuration response payload.""" + environment_map = { + "prod": "PROD", + "production": "PROD", + "test": "TEST", + "staging": "TEST", + "dev": "DEV", + "development": "DEV", + } + production_status = environment_map.get(settings.environment.lower(), "DEV") + + return { + "$schema": "https://raw.githubusercontent.com/ga4gh-beacon/beacon-v2/main/framework/json/configuration/beaconConfigurationSchema.json", + "maturityAttributes": { + "productionStatus": production_status, + }, + "securityAttributes": { + "defaultGranularity": "record", + "securityLevels": ["PUBLIC"], + }, + "entryTypes": get_entry_types(), + } + + +def get_entry_types_response() -> dict[str, Any]: + """Return the entry types response payload.""" + return { + "entryTypes": get_entry_types(), + } + + +def get_map_response(base_url: str) -> dict[str, Any]: + """Return the map response payload.""" + base_url = base_url.rstrip("/") + endpoint_sets: dict[str, Any] = {} + + for entry_type, entry in ENTRY_TYPES.items(): + root_url = f"{base_url}{entry['root_path']}" + single_path = entry.get("single_path") + endpoint_sets[entry_type] = { + "entryType": entry_type, + "rootUrl": root_url, + "openAPIEndpointsDefinition": f"{base_url}/openapi.json", + } + if single_path: + endpoint_sets[entry_type]["singleEntryUrl"] = f"{base_url}{single_path}" + + return { + "$schema": "https://raw.githubusercontent.com/ga4gh-beacon/beacon-v2/main/framework/json/configuration/beaconMapSchema.json", + "endpointSets": endpoint_sets, + } diff --git a/src/beacon_api/main.py b/src/beacon_api/main.py index f967eda..57c9a9c 100644 --- a/src/beacon_api/main.py +++ b/src/beacon_api/main.py @@ -12,10 +12,13 @@ analyses_router, biosamples_router, cohorts_router, + configuration_router, datasets_router, + entry_types_router, g_variations_router, individuals_router, info_router, + map_router, monitor_router, runs_router, ) @@ -72,6 +75,9 @@ def create_app() -> FastAPI: # Include routers app.include_router(info_router, prefix="/api") + app.include_router(configuration_router, prefix="/api") + app.include_router(map_router, prefix="/api") + app.include_router(entry_types_router, prefix="/api") app.include_router(individuals_router, prefix="/api") app.include_router(biosamples_router, prefix="/api") app.include_router(g_variations_router, prefix="/api") diff --git a/src/beacon_api/models/common.py b/src/beacon_api/models/common.py index 0ab8ab8..254d03a 100644 --- a/src/beacon_api/models/common.py +++ b/src/beacon_api/models/common.py @@ -54,20 +54,19 @@ class ListingQuery(BaseModel): class SchemaReference(BaseModel): """Reference to a schema definition.""" - schema_name: str = Field( + model_config = {"populate_by_name": True} + + entity_type: str = Field( ..., - description="Name of the schema", - examples=["beacon-individual-v2.0.0"], + alias="entityType", + description="Entity type associated with the schema", + examples=["individual", "biosample"], ) - schema_version: str = Field( + schema: str = Field( ..., - description="Version of the schema", - examples=["2.0.0"], - ) - schema_url: str | None = Field( - default=None, - description="URL to the schema definition", + description="Schema reference (URL or file path)", examples=[ - "https://raw.githubusercontent.com/ga4gh-beacon/beacon-v2/main/models/json/beacon-v2-default-model/individuals/defaultSchema.json" + "./ga4gh-beacon-dataset-v2.0.0", + "https://www.example.org/schemas/ga4gh-beacon-dataset-v2.0.0.json", ], ) diff --git a/src/beacon_api/models/common_types.py b/src/beacon_api/models/common_types.py new file mode 100644 index 0000000..1efd8f6 --- /dev/null +++ b/src/beacon_api/models/common_types.py @@ -0,0 +1,521 @@ +"""Common types for Beacon v2 models. + +These types are extracted from the Beacon v2 JSON schemas and provide +proper validation for nested structures used across entity models. + +Generated from beacon-v2 schemas with manual deduplication and cleanup. +""" + +from __future__ import annotations + +from datetime import date as date_type +from enum import StrEnum + +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field + + +class OntologyTerm(BaseModel): + """Ontology term with CURIE identifier. + + Used throughout Beacon for controlled vocabulary terms (sex, ethnicity, + disease codes, phenotype features, etc.). + """ + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str = Field( + ..., + pattern=r"^\w[^:]+:.+$", + description="CURIE identifier (e.g., 'HP:0001250', 'NCIT:C20197')", + examples=[ + "ga4gh:GA.01234abcde", + "DUO:0000004", + "orcid:0000-0003-3463-0775", + "PMID:15254584", + ], + ) + label: str | None = Field( + default=None, + description="Human-readable label for the term", + ) + + +class Age(BaseModel): + """Age as ISO8601 duration. + + Provenance: GA4GH Phenopackets v2 `Age` + """ + + model_config = ConfigDict(populate_by_name=True) + + iso8601duration: str | None = Field( + default=None, + description="Age as ISO8601 duration (e.g., P40Y10M05D)", + examples=["P32Y6M1D", "P25Y", "P3M"], + ) + + +class AgeRange(BaseModel): + """Age range with start and end. + + Provenance: GA4GH Phenopackets v2 `AgeRange` + """ + + model_config = ConfigDict(populate_by_name=True) + + start: Age | None = Field(default=None, description="Start of age range") + end: Age | None = Field(default=None, description="End of age range") + + +class GestationalAge(BaseModel): + """Gestational age in weeks and days. + + Provenance: GA4GH Phenopackets v2 + """ + + model_config = ConfigDict(populate_by_name=True) + + weeks: int = Field( + ..., description="Completed weeks of gestation", examples=[4, 33] + ) + days: int | None = Field( + default=None, + description="Days beyond completed weeks", + examples=[2, 4], + ) + + +class TimeInterval(BaseModel): + """Time interval with ISO8601 timestamps.""" + + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + start: AwareDatetime = Field( + ..., + description="Start time in ISO8601 format", + examples=["1999-08-05T17:21:00+01:00"], + ) + end: AwareDatetime = Field( + ..., + description="End time in ISO8601 format", + examples=["2002-09-21T02:37:00-08:00"], + ) + + +# Union type for time-related fields (ageOfOnset, onset, resolution, etc.) +TimeElement = ( + Age | AgeRange | GestationalAge | AwareDatetime | TimeInterval | OntologyTerm +) + + +class KaryotypicSex(StrEnum): + """Chromosomal sex of an individual.""" + + UNKNOWN_KARYOTYPE = "UNKNOWN_KARYOTYPE" + XX = "XX" + XY = "XY" + XO = "XO" + XXY = "XXY" + XXX = "XXX" + XXYY = "XXYY" + XXXY = "XXXY" + XXXX = "XXXX" + XYY = "XYY" + OTHER_KARYOTYPE = "OTHER_KARYOTYPE" + + +class ReferenceRange(BaseModel): + """Reference range for measurements.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + low: float = Field(..., description="Lower range end of normal", examples=[85]) + high: float = Field(..., description="Upper range end of normal", examples=[135]) + unit: OntologyTerm = Field(..., description="Unit of measurement") + + +class Quantity(BaseModel): + """Quantity with value, unit, and optional reference range. + + Provenance: GA4GH Phenopackets v2 `Quantity` + """ + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + value: float = Field(..., description="The value of the quantity") + unit: OntologyTerm = Field(..., description="Unit of measurement") + reference_range: ReferenceRange | None = Field( + default=None, + alias="referenceRange", + description="Normal range for this measurement", + ) + + +class TypedQuantity(BaseModel): + """Quantity with an associated type.""" + + model_config = ConfigDict(populate_by_name=True) + + quantity: Quantity = Field(..., description="The quantity value") + quantity_type: OntologyTerm = Field( + ..., + alias="quantityType", + description="Type of quantity being measured", + examples=[{"id": "NCIT:C25298", "label": "Systolic Blood Pressure"}], + ) + + +class ComplexValue(BaseModel): + """Complex measurement value with multiple typed quantities.""" + + model_config = ConfigDict(populate_by_name=True) + + typed_quantities: list[TypedQuantity] | None = Field( + default=None, + alias="typedQuantities", + description="List of quantities for complex measurements", + ) + + +class ExternalReference(BaseModel): + """Reference to external resource.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str | None = Field( + default=None, + description="External identifier", + examples=["PMID:34054918"], + ) + reference: str | None = Field( + default=None, + description="URL to the resource", + examples=["https://www.ncbi.nlm.nih.gov/pmc/articles/PMC8155688/"], + ) + notes: str | None = Field( + default=None, + description="Additional notes about the reference", + ) + + +class Evidence(BaseModel): + """Evidence for an assertion.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + evidence_code: OntologyTerm = Field( + ..., + alias="evidenceCode", + description="Evidence type ontology term", + examples=[ + { + "id": "ECO:0006017", + "label": "author statement from published clinical study", + } + ], + ) + reference: ExternalReference | None = Field( + default=None, + description="Source of the evidence", + ) + + +class Procedure(BaseModel): + """Clinical procedure or intervention. + + Provenance: GA4GH Phenopackets v2 `Procedure` + """ + + model_config = ConfigDict(populate_by_name=True) + + procedure_code: OntologyTerm = Field( + ..., + alias="procedureCode", + description="Procedure ontology term", + examples=[ + {"id": "MAXO:0001175", "label": "liver transplantation"}, + {"id": "OBI:0002654", "label": "needle biopsy"}, + ], + ) + body_site: OntologyTerm | None = Field( + default=None, + alias="bodySite", + description="Body site where procedure was performed", + examples=[{"id": "UBERON:0003403", "label": "Skin of forearm"}], + ) + date_of_procedure: date_type | None = Field( + default=None, + alias="dateOfProcedure", + description="Date of procedure in ISO8601 format", + examples=["2010-07-10"], + ) + age_at_procedure: TimeElement | None = Field( + default=None, + alias="ageAtProcedure", + description="Age when procedure was performed", + ) + + +class Disease(BaseModel): + """Disease diagnosed to an individual. + + Similarities to GA4GH Phenopackets v2 `Disease` + """ + + model_config = ConfigDict(populate_by_name=True) + + disease_code: OntologyTerm = Field( + ..., + alias="diseaseCode", + description="Disease ontology term", + examples=[ + {"id": "HP:0004789", "label": "lactose intolerance"}, + {"id": "OMIM:164400", "label": "Spinocerebellar ataxia 1"}, + ], + ) + age_of_onset: TimeElement | None = Field( + default=None, + alias="ageOfOnset", + description="Age or time when disease was first observed", + ) + stage: OntologyTerm | None = Field( + default=None, + description="Disease stage", + examples=[ + {"id": "OGMS:0000119", "label": "acute onset"}, + {"id": "OGMS:0000117", "label": "asymptomatic"}, + ], + ) + severity: OntologyTerm | None = Field( + default=None, + description="Disease severity", + examples=[ + {"id": "HP:0012828", "label": "Severe"}, + {"id": "HP:0012826", "label": "Moderate"}, + ], + ) + family_history: bool | None = Field( + default=None, + alias="familyHistory", + description="Presence of family history of this disease", + ) + notes: str | None = Field( + default=None, + description="Additional notes about the disease", + ) + + +class PhenotypicFeature(BaseModel): + """Phenotypic feature observed in an individual or biosample. + + Provenance: GA4GH Phenopackets v2 `PhenotypicFeature` + """ + + model_config = ConfigDict(populate_by_name=True) + + feature_type: OntologyTerm = Field( + ..., + alias="featureType", + description="Phenotype ontology term", + examples=[ + {"id": "HP:0000002", "label": "Abnormality of body height"}, + {"id": "HP:0012469", "label": "Infantile spasms"}, + ], + ) + excluded: bool | None = Field( + default=False, + description="True if the feature was looked for but not found", + ) + onset: TimeElement | None = Field( + default=None, + description="Age or time when feature was first observed", + ) + resolution: TimeElement | None = Field( + default=None, + description="Age or time when feature resolved", + ) + severity: OntologyTerm | None = Field( + default=None, + description="Severity of the phenotypic feature", + ) + modifiers: list[OntologyTerm] | None = Field( + default=None, + description="Modifier terms (e.g., laterality, severity qualifiers)", + ) + evidence: Evidence | None = Field( + default=None, + description="Evidence for this phenotypic feature", + ) + notes: str | None = Field( + default=None, + description="Additional notes", + ) + + +class Exposure(BaseModel): + """Environmental or other exposure.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + exposure_code: OntologyTerm = Field( + ..., + alias="exposureCode", + description="Exposure type ontology term", + examples=[ + {"id": "CHEBI:46661", "label": "asbestos"}, + {"id": "ENVO:21001217", "label": "X-ray radiation"}, + ], + ) + age_at_exposure: Age = Field( + ..., + alias="ageAtExposure", + description="Age at time of exposure", + ) + duration: str = Field( + ..., + description="Exposure duration in ISO8601 format", + examples=["P32Y6M1D"], + ) + unit: OntologyTerm = Field(..., description="Unit of exposure measurement") + value: float | None = Field( + default=None, + description="Quantification of the exposure", + ) + date: date_type | None = Field( + default=None, + description="Date of exposure in ISO8601 format", + ) + + +class DoseInterval(BaseModel): + """Dose interval for treatments.""" + + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + interval: TimeInterval = Field(..., description="Time interval for dosing") + quantity: Quantity = Field(..., description="Dose quantity") + schedule_frequency: OntologyTerm = Field( + ..., + alias="scheduleFrequency", + description="Dosing frequency", + examples=[{"id": "NCIT:C64496", "label": "Twice Daily"}], + ) + + +class Treatment(BaseModel): + """Treatment administered to an individual.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + treatment_code: OntologyTerm = Field( + ..., + alias="treatmentCode", + description="Treatment ontology term", + examples=[ + {"id": "NCIT:C287", "label": "Aspirin"}, + {"id": "NCIT:C62078", "label": "Tamoxifen"}, + ], + ) + age_at_onset: Age | None = Field( + default=None, + alias="ageAtOnset", + description="Age when treatment started", + ) + route_of_administration: OntologyTerm | None = Field( + default=None, + alias="routeOfAdministration", + description="Route of administration", + examples=[{"id": "NCIT:C38304", "label": "Topical"}], + ) + cumulative_dose: Quantity | None = Field( + default=None, + alias="cumulativeDose", + description="Total cumulative dose", + ) + dose_intervals: list[DoseInterval] | None = Field( + default=None, + alias="doseIntervals", + description="Dose intervals", + ) + + +class Measurement(BaseModel): + """Measurement performed on an individual or biosample. + + Provenance: GA4GH Phenopackets v2 `Measurement` + """ + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + assay_code: OntologyTerm = Field( + ..., + alias="assayCode", + description="Assay ontology term", + examples=[{"id": "LOINC:26515-7", "label": "Platelets [#/volume] in Blood"}], + ) + measurement_value: ComplexValue | Quantity | OntologyTerm = Field( + ..., + alias="measurementValue", + description="Result of the measurement", + ) + observation_moment: TimeElement | None = Field( + default=None, + alias="observationMoment", + description="Time when measurement was performed", + ) + procedure: Procedure | None = Field( + default=None, + description="Procedure used for measurement", + ) + date: date_type | None = Field( + default=None, + description="Date of measurement", + ) + notes: str | None = Field( + default=None, + description="Additional notes", + ) + + +class Member(BaseModel): + """Member of a pedigree.""" + + model_config = ConfigDict(populate_by_name=True) + + member_id: str = Field( + ..., + alias="memberId", + description="Identifier of the pedigree member", + examples=["Pedigree1001-m1", "Ind0012122"], + ) + role: OntologyTerm = Field( + ..., + description="Role in the pedigree", + examples=[ + {"id": "NCIT:C64435", "label": "Proband"}, + {"id": "NCIT:C96580", "label": "Biological Mother"}, + ], + ) + affected: bool = Field( + ..., + description="Whether member is affected by the disease", + ) + + +class Pedigree(BaseModel): + """Pedigree information for an individual.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str = Field(..., description="Pedigree identifier", examples=["Pedigree1001"]) + disease: Disease = Field(..., description="Disease tracked in this pedigree") + members: list[Member] = Field( + ..., + min_length=1, + description="Members of the pedigree", + ) + num_subjects: int | None = Field( + default=None, + alias="numSubjects", + description="Total number of subjects in pedigree", + ) diff --git a/src/beacon_api/models/entities.py b/src/beacon_api/models/entities.py index 09b761d..958ba47 100644 --- a/src/beacon_api/models/entities.py +++ b/src/beacon_api/models/entities.py @@ -4,37 +4,70 @@ from pydantic import BaseModel, Field +from beacon_api.models.common_types import ( + Disease, + Exposure, + KaryotypicSex, + Measurement, + OntologyTerm, + Pedigree, + PhenotypicFeature, + Procedure, + Treatment, +) + class Individual(BaseModel): """Individual/subject entity model.""" + model_config = {"populate_by_name": True} + id: str = Field(..., description="Individual identifier") - sex: str | None = Field( + sex: OntologyTerm | None = Field( default=None, - description="Sex of the individual", - examples=["MALE", "FEMALE", "OTHER_SEX", "UNKNOWN_SEX"], + description="Sex of the individual (ontology term)", ) - ethnicity: dict[str, Any] | None = Field( + karyotypic_sex: KaryotypicSex | None = Field( + default=None, + alias="karyotypicSex", + description="Chromosomal sex", + ) + ethnicity: OntologyTerm | None = Field( default=None, description="Ethnic background ontology term", ) - geographic_origin: dict[str, Any] | None = Field( + geographic_origin: OntologyTerm | None = Field( default=None, + alias="geographicOrigin", description="Geographic origin ontology term", ) - diseases: list[dict[str, Any]] | None = Field( + diseases: list[Disease] | None = Field( default=None, description="List of diseases associated with the individual", ) - phenotypic_features: list[dict[str, Any]] | None = Field( + phenotypic_features: list[PhenotypicFeature] | None = Field( default=None, + alias="phenotypicFeatures", description="List of phenotypic features", ) - interventions_or_procedures: list[dict[str, Any]] | None = Field( + interventions_or_procedures: list[Procedure] | None = Field( default=None, + alias="interventionsOrProcedures", description="List of interventions or procedures", ) - measures: list[dict[str, Any]] | None = Field( + exposures: list[Exposure] | None = Field( + default=None, + description="List of exposures", + ) + treatments: list[Treatment] | None = Field( + default=None, + description="List of treatments", + ) + pedigrees: list[Pedigree] | None = Field( + default=None, + description="Pedigree information", + ) + measurements: list[Measurement] | None = Field( default=None, description="List of measurements", ) @@ -47,62 +80,96 @@ class Individual(BaseModel): class Biosample(BaseModel): """Biosample entity model.""" + model_config = {"populate_by_name": True} + id: str = Field(..., description="Biosample identifier") individual_id: str | None = Field( default=None, + alias="individualId", description="Reference to the individual this biosample was derived from", ) - biosample_status: dict[str, Any] | None = Field( + biosample_status: OntologyTerm | None = Field( default=None, + alias="biosampleStatus", description="Status of the biosample", ) - sample_origin_type: dict[str, Any] | None = Field( + sample_origin_type: OntologyTerm | None = Field( default=None, + alias="sampleOriginType", description="Type of sample origin", ) - sample_origin_detail: dict[str, Any] | None = Field( + sample_origin_detail: OntologyTerm | None = Field( default=None, + alias="sampleOriginDetail", description="Detailed information about sample origin", ) collection_date: str | None = Field( default=None, + alias="collectionDate", description="Date when the sample was collected", ) collection_moment: str | None = Field( default=None, + alias="collectionMoment", description="Moment in time when sample was collected", ) - obtained_from_id: str | None = Field( + obtention_procedure: Procedure | None = Field( default=None, - description="ID of the biosample this sample was obtained from", + alias="obtentionProcedure", + description="Procedure used to obtain the sample", ) - phenotypic_features: list[dict[str, Any]] | None = Field( + phenotypic_features: list[PhenotypicFeature] | None = Field( default=None, + alias="phenotypicFeatures", description="List of phenotypic features", ) - measurements: list[dict[str, Any]] | None = Field( + measurements: list[Measurement] | None = Field( default=None, description="List of measurements", ) - pathological_stage: dict[str, Any] | None = Field( + histological_diagnosis: OntologyTerm | None = Field( + default=None, + alias="histologicalDiagnosis", + description="Histological diagnosis (ontology term)", + ) + pathological_stage: OntologyTerm | None = Field( default=None, + alias="pathologicalStage", description="Pathological stage of the sample", ) - tumor_progression: dict[str, Any] | None = Field( + pathological_tnm_finding: list[OntologyTerm] | None = Field( + default=None, + alias="pathologicalTnmFinding", + description="TNM pathological findings", + ) + tumor_progression: OntologyTerm | None = Field( default=None, + alias="tumorProgression", description="Tumor progression status", ) - tumor_grade: dict[str, Any] | None = Field( + tumor_grade: OntologyTerm | None = Field( default=None, + alias="tumorGrade", description="Tumor grade", ) - diagnostic_markers: list[dict[str, Any]] | None = Field( + diagnostic_markers: list[OntologyTerm] | None = Field( default=None, + alias="diagnosticMarkers", description="List of diagnostic markers", ) - procedure: dict[str, Any] | None = Field( + sample_processing: OntologyTerm | None = Field( default=None, - description="Procedure used to collect the sample", + alias="sampleProcessing", + description="Sample processing information", + ) + sample_storage: OntologyTerm | None = Field( + default=None, + alias="sampleStorage", + description="Sample storage conditions", + ) + notes: str | None = Field( + default=None, + description="Additional notes about the biosample", ) info: dict[str, Any] | None = Field( default=None, @@ -164,33 +231,46 @@ class GenomicVariation(BaseModel): class Analysis(BaseModel): """Analysis entity model.""" + model_config = {"populate_by_name": True} + id: str = Field(..., description="Analysis identifier") - analysis_type: dict[str, Any] | None = Field( + analysis_date: str | None = Field( default=None, - description="Type of analysis performed", + alias="analysisDate", + description="Date when the analysis was performed", ) pipeline_name: str | None = Field( default=None, + alias="pipelineName", description="Name of the analysis pipeline", ) pipeline_ref: str | None = Field( default=None, + alias="pipelineRef", description="Reference to the pipeline (e.g., URL, DOI)", ) - analysis_date: str | None = Field( + aligner: str | None = Field( default=None, - description="Date when the analysis was performed", + description="Aligner used in the analysis", + ) + variant_caller: str | None = Field( + default=None, + alias="variantCaller", + description="Variant caller used in the analysis", ) biosample_id: str | None = Field( default=None, + alias="biosampleId", description="Reference to the biosample analyzed", ) individual_id: str | None = Field( default=None, + alias="individualId", description="Reference to the individual analyzed", ) run_id: str | None = Field( default=None, + alias="runId", description="Reference to the sequencing run", ) info: dict[str, Any] | None = Field( @@ -202,60 +282,68 @@ class Analysis(BaseModel): class Cohort(BaseModel): """Cohort entity model.""" + model_config = {"populate_by_name": True} + id: str = Field(..., description="Cohort identifier") name: str = Field(..., description="Cohort name") cohort_type: str | None = Field( default=None, + alias="cohortType", description="Type of cohort", examples=["study-defined", "beacon-defined"], ) + cohort_design: OntologyTerm | None = Field( + default=None, + alias="cohortDesign", + description="Cohort design (ontology term)", + ) cohort_size: int | None = Field( default=None, + alias="cohortSize", description="Number of individuals in the cohort", ge=0, ) - cohort_data_types: list[dict[str, Any]] | None = Field( + cohort_data_types: list[OntologyTerm] | None = Field( default=None, + alias="cohortDataTypes", description="Types of data available for this cohort", ) collection_events: list[dict[str, Any]] | None = Field( default=None, + alias="collectionEvents", description="Collection events for this cohort", ) - inclusion_criteria: dict[str, Any] | None = Field( + inclusion_criteria: OntologyTerm | None = Field( default=None, + alias="inclusionCriteria", description="Inclusion criteria for the cohort", ) - exclusion_criteria: dict[str, Any] | None = Field( + exclusion_criteria: OntologyTerm | None = Field( default=None, + alias="exclusionCriteria", description="Exclusion criteria for the cohort", ) - info: dict[str, Any] | None = Field( - default=None, - description="Additional information", - ) class Dataset(BaseModel): """Dataset entity model.""" + model_config = {"populate_by_name": True} + id: str = Field(..., description="Dataset identifier") name: str = Field(..., description="Dataset name") description: str | None = Field( default=None, description="Dataset description", ) - assembly_id: str | None = Field( - default=None, - description="Reference genome assembly", - examples=["GRCh38", "GRCh37"], - ) create_date_time: str | None = Field( default=None, + alias="createDateTime", description="Dataset creation date and time", ) update_date_time: str | None = Field( default=None, + alias="updateDateTime", description="Dataset last update date and time", ) version: str | None = Field( @@ -264,10 +352,12 @@ class Dataset(BaseModel): ) external_url: str | None = Field( default=None, + alias="externalUrl", description="External URL for the dataset", ) - data_use_conditions: dict[str, Any] | None = Field( + data_use_conditions: OntologyTerm | None = Field( default=None, + alias="dataUseConditions", description="Data use conditions and restrictions", ) info: dict[str, Any] | None = Field( @@ -279,35 +369,43 @@ class Dataset(BaseModel): class Run(BaseModel): """Sequencing run entity model.""" + model_config = {"populate_by_name": True} + id: str = Field(..., description="Run identifier") biosample_id: str | None = Field( default=None, + alias="biosampleId", description="Reference to the biosample sequenced", ) individual_id: str | None = Field( default=None, + alias="individualId", description="Reference to the individual sequenced", ) run_date: str | None = Field( default=None, + alias="runDate", description="Date when the run was performed", ) - library_source: str | None = Field( + library_source: OntologyTerm | None = Field( default=None, - description="Source of the library", - examples=["GENOMIC", "TRANSCRIPTOMIC", "METAGENOMIC"], + alias="librarySource", + description="Source of the library (ontology term)", ) library_selection: str | None = Field( default=None, + alias="librarySelection", description="Library selection method", ) library_strategy: str | None = Field( default=None, + alias="libraryStrategy", description="Library strategy", examples=["WGS", "WXS", "RNA-Seq", "ChIP-Seq"], ) library_layout: str | None = Field( default=None, + alias="libraryLayout", description="Library layout", examples=["SINGLE", "PAIRED"], ) @@ -316,9 +414,10 @@ class Run(BaseModel): description="Sequencing platform", examples=["ILLUMINA", "PACBIO", "OXFORD_NANOPORE"], ) - platform_model: str | None = Field( + platform_model: OntologyTerm | None = Field( default=None, - description="Specific platform model", + alias="platformModel", + description="Specific platform model (ontology term)", ) info: dict[str, Any] | None = Field( default=None, diff --git a/src/beacon_api/models/request.py b/src/beacon_api/models/request.py index d998535..f0eb3fd 100644 --- a/src/beacon_api/models/request.py +++ b/src/beacon_api/models/request.py @@ -1,11 +1,11 @@ """Beacon v2 request models.""" -from enum import Enum +from enum import StrEnum from pydantic import BaseModel, Field -class RequestedGranularity(str, Enum): +class RequestedGranularity(StrEnum): """Requested granularity of the response.""" BOOLEAN = "boolean" diff --git a/src/beacon_api/models/response.py b/src/beacon_api/models/response.py index 1af4839..2680b14 100644 --- a/src/beacon_api/models/response.py +++ b/src/beacon_api/models/response.py @@ -5,11 +5,14 @@ from pydantic import BaseModel, Field from beacon_api.models.common import BeaconError, SchemaReference +from beacon_api.models.common_types import OntologyTerm class BeaconOrganization(BaseModel): """Organization information.""" + model_config = {"populate_by_name": True} + id: str = Field(..., description="Organization identifier") name: str = Field(..., description="Organization name") description: str | None = Field( @@ -19,13 +22,23 @@ class BeaconOrganization(BaseModel): address: str | None = Field(default=None, description="Organization address") welcome_url: str | None = Field( default=None, + alias="welcomeUrl", description="Welcome page URL", ) contact_url: str | None = Field( default=None, + alias="contactUrl", description="Contact information URL", ) - logo_url: str | None = Field(default=None, description="Organization logo URL") + logo_url: str | None = Field( + default=None, + alias="logoUrl", + description="Organization logo URL", + ) + info: dict[str, Any] = Field( + default_factory=dict, + description="Additional organization metadata", + ) class BeaconInformationalResponse(BaseModel): @@ -78,6 +91,10 @@ class BeaconInformationalResponse(BaseModel): alias="updateDateTime", description="Beacon last update date and time", ) + info: dict[str, Any] = Field( + default_factory=dict, + description="Additional beacon metadata", + ) class BeaconInfoResponse(BaseModel): @@ -96,12 +113,15 @@ class BeaconInfoResponse(BaseModel): class BeaconSummaryResults(BaseModel): """Summary of Beacon query results.""" + model_config = {"populate_by_name": True} + exists: bool = Field( ..., description="Whether any results were found", ) num_total_results: int | None = Field( default=None, + alias="numTotalResults", description="Total number of results", ) @@ -109,27 +129,32 @@ class BeaconSummaryResults(BaseModel): class ResultsetInstance(BaseModel): """Single result instance from a Beacon query.""" + model_config = {"populate_by_name": True} + id: str = Field(..., description="Result identifier") set_type: str = Field( ..., + alias="setType", description="Type of the result set", examples=["dataset", "individual", "biosample"], ) exists: bool = Field(..., description="Whether results exist in this set") result_count: int | None = Field( default=None, + alias="resultsCount", description="Number of results in this set", ) - results: list[dict[str, Any]] | None = Field( - default=None, + results: list[dict[str, Any]] = Field( + default_factory=list, description="Actual result records", ) - info: dict[str, Any] | None = Field( - default=None, + info: dict[str, Any] = Field( + default_factory=dict, description="Additional information about the result set", ) - results_handover: list[dict[str, Any]] | None = Field( - default=None, + results_handover: list[dict[str, Any]] = Field( + default_factory=list, + alias="resultsHandovers", description="Handover information for accessing full results", ) @@ -137,37 +162,70 @@ class ResultsetInstance(BaseModel): class BeaconResponseMeta(BaseModel): """Beacon response metadata.""" - beacon_id: str = Field(..., description="Beacon identifier") - api_version: str = Field(..., description="Beacon API version") + model_config = {"populate_by_name": True} + + beacon_id: str = Field(..., alias="beaconId", description="Beacon identifier") + api_version: str = Field(..., alias="apiVersion", description="Beacon API version") returned_granularity: str = Field( ..., + alias="returnedGranularity", description="Granularity of the returned data", examples=["boolean", "count", "record"], ) received_request_summary: dict[str, Any] = Field( ..., + alias="receivedRequestSummary", description="Summary of the received request", ) - returned_schemas: list[SchemaReference] | None = Field( - default=None, + returned_schemas: list[SchemaReference] = Field( + default_factory=list, + alias="returnedSchemas", description="Schemas used in the response", ) + test_mode: bool = Field( + default=False, + alias="testMode", + description="Indicates if the request was executed in test mode", + ) + + +class BeaconHandover(BaseModel): + """Handover for linking to external data sources.""" + + model_config = {"populate_by_name": True} + + handover_type: OntologyTerm = Field( + ..., + alias="handoverType", + description="Handover type (ontology term)", + ) + url: str = Field(..., description="URL to access the data") + note: str | None = Field(default=None, description="Additional note") class BeaconBooleanResponse(BaseModel): """Boolean response from Beacon query.""" + model_config = {"populate_by_name": True} + meta: BeaconResponseMeta = Field(..., description="Response metadata") response_summary: BeaconSummaryResults = Field( ..., + alias="responseSummary", description="Summary of query results", ) - info: dict[str, Any] | None = Field( - default=None, + info: dict[str, Any] = Field( + default_factory=dict, description="Additional information", ) + beacon_handovers: list[BeaconHandover] = Field( + default_factory=list, + alias="beaconHandovers", + description="Handovers for accessing data externally", + ) beacon_error: BeaconError | None = Field( default=None, + alias="beaconError", description="Error information if query failed", ) @@ -175,38 +233,105 @@ class BeaconBooleanResponse(BaseModel): class BeaconCountResponse(BaseModel): """Count response from Beacon query.""" + model_config = {"populate_by_name": True} + meta: BeaconResponseMeta = Field(..., description="Response metadata") response_summary: BeaconSummaryResults = Field( ..., + alias="responseSummary", description="Summary of query results", ) - info: dict[str, Any] | None = Field( - default=None, + info: dict[str, Any] = Field( + default_factory=dict, description="Additional information", ) + beacon_handovers: list[BeaconHandover] = Field( + default_factory=list, + alias="beaconHandovers", + description="Handovers for accessing data externally", + ) beacon_error: BeaconError | None = Field( default=None, + alias="beaconError", description="Error information if query failed", ) +class BeaconResultsetsResponseBody(BaseModel): + """Resultsets container for record-level responses.""" + + model_config = {"populate_by_name": True} + + result_sets: list[ResultsetInstance] = Field( + default_factory=list, + alias="resultSets", + description="Result sets", + ) + + class BeaconResultsetsResponse(BaseModel): """Full results response from Beacon query.""" + model_config = {"populate_by_name": True} + meta: BeaconResponseMeta = Field(..., description="Response metadata") response_summary: BeaconSummaryResults = Field( ..., + alias="responseSummary", description="Summary of query results", ) - response: list[ResultsetInstance] | None = Field( + response: BeaconResultsetsResponseBody | None = Field( default=None, description="Result sets", ) - info: dict[str, Any] | None = Field( - default=None, + info: dict[str, Any] = Field( + default_factory=dict, description="Additional information", ) + beacon_handovers: list[BeaconHandover] = Field( + default_factory=list, + alias="beaconHandovers", + description="Handovers for accessing data externally", + ) beacon_error: BeaconError | None = Field( default=None, + alias="beaconError", description="Error information if query failed", ) + + +class BeaconCollectionsResponseBody(BaseModel): + """Collections container for collections responses.""" + + model_config = {"populate_by_name": True} + + collections: list[dict[str, Any]] = Field( + default_factory=list, + description="Collections returned by the beacon", + ) + + +class BeaconCollectionsResponse(BaseModel): + """Collections response from Beacon.""" + + model_config = {"populate_by_name": True} + + meta: BeaconResponseMeta = Field(..., description="Response metadata") + response_summary: BeaconSummaryResults = Field( + ..., + alias="responseSummary", + description="Summary of query results", + ) + response: BeaconCollectionsResponseBody = Field( + ..., + description="Collections payload", + ) + info: dict[str, Any] = Field( + default_factory=dict, + description="Additional information", + ) + beacon_handovers: list[BeaconHandover] = Field( + default_factory=list, + alias="beaconHandovers", + description="Handovers for accessing data externally", + ) diff --git a/uv.lock b/uv.lock index 6474854..a4a538b 100644 --- a/uv.lock +++ b/uv.lock @@ -25,6 +25,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, ] +[[package]] +name = "argcomplete" +version = "3.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/61/0b9ae6399dd4a58d8c1b1dc5a27d6f2808023d0b5dd3104bb99f45a33ff6/argcomplete-3.6.3.tar.gz", hash = "sha256:62e8ed4fd6a45864acc8235409461b72c9a28ee785a2011cc5eb78318786c89c", size = 73754, upload-time = "2025-10-20T03:33:34.741Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/f5/9373290775639cb67a2fce7f629a1c240dce9f12fe927bc32b2736e16dfc/argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce", size = 43846, upload-time = "2025-10-20T03:33:33.021Z" }, +] + [[package]] name = "beacon-api" version = "0.1.0" @@ -49,6 +58,7 @@ dev = [ [package.dev-dependencies] dev = [ + { name = "datamodel-code-generator" }, { name = "httpx" }, { name = "mypy" }, { name = "pytest" }, @@ -75,6 +85,7 @@ provides-extras = ["dev"] [package.metadata.requires-dev] dev = [ + { name = "datamodel-code-generator", specifier = ">=0.53.0" }, { name = "httpx", specifier = ">=0.27.0" }, { name = "mypy", specifier = ">=1.13.0" }, { name = "pytest", specifier = ">=8.3.0" }, @@ -83,6 +94,43 @@ dev = [ { name = "ruff", specifier = ">=0.7.0" }, ] +[[package]] +name = "black" +version = "25.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "pytokens" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/d9/07b458a3f1c525ac392b5edc6b191ff140b596f9d77092429417a54e249d/black-25.12.0.tar.gz", hash = "sha256:8d3dd9cea14bff7ddc0eb243c811cdb1a011ebb4800a5f0335a01a68654796a7", size = 659264, upload-time = "2025-12-08T01:40:52.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/ad/7ac0d0e1e0612788dbc48e62aef8a8e8feffac7eb3d787db4e43b8462fa8/black-25.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d0cfa263e85caea2cff57d8f917f9f51adae8e20b610e2b23de35b5b11ce691a", size = 1877003, upload-time = "2025-12-08T01:43:29.967Z" }, + { url = "https://files.pythonhosted.org/packages/e8/dd/a237e9f565f3617a88b49284b59cbca2a4f56ebe68676c1aad0ce36a54a7/black-25.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a2f578ae20c19c50a382286ba78bfbeafdf788579b053d8e4980afb079ab9be", size = 1712639, upload-time = "2025-12-08T01:52:46.756Z" }, + { url = "https://files.pythonhosted.org/packages/12/80/e187079df1ea4c12a0c63282ddd8b81d5107db6d642f7d7b75a6bcd6fc21/black-25.12.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e1b65634b0e471d07ff86ec338819e2ef860689859ef4501ab7ac290431f9b", size = 1758143, upload-time = "2025-12-08T01:45:29.137Z" }, + { url = "https://files.pythonhosted.org/packages/93/b5/3096ccee4f29dc2c3aac57274326c4d2d929a77e629f695f544e159bfae4/black-25.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:a3fa71e3b8dd9f7c6ac4d818345237dfb4175ed3bf37cd5a581dbc4c034f1ec5", size = 1420698, upload-time = "2025-12-08T01:45:53.379Z" }, + { url = "https://files.pythonhosted.org/packages/7e/39/f81c0ffbc25ffbe61c7d0385bf277e62ffc3e52f5ee668d7369d9854fadf/black-25.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:51e267458f7e650afed8445dc7edb3187143003d52a1b710c7321aef22aa9655", size = 1229317, upload-time = "2025-12-08T01:46:35.606Z" }, + { url = "https://files.pythonhosted.org/packages/d1/bd/26083f805115db17fda9877b3c7321d08c647df39d0df4c4ca8f8450593e/black-25.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:31f96b7c98c1ddaeb07dc0f56c652e25bdedaac76d5b68a059d998b57c55594a", size = 1924178, upload-time = "2025-12-08T01:49:51.048Z" }, + { url = "https://files.pythonhosted.org/packages/89/6b/ea00d6651561e2bdd9231c4177f4f2ae19cc13a0b0574f47602a7519b6ca/black-25.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:05dd459a19e218078a1f98178c13f861fe6a9a5f88fc969ca4d9b49eb1809783", size = 1742643, upload-time = "2025-12-08T01:49:59.09Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f3/360fa4182e36e9875fabcf3a9717db9d27a8d11870f21cff97725c54f35b/black-25.12.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1f68c5eff61f226934be6b5b80296cf6939e5d2f0c2f7d543ea08b204bfaf59", size = 1800158, upload-time = "2025-12-08T01:44:27.301Z" }, + { url = "https://files.pythonhosted.org/packages/f8/08/2c64830cb6616278067e040acca21d4f79727b23077633953081c9445d61/black-25.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:274f940c147ddab4442d316b27f9e332ca586d39c85ecf59ebdea82cc9ee8892", size = 1426197, upload-time = "2025-12-08T01:45:51.198Z" }, + { url = "https://files.pythonhosted.org/packages/d4/60/a93f55fd9b9816b7432cf6842f0e3000fdd5b7869492a04b9011a133ee37/black-25.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:169506ba91ef21e2e0591563deda7f00030cb466e747c4b09cb0a9dae5db2f43", size = 1237266, upload-time = "2025-12-08T01:45:10.556Z" }, + { url = "https://files.pythonhosted.org/packages/c8/52/c551e36bc95495d2aa1a37d50566267aa47608c81a53f91daa809e03293f/black-25.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a05ddeb656534c3e27a05a29196c962877c83fa5503db89e68857d1161ad08a5", size = 1923809, upload-time = "2025-12-08T01:46:55.126Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f7/aac9b014140ee56d247e707af8db0aae2e9efc28d4a8aba92d0abd7ae9d1/black-25.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9ec77439ef3e34896995503865a85732c94396edcc739f302c5673a2315e1e7f", size = 1742384, upload-time = "2025-12-08T01:49:37.022Z" }, + { url = "https://files.pythonhosted.org/packages/74/98/38aaa018b2ab06a863974c12b14a6266badc192b20603a81b738c47e902e/black-25.12.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e509c858adf63aa61d908061b52e580c40eae0dfa72415fa47ac01b12e29baf", size = 1798761, upload-time = "2025-12-08T01:46:05.386Z" }, + { url = "https://files.pythonhosted.org/packages/16/3a/a8ac542125f61574a3f015b521ca83b47321ed19bb63fe6d7560f348bfe1/black-25.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:252678f07f5bac4ff0d0e9b261fbb029fa530cfa206d0a636a34ab445ef8ca9d", size = 1429180, upload-time = "2025-12-08T01:45:34.903Z" }, + { url = "https://files.pythonhosted.org/packages/e6/2d/bdc466a3db9145e946762d52cd55b1385509d9f9004fec1c97bdc8debbfb/black-25.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:bc5b1c09fe3c931ddd20ee548511c64ebf964ada7e6f0763d443947fd1c603ce", size = 1239350, upload-time = "2025-12-08T01:46:09.458Z" }, + { url = "https://files.pythonhosted.org/packages/35/46/1d8f2542210c502e2ae1060b2e09e47af6a5e5963cb78e22ec1a11170b28/black-25.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0a0953b134f9335c2434864a643c842c44fba562155c738a2a37a4d61f00cad5", size = 1917015, upload-time = "2025-12-08T01:53:27.987Z" }, + { url = "https://files.pythonhosted.org/packages/41/37/68accadf977672beb8e2c64e080f568c74159c1aaa6414b4cd2aef2d7906/black-25.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2355bbb6c3b76062870942d8cc450d4f8ac71f9c93c40122762c8784df49543f", size = 1741830, upload-time = "2025-12-08T01:54:36.861Z" }, + { url = "https://files.pythonhosted.org/packages/ac/76/03608a9d8f0faad47a3af3a3c8c53af3367f6c0dd2d23a84710456c7ac56/black-25.12.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9678bd991cc793e81d19aeeae57966ee02909877cb65838ccffef24c3ebac08f", size = 1791450, upload-time = "2025-12-08T01:44:52.581Z" }, + { url = "https://files.pythonhosted.org/packages/06/99/b2a4bd7dfaea7964974f947e1c76d6886d65fe5d24f687df2d85406b2609/black-25.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:97596189949a8aad13ad12fcbb4ae89330039b96ad6742e6f6b45e75ad5cfd83", size = 1452042, upload-time = "2025-12-08T01:46:13.188Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7c/d9825de75ae5dd7795d007681b752275ea85a1c5d83269b4b9c754c2aaab/black-25.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:778285d9ea197f34704e3791ea9404cd6d07595745907dd2ce3da7a13627b29b", size = 1267446, upload-time = "2025-12-08T01:46:14.497Z" }, + { url = "https://files.pythonhosted.org/packages/68/11/21331aed19145a952ad28fca2756a1433ee9308079bd03bd898e903a2e53/black-25.12.0-py3-none-any.whl", hash = "sha256:48ceb36c16dbc84062740049eef990bb2ce07598272e673c17d1a7720c71c828", size = 206191, upload-time = "2025-12-08T01:40:50.963Z" }, +] + [[package]] name = "certifi" version = "2025.10.5" @@ -205,6 +253,27 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] +[[package]] +name = "datamodel-code-generator" +version = "0.53.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argcomplete" }, + { name = "black" }, + { name = "genson" }, + { name = "inflect" }, + { name = "isort" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "tomli", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/65/3802abca0291263862a16e032e984e61e4d0d30a344d9be97815721d64ff/datamodel_code_generator-0.53.0.tar.gz", hash = "sha256:af46b57ad78e6435873132c52843ef0ec7b768a591d3b9917d3409dfc1ab1c90", size = 809949, upload-time = "2026-01-12T18:14:05.459Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/43/5dbb6fe09842e10062f94016ccb48c9613f2443253866de3d7b815713b4d/datamodel_code_generator-0.53.0-py3-none-any.whl", hash = "sha256:d1cc2abe79f99b8208c363f5f4b603c29290327ff4e3219a08c0fff45f42aff4", size = 258912, upload-time = "2026-01-12T18:14:02.737Z" }, +] + [[package]] name = "fastapi" version = "0.119.1" @@ -219,6 +288,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b1/26/e6d959b4ac959fdb3e9c4154656fc160794db6af8e64673d52759456bf07/fastapi-0.119.1-py3-none-any.whl", hash = "sha256:0b8c2a2cce853216e150e9bd4faaed88227f8eb37de21cb200771f491586a27f", size = 108123, upload-time = "2025-10-20T11:30:26.185Z" }, ] +[[package]] +name = "genson" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c5/cf/2303c8ad276dcf5ee2ad6cf69c4338fd86ef0f471a5207b069adf7a393cf/genson-1.3.0.tar.gz", hash = "sha256:e02db9ac2e3fd29e65b5286f7135762e2cd8a986537c075b06fc5f1517308e37", size = 34919, upload-time = "2024-05-15T22:08:49.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/5c/e226de133afd8bb267ec27eead9ae3d784b95b39a287ed404caab39a5f50/genson-1.3.0-py3-none-any.whl", hash = "sha256:468feccd00274cc7e4c09e84b08704270ba8d95232aa280f65b986139cec67f7", size = 21470, upload-time = "2024-05-15T22:08:47.056Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -301,6 +379,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "inflect" +version = "7.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, + { name = "typeguard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/c6/943357d44a21fd995723d07ccaddd78023eace03c1846049a2645d4324a3/inflect-7.5.0.tar.gz", hash = "sha256:faf19801c3742ed5a05a8ce388e0d8fe1a07f8d095c82201eb904f5d27ad571f", size = 73751, upload-time = "2024-12-28T17:11:18.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/eb/427ed2b20a38a4ee29f24dbe4ae2dafab198674fe9a85e3d6adf9e5f5f41/inflect-7.5.0-py3-none-any.whl", hash = "sha256:2aea70e5e70c35d8350b8097396ec155ffd68def678c7ff97f51aa69c1d92344", size = 35197, upload-time = "2024-12-28T17:11:15.931Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -310,6 +401,110 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "isort" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/53/4f3c058e3bace40282876f9b553343376ee687f3c35a525dc79dbd450f88/isort-7.0.0.tar.gz", hash = "sha256:5513527951aadb3ac4292a41a16cbc50dd1642432f5e8c20057d414bdafb4187", size = 805049, upload-time = "2025-10-11T13:30:59.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/ed/e3705d6d02b4f7aea715a353c8ce193efd0b5db13e204df895d38734c244/isort-7.0.0-py3-none-any.whl", hash = "sha256:1bcabac8bc3c36c7fb7b98a76c8abb18e0f841a3ba81decac7691008592499c1", size = 94672, upload-time = "2025-10-11T13:30:57.665Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "more-itertools" +version = "10.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" }, +] + [[package]] name = "mypy" version = "1.18.2" @@ -375,6 +570,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, ] +[[package]] +name = "platformdirs" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -567,6 +771,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, ] +[[package]] +name = "pytokens" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/92/790ebe03f07b57e53b10884c329b9a1a308648fc083a6d4a39a10a28c8fc/pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440", size = 160864, upload-time = "2026-01-30T01:02:57.882Z" }, + { url = "https://files.pythonhosted.org/packages/13/25/a4f555281d975bfdd1eba731450e2fe3a95870274da73fb12c40aeae7625/pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc", size = 248565, upload-time = "2026-01-30T01:02:59.912Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/bc0394b4ad5b1601be22fa43652173d47e4c9efbf0044c62e9a59b747c56/pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d", size = 260824, upload-time = "2026-01-30T01:03:01.471Z" }, + { url = "https://files.pythonhosted.org/packages/4e/54/3e04f9d92a4be4fc6c80016bc396b923d2a6933ae94b5f557c939c460ee0/pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16", size = 264075, upload-time = "2026-01-30T01:03:04.143Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1b/44b0326cb5470a4375f37988aea5d61b5cc52407143303015ebee94abfd6/pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6", size = 103323, upload-time = "2026-01-30T01:03:05.412Z" }, + { url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663, upload-time = "2026-01-30T01:03:06.473Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" }, + { url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" }, + { url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" }, + { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" }, + { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" }, + { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, + { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, + { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" }, + { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" }, + { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" }, + { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -719,6 +957,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, ] +[[package]] +name = "typeguard" +version = "4.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/68/71c1a15b5f65f40e91b65da23b8224dad41349894535a97f63a52e462196/typeguard-4.4.4.tar.gz", hash = "sha256:3a7fd2dffb705d4d0efaed4306a704c89b9dee850b688f060a8b1615a79e5f74", size = 75203, upload-time = "2025-06-18T09:56:07.624Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/a9/e3aee762739c1d7528da1c3e06d518503f8b6c439c35549b53735ba52ead/typeguard-4.4.4-py3-none-any.whl", hash = "sha256:b5f562281b6bfa1f5492470464730ef001646128b180769880468bd84b68b09e", size = 34874, upload-time = "2025-06-18T09:56:05.999Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0"