diff --git a/.gitignore b/.gitignore index d69167e9d..ac56a2191 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ gha-creds-*.json ## vscode settings /.vscode *.db.init.lock + +# Hypothesis example database +.hypothesis/ diff --git a/changelog.d/remove-v2-household-list.removed.md b/changelog.d/remove-v2-household-list.removed.md new file mode 100644 index 000000000..2b328bde6 --- /dev/null +++ b/changelog.d/remove-v2-household-list.removed.md @@ -0,0 +1 @@ +Removed `GET /v2/households`, which listed stored households without a credential. Creating a household and reading one by ID are unchanged. diff --git a/docs/engineering/migration-contracts.md b/docs/engineering/migration-contracts.md index f39a5e369..e16ea8f72 100644 --- a/docs/engineering/migration-contracts.md +++ b/docs/engineering/migration-contracts.md @@ -8,7 +8,7 @@ Generated from `policyengine_api/migration_registry.py` and `tests/contract/regi | --- | ---: | | route group count | 9 | | workflow count | 16 | -| request count | 89 | +| request count | 88 | | db entity count | 6 | | sim flow count | 3 | @@ -98,7 +98,6 @@ Generated from `policyengine_api/migration_registry.py` and `tests/contract/regi | --- | --- | ---: | --- | --- | --- | | `POST` | `/v2/households?country_id=us` | 201 | `household` | none | `status`, `message`, `result.item.id`, `result.item.country_id`, `result.item.default_year`, `result.item.household_data`, `result.item.created_at`, `result.item.updated_at` | | `GET` | `/v2/households/{household_id}?country_id=us` | 200 | `household` | none | `status`, `message`, `result.item.id`, `result.item.country_id`, `result.item.default_year`, `result.item.household_data`, `result.item.created_at`, `result.item.updated_at` | -| `GET` | `/v2/households?country_id=us` | 200 | `household` | none | `status`, `message`, `result.items`, `result.offset`, `result.limit`, `result.has_more` | ### `user_household_associations_v2` diff --git a/docs/generated/migration_contracts.json b/docs/generated/migration_contracts.json index 7a0f1b67d..b7b5a162a 100644 --- a/docs/generated/migration_contracts.json +++ b/docs/generated/migration_contracts.json @@ -1,7 +1,7 @@ { "metadata": { "db_entity_count": 6, - "request_count": 89, + "request_count": 88, "route_group_count": 9, "sim_flow_count": 3, "workflow_count": 16 @@ -518,21 +518,6 @@ "result.item.created_at", "result.item.updated_at" ] - }, - { - "expected_status": 200, - "headers": [], - "method": "GET", - "path": "/v2/households?country_id=us", - "route_group": "household", - "stable_response_fields": [ - "status", - "message", - "result.items", - "result.offset", - "result.limit", - "result.has_more" - ] } ] }, diff --git a/policyengine_api/fastapi_routes/dependencies.py b/policyengine_api/fastapi_routes/dependencies.py index 5d5a1a38d..726a2866b 100644 --- a/policyengine_api/fastapi_routes/dependencies.py +++ b/policyengine_api/fastapi_routes/dependencies.py @@ -23,7 +23,6 @@ LegacyReferenceChecker, ) from policyengine_api.services.v2.households.types import ( - HouseholdPage, HouseholdRead, NativeHouseholdCreation, NativeHouseholdCreationInput, @@ -371,17 +370,6 @@ def get_household( """ ... - def list_households( - self, - *, - country_id: str, - default_year: int | None, - offset: int, - limit: int, - ) -> HouseholdPage: - """Return a page of the country's households, optionally of one default year.""" - ... - class V2UserPolicyResourceService(Protocol): """Route-independent user-policy operations for one request.""" diff --git a/policyengine_api/fastapi_routes/v2/households/response_models.py b/policyengine_api/fastapi_routes/v2/households/response_models.py index 35c645c9c..f0b93b0e2 100644 --- a/policyengine_api/fastapi_routes/v2/households/response_models.py +++ b/policyengine_api/fastapi_routes/v2/households/response_models.py @@ -16,7 +16,6 @@ if TYPE_CHECKING: from policyengine_api.services.v2.households.types import ( - HouseholdPage, HouseholdRead, ) @@ -57,29 +56,6 @@ class HouseholdDetailResult(StrictHouseholdAPIModel): item: HouseholdItem -class HouseholdPageResult(StrictHouseholdAPIModel): - """One page of households, oldest first. - - ``offset`` and ``limit`` echo the request; when ``has_more`` is true, ask - for the next page with ``offset + limit``. - """ - - items: list[HouseholdItem] - offset: int - limit: int - has_more: bool - - @classmethod - def from_page(cls, page: HouseholdPage) -> HouseholdPageResult: - """Build the response page from a service page, converting each item.""" - return cls( - items=[HouseholdItem.from_read(item) for item in page.items], - offset=page.offset, - limit=page.limit, - has_more=page.has_more, - ) - - ResultT = TypeVar("ResultT") @@ -95,10 +71,6 @@ class HouseholdDetailResponse(HouseholdSuccessResponse[HouseholdDetailResult]): """Success response holding one household.""" -class HouseholdPageResponse(HouseholdSuccessResponse[HouseholdPageResult]): - """Success response holding one page of households.""" - - HOUSEHOLD_ERROR_RESPONSES: dict[int | str, dict[str, Any]] = { 400: { "model": V2ErrorResponse, diff --git a/policyengine_api/fastapi_routes/v2/households/routes.py b/policyengine_api/fastapi_routes/v2/households/routes.py index 54e24dbf5..fafe8775e 100644 --- a/policyengine_api/fastapi_routes/v2/households/routes.py +++ b/policyengine_api/fastapi_routes/v2/households/routes.py @@ -29,11 +29,8 @@ HouseholdDetailResponse, HouseholdDetailResult, HouseholdItem, - HouseholdPageResponse, - HouseholdPageResult, ) from policyengine_api.query_parameters import ( - HouseholdCollectionQuery, HouseholdCreateQuery, HouseholdDetailQuery, ) @@ -59,9 +56,6 @@ HouseholdDetailQueryDependency = Annotated[ HouseholdDetailQuery, Depends(query_dependency(HouseholdDetailQuery)) ] -HouseholdCollectionQueryDependency = Annotated[ - HouseholdCollectionQuery, Depends(query_dependency(HouseholdCollectionQuery)) -] async def enforce_household_request_size(request: Request) -> None: @@ -182,23 +176,4 @@ def read() -> HouseholdDetailResponse: return _household_operation(read) - @router.get( - "/households", - response_model=HouseholdPageResponse, - summary="List immutable households", - ) - def get_households( - query: HouseholdCollectionQueryDependency, - ) -> HouseholdPageResponse | JSONResponse: - def read() -> HouseholdPageResponse: - page = service_factory().list_households( - country_id=query.country_id, - default_year=query.default_year, - offset=query.offset, - limit=query.limit, - ) - return HouseholdPageResponse(result=HouseholdPageResult.from_page(page)) - - return _household_operation(read) - return router diff --git a/policyengine_api/query_parameters.py b/policyengine_api/query_parameters.py index e06e9cdcb..67b639637 100644 --- a/policyengine_api/query_parameters.py +++ b/policyengine_api/query_parameters.py @@ -261,12 +261,6 @@ class HouseholdDetailQuery(CountryQuery): """Query contract for country-scoped household detail reads.""" -class HouseholdCollectionQuery(CountryQuery, PaginationQuery): - """Query contract for an exact-filtered household collection.""" - - default_year: DefaultYear | None = None - - class UserHouseholdCreateQuery(CountryQuery): """Query contract for native user-household association creation.""" diff --git a/policyengine_api/services/v2/households/database_connectors/reads.py b/policyengine_api/services/v2/households/database_connectors/reads.py index 8a1e6ad8c..474e640c8 100644 --- a/policyengine_api/services/v2/households/database_connectors/reads.py +++ b/policyengine_api/services/v2/households/database_connectors/reads.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING -from sqlmodel import Session, col, select +from sqlmodel import Session, select from policyengine_api.data.v2.models import Household, LegacyHouseholdMapping @@ -36,30 +36,6 @@ def read_household_row( ).one_or_none() -def read_household_rows( - session: Session, - *, - country_id: str, - default_year: int | None, - offset: int, - limit: int, -) -> list[Household]: - """Return up to ``limit + 1`` households in creation order, starting at ``offset``. - - The extra row lets the caller report whether another page exists. - """ - statement = select(Household).where(Household.country_id == country_id) - if default_year is not None: - statement = statement.where(Household.default_year == default_year) - return list( - session.exec( - statement.order_by(col(Household.created_at), col(Household.id)) - .offset(offset) - .limit(limit + 1) - ).all() - ) - - def read_legacy_household_mapping( session: Session, *, diff --git a/policyengine_api/services/v2/households/services.py b/policyengine_api/services/v2/households/services.py index d49b4b368..c5050a1a6 100644 --- a/policyengine_api/services/v2/households/services.py +++ b/policyengine_api/services/v2/households/services.py @@ -11,14 +11,12 @@ from policyengine_api.services.v2.households.database_connectors.reads import ( read_household_by_content_identity, read_household_row, - read_household_rows, read_legacy_household_mapping, ) from policyengine_api.services.v2.households.transformations import ( LEGACY_HOUSEHOLD_FINGERPRINT_VERSION, canonical_household_document, canonicalize_household, - household_page, household_read, legacy_household_fingerprint, stored_household_creation_input, @@ -28,7 +26,6 @@ CanonicalHouseholdContent, HouseholdCreationInput, HouseholdCreationResult, - HouseholdPage, HouseholdRead, LegacyHouseholdPersistenceResult, LegacyHouseholdSnapshot, @@ -126,28 +123,6 @@ def read_complete_household( return household_read(household) -def read_household_page( - session: Session, - *, - country_id: str, - default_year: int | None, - offset: int, - limit: int, -) -> HouseholdPage: - """Return a page of households, optionally filtered by default year.""" - return household_page( - read_household_rows( - session, - country_id=country_id, - default_year=default_year, - offset=offset, - limit=limit, - ), - offset=offset, - limit=limit, - ) - - def mirror_legacy_household_in_session( session: Session, snapshot: LegacyHouseholdSnapshot, @@ -256,24 +231,6 @@ def get_household(self, *, country_id: str, household_id: UUID) -> HouseholdRead household_id=household_id, ) - def list_households( - self, - *, - country_id: str, - default_year: int | None, - offset: int, - limit: int, - ) -> HouseholdPage: - """Return a page of households, optionally filtered by default year.""" - with self._database_session.read() as session: - return read_household_page( - session, - country_id=country_id, - default_year=default_year, - offset=offset, - limit=limit, - ) - def mirror_legacy_household( self, snapshot: LegacyHouseholdSnapshot ) -> LegacyHouseholdPersistenceResult: diff --git a/policyengine_api/services/v2/households/transformations.py b/policyengine_api/services/v2/households/transformations.py index 13ce005b6..4dff1d7de 100644 --- a/policyengine_api/services/v2/households/transformations.py +++ b/policyengine_api/services/v2/households/transformations.py @@ -14,7 +14,6 @@ from policyengine_api.services.v2.households.types import ( CanonicalHouseholdContent, HouseholdCreationInput, - HouseholdPage, HouseholdRead, LegacyHouseholdSnapshot, ) @@ -136,20 +135,6 @@ def household_read(household: Household) -> HouseholdRead: ) -def household_page(rows: list[Household], *, offset: int, limit: int) -> HouseholdPage: - """Build a page from up to ``limit + 1`` rows. - - The extra row is not returned; it only sets ``has_more``. - """ - displayed = rows[:limit] - return HouseholdPage( - items=tuple(household_read(row) for row in displayed), - offset=offset, - limit=limit, - has_more=len(rows) > limit, - ) - - def _stable_ids(kind: str, source_names: list[str]) -> dict[str, str]: return { source_name: f"{kind}-{index + 1}" diff --git a/policyengine_api/services/v2/households/types.py b/policyengine_api/services/v2/households/types.py index 73e9b9d4a..5901ad021 100644 --- a/policyengine_api/services/v2/households/types.py +++ b/policyengine_api/services/v2/households/types.py @@ -119,16 +119,6 @@ class HouseholdRead: updated_at: datetime -@dataclass(frozen=True) -class HouseholdPage: - """One page of households in creation order; ``has_more`` says if more follow.""" - - items: tuple[HouseholdRead, ...] - offset: int - limit: int - has_more: bool - - @dataclass(frozen=True) class NativeHouseholdCreation: """A created-or-found household, with ``created`` choosing 201 or 200.""" diff --git a/pyproject.toml b/pyproject.toml index ef1bee9d6..96dfc0308 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,6 +67,7 @@ dependencies = [ dev = [ "build", "coverage", + "hypothesis>=6.100", "mypy>=1.15,<2", "pytest", "pytest-snapshot", diff --git a/tests/contract/registry.py b/tests/contract/registry.py index 778c3da26..9ce97f66c 100644 --- a/tests/contract/registry.py +++ b/tests/contract/registry.py @@ -339,20 +339,6 @@ class WorkflowContract: ), route_group="household", ), - ContractRequest( - method="GET", - path="/v2/households?country_id=us", - expected_status=200, - stable_response_fields=( - "status", - "message", - "result.items", - "result.offset", - "result.limit", - "result.has_more", - ), - route_group="household", - ), ), ), WorkflowContract( diff --git a/tests/unit/routes/test_migration_context_logging.py b/tests/unit/routes/test_migration_context_logging.py index 641c8c34b..00a34fa5d 100644 --- a/tests/unit/routes/test_migration_context_logging.py +++ b/tests/unit/routes/test_migration_context_logging.py @@ -463,7 +463,7 @@ def test_v2_household_resources_log_actual_supabase_read_and_write_sources( ServedRequest( request_id="request-read", method="GET", - path="/v2/households", + path="/v2/households/00000000-0000-0000-0000-000000000010", status_code=200, started_at=None, country_id="us", diff --git a/tests/unit/test_migration_contract_artifacts.py b/tests/unit/test_migration_contract_artifacts.py index d08090feb..09cf8b313 100644 --- a/tests/unit/test_migration_contract_artifacts.py +++ b/tests/unit/test_migration_contract_artifacts.py @@ -14,7 +14,7 @@ def test_migration_contract_payload_summarizes_route_contracts(): assert payload["metadata"] == { "route_group_count": 9, "workflow_count": 16, - "request_count": 89, + "request_count": 88, "db_entity_count": 6, "sim_flow_count": 3, } diff --git a/tests/unit/test_query_parameters.py b/tests/unit/test_query_parameters.py index 534641250..332abc5bc 100644 --- a/tests/unit/test_query_parameters.py +++ b/tests/unit/test_query_parameters.py @@ -17,7 +17,6 @@ BudgetWindowEconomyQuery, CountryQuery, DuplicateScalarQueryParameterError, - HouseholdCollectionQuery, HouseholdCreateQuery, HouseholdDetailQuery, PolicyCollectionQuery, @@ -49,9 +48,6 @@ class ExplicitListQuery(CountryQuery): PolicyCollectionQueryDependency = Annotated[ PolicyCollectionQuery, Depends(query_dependency(PolicyCollectionQuery)) ] -HouseholdCollectionQueryDependency = Annotated[ - HouseholdCollectionQuery, Depends(query_dependency(HouseholdCollectionQuery)) -] def test_canonical_required_defaults_and_normalization() -> None: @@ -311,15 +307,6 @@ def test_uuid_filter_is_materialized_as_uuid() -> None: def test_household_query_contracts_compose_canonical_fields() -> None: household_id = uuid4() - collection = parse_query_items( - HouseholdCollectionQuery, - [ - ("country_id", "UK"), - ("default_year", "2026"), - ("offset", "2"), - ("limit", "25"), - ], - ) associations = parse_query_items( UserHouseholdCollectionQuery, [ @@ -329,12 +316,6 @@ def test_household_query_contracts_compose_canonical_fields() -> None: ], ) - assert collection.model_dump() == { - "country_id": "uk", - "offset": 2, - "limit": 25, - "default_year": 2026, - } assert associations.country_id == "us" assert associations.household_id == household_id assert associations.legacy_household_id == 123 @@ -354,17 +335,6 @@ def test_household_query_contracts_compose_canonical_fields() -> None: ) -@pytest.mark.parametrize("default_year", ["1899", "2201", "not-a-year"]) -def test_household_default_year_is_bounded(default_year: str) -> None: - with pytest.raises(ValidationError) as raised: - parse_query_items( - HouseholdCollectionQuery, - [("country_id", "us"), ("default_year", default_year)], - ) - - assert raised.value.errors()[0]["loc"] == ("default_year",) - - @pytest.mark.parametrize( ("model_type", "field_name"), [ @@ -396,7 +366,7 @@ def test_user_association_collections_take_the_owner_from_the_session( @pytest.mark.parametrize( "model_type", - [HouseholdCollectionQuery, UserHouseholdCollectionQuery], + [HouseholdCreateQuery, HouseholdDetailQuery, UserHouseholdCollectionQuery], ) def test_household_query_models_reject_unknown_and_duplicate_scalars( model_type: type[CountryQuery], @@ -414,34 +384,6 @@ def test_household_query_models_reject_unknown_and_duplicate_scalars( ) -def test_household_collection_openapi_matches_runtime_contract() -> None: - app = FastAPI() - - @app.get("/households") - def households( - query: HouseholdCollectionQueryDependency, - ) -> dict[str, object]: - return query.model_dump(mode="json") - - operation = ( - TestClient(app).get("/openapi.json").json()["paths"]["/households"]["get"] - ) - parameters = {item["name"]: item for item in operation["parameters"]} - - assert set(parameters) == {"country_id", "default_year", "offset", "limit"} - assert parameters["country_id"]["required"] is True - assert parameters["default_year"]["required"] is False - year_schema = parameters["default_year"]["schema"]["anyOf"][0] - assert year_schema == { - "type": "integer", - "maximum": 2200, - "minimum": 1900, - "description": "Default year for values without explicit period keys", - } - assert parameters["offset"]["schema"]["default"] == 0 - assert parameters["limit"]["schema"]["default"] == 100 - - def _strict_query_subclasses(base: type) -> set[type]: found: set[type] = set() for subclass in base.__subclasses__(): diff --git a/tests/unit/v2/test_household_query.py b/tests/unit/v2/test_household_query.py index ccfa5d730..25e50de91 100644 --- a/tests/unit/v2/test_household_query.py +++ b/tests/unit/v2/test_household_query.py @@ -9,10 +9,7 @@ from sqlmodel import Session, create_engine from policyengine_api.data.v2.models import V2_METADATA, Household -from policyengine_api.services.v2.households.services import ( - read_complete_household, - read_household_page, -) +from policyengine_api.services.v2.households.services import read_complete_household from policyengine_api.services.v2.households.validators import HouseholdNotFoundError @@ -72,38 +69,3 @@ def test_detail_is_complete_and_uses_country_as_resource_identity() -> None: read_complete_household(session, country_id="uk", household_id=first_id) finally: engine.dispose() - - -def test_collection_orders_paginates_and_filters_exact_default_year() -> None: - engine, first_id, second_id, _other_id = _stored_households() - try: - with Session(engine) as session: - first_page = read_household_page( - session, - country_id="us", - default_year=None, - offset=0, - limit=1, - ) - second_page = read_household_page( - session, - country_id="us", - default_year=None, - offset=1, - limit=1, - ) - filtered = read_household_page( - session, - country_id="us", - default_year=2026, - offset=0, - limit=100, - ) - - assert [item.id for item in first_page.items] == [first_id] - assert first_page.has_more is True - assert [item.id for item in second_page.items] == [second_id] - assert second_page.has_more is False - assert [item.id for item in filtered.items] == [first_id] - finally: - engine.dispose() diff --git a/tests/unit/v2/test_household_routes.py b/tests/unit/v2/test_household_routes.py index 3932129b5..399d09183 100644 --- a/tests/unit/v2/test_household_routes.py +++ b/tests/unit/v2/test_household_routes.py @@ -2,24 +2,35 @@ from __future__ import annotations +import string from datetime import UTC, datetime, timedelta +from types import SimpleNamespace +from urllib.parse import urlencode from uuid import UUID, uuid4 import pytest +from fastapi import FastAPI from fastapi.testclient import TestClient from flask import Flask, jsonify +from hypothesis import example, given, settings +from hypothesis import strategies as st +from pydantic import BaseModel from sqlalchemy.exc import OperationalError from sqlalchemy.exc import TimeoutError as PoolTimeoutError from policyengine_api.asgi_factory import create_asgi_app from policyengine_api.data.v2.settings import V2ConfigurationError from policyengine_api.fastapi_routes.dependencies import NativeRouteDependencies +from policyengine_api.fastapi_routes.v2.households.response_models import ( + HouseholdDetailResponse, + HouseholdItem, +) +from policyengine_api.libs.user_auth import UNAUTHENTICATED_USER_HEADER from policyengine_api.migration_flags import ( RouteImplementation, RouteImplementationSettings, ) from policyengine_api.services.v2.households.types import ( - HouseholdPage, HouseholdRead, NativeHouseholdCreation, ) @@ -94,16 +105,6 @@ def get_household(self, **filters) -> HouseholdRead: self._raise() return _household_read(household_id=filters["household_id"]) - def list_households(self, **filters) -> HouseholdPage: - self.calls.append(("list_households", filters)) - self._raise() - return HouseholdPage( - items=(_household_read(),), - offset=filters["offset"], - limit=filters["limit"], - has_more=False, - ) - def _client( service: FakeHouseholdService, @@ -268,32 +269,272 @@ def test_detail_is_country_scoped_and_returns_complete_content() -> None: assert flask_calls["count"] == 0 -def test_list_passes_exact_year_filter_and_canonical_pagination() -> None: - service = FakeHouseholdService() - client, _flask_calls = _client(service) +class HouseholdListingService(FakeHouseholdService): + """A service that would return stored households to any route that asked.""" - response = client.get( - "/v2/households?country_id=us&default_year=2026&offset=2&limit=3" - ) - search = client.get("/v2/households?country_id=us&search=age") - excessive = client.get("/v2/households?country_id=us&limit=501") + def list_households(self, **filters) -> SimpleNamespace: + self.calls.append(("list_households", filters)) + return SimpleNamespace( + items=(_household_read(),), + offset=filters["offset"], + limit=filters["limit"], + has_more=True, + ) - assert response.status_code == 200 - assert response.json()["result"]["offset"] == 2 - assert response.json()["result"]["limit"] == 3 - assert service.calls == [ - ( - "list_households", - { - "country_id": "us", - "default_year": 2026, - "offset": 2, - "limit": 3, - }, + +def test_anonymous_household_collection_is_not_served() -> None: + service = HouseholdListingService() + client, flask_calls = _client(service) + + response = client.get("/v2/households?country_id=us&offset=0&limit=500") + + assert response.status_code == 404 + assert response.json() == { + "status": "error", + "message": "API v2 resource 'households' was not found", + } + assert service.calls == [] + assert flask_calls["count"] == 0 + + +_COLLECTION_PATHS = ("/v2/households", "/v2/households/", "/v2/%68ouseholds") +_QUERY_TEXT = st.text(alphabet=string.printable, max_size=12) + + +@st.composite +def _collection_queries(draw: st.DrawFn) -> list[tuple[str, str]]: + country = draw(st.none() | st.sampled_from(["us", "uk", "US", "Uk"]) | _QUERY_TEXT) + offset = draw(st.none() | st.integers(min_value=-5, max_value=10**6)) + limit = draw(st.none() | st.integers(min_value=-5, max_value=1_000)) + default_year = draw(st.none() | st.integers(min_value=1_800, max_value=2_300)) + pairs = [ + (name, str(value)) + for name, value in ( + ("country_id", country), + ("offset", offset), + ("limit", limit), + ("default_year", default_year), ) + if value is not None ] - assert search.status_code == 422 - assert excessive.status_code == 422 + extras = st.tuples( + st.sampled_from(["search", "user_id", "household_id", "page", "country_id"]) + | st.text(alphabet=string.ascii_lowercase + "_", min_size=1, max_size=12), + _QUERY_TEXT, + ) + pairs.extend(draw(st.lists(extras, max_size=3))) + return draw(st.permutations(pairs)) + + +_BEARER_TOKENS = st.text( + alphabet=string.ascii_letters + string.digits + "-_.", min_size=1, max_size=64 +) +_CREDENTIAL_HEADERS = st.one_of( + st.just({}), + _BEARER_TOKENS.map(lambda token: {"Authorization": f"Bearer {token}"}), + st.uuids().map(lambda user: {UNAUTHENTICATED_USER_HEADER: str(user)}), + st.tuples(_BEARER_TOKENS, st.uuids()).map( + lambda pair: { + "Authorization": f"Bearer {pair[0]}", + UNAUTHENTICATED_USER_HEADER: str(pair[1]), + } + ), + st.uuids().map( + lambda user: { + "Origin": "https://policyengine.org", + UNAUTHENTICATED_USER_HEADER: str(user), + } + ), +) + + +def test_household_collection_path_never_returns_household_content() -> None: + """No query or credential on the collection path returns stored households. + + The backing service would return a page to any route that asked, so the + property holds only if no collection route exists to ask it. + """ + service = HouseholdListingService() + client, flask_calls = _client(service) + + @settings(max_examples=150, deadline=None) + @given( + path=st.sampled_from(_COLLECTION_PATHS), + query=_collection_queries(), + headers=_CREDENTIAL_HEADERS, + ) + @example(path="/v2/households", query=[("country_id", "us")], headers={}) + @example( + path="/v2/households", + query=[("country_id", "uk"), ("offset", "500"), ("limit", "500")], + headers={"Authorization": "Bearer anything"}, + ) + @example( + path="/v2/households", + query=[("country_id", "us"), ("default_year", "2026")], + headers={UNAUTHENTICATED_USER_HEADER: str(uuid4())}, + ) + def check(path: str, query: list[tuple[str, str]], headers: dict[str, str]) -> None: + service.calls.clear() + response = client.get(f"{path}?{urlencode(query)}", headers=headers) + + assert service.calls == [] + assert "household_data" not in response.text + assert str(HOUSEHOLD_ID) not in response.text + assert "result" not in response.json() + assert flask_calls["count"] == 0 + assert response.json()["status"] == "error" + assert response.status_code in {401, 404, 405} + + check() + + +# The signed-out header carries a UUID the caller generates, so it proves no +# identity: an operation that accepts it alone is as open as one without security. +_SELF_ASSERTED_SCHEMES = frozenset({"UnauthenticatedUser"}) +_HTTP_METHODS = frozenset( + {"get", "put", "post", "delete", "options", "head", "patch", "trace"} +) +_ELEMENT_KEYWORDS = ( + "items", + "prefixItems", + "additionalProperties", + "patternProperties", +) + + +def _referenced_schemas(node: object, components: dict[str, object]) -> set[str]: + """Return the name of every component schema reachable from an OpenAPI node.""" + names: set[str] = set() + pending = [node] + while pending: + current = pending.pop() + if isinstance(current, list): + pending.extend(current) + elif isinstance(current, dict): + reference = current.get("$ref") + if isinstance(reference, str): + name = reference.rsplit("/", 1)[-1] + if name not in names: + names.add(name) + pending.append(components[name]) + pending.extend(value for key, value in current.items() if key != "$ref") + return names + + +def _holds_many_households(node: object, components: dict[str, object]) -> bool: + """Whether a schema has an array or map whose elements can hold a HouseholdItem. + + Element schemas are followed through references and composition + (``anyOf``, ``allOf``, nested models), so ``list[HouseholdItem | None]`` + and ``list[Wrapper]`` count as well as ``list[HouseholdItem]``. + """ + pending = [node] + visited: set[str] = set() + while pending: + current = pending.pop() + if isinstance(current, list): + pending.extend(current) + continue + if not isinstance(current, dict): + continue + for keyword in _ELEMENT_KEYWORDS: + element = current.get(keyword) + if isinstance(element, dict | list) and "HouseholdItem" in ( + _referenced_schemas(element, components) + ): + return True + reference = current.get("$ref") + if isinstance(reference, str): + name = reference.rsplit("/", 1)[-1] + if name not in visited: + visited.add(name) + pending.append(components[name]) + pending.extend(value for key, value in current.items() if key != "$ref") + return False + + +def _reachable_without_account( + operation: dict[str, object], schema: dict[str, object] +) -> bool: + """Whether a security alternative needs nothing beyond the signed-out header.""" + security = operation.get("security", schema.get("security")) or [] + return not security or any( + set(alternative) <= _SELF_ASSERTED_SCHEMES for alternative in security + ) + + +def _open_household_collections(schema: dict[str, object]) -> set[tuple[str, str]]: + """Return operations that return many households to a caller with no account. + + A route that returned only the caller's own households in full would also + match; it would need an explicit, reviewed exception here. + """ + components = schema.get("components", {}).get("schemas", {}) + return { + (method.upper(), path) + for path, operations in schema["paths"].items() + for method, operation in operations.items() + if method in _HTTP_METHODS + and _reachable_without_account(operation, schema) + and any( + _holds_many_households(response, components) + for status, response in operation.get("responses", {}).items() + if str(status).startswith("2") + ) + } + + +class _WrappedHousehold(BaseModel): + household: HouseholdItem + + +def test_open_household_collection_check_catches_each_list_and_credential_shape() -> ( + None +): + app = FastAPI() + signed_out = [{"UnauthenticatedUser": []}] + shapes = { + "/list": (list[HouseholdItem], None), + "/optional-items": (list[HouseholdItem | None], None), + "/map": (dict[str, HouseholdItem], None), + "/wrapped": (list[_WrappedHousehold], None), + "/signed-out-header": (list[HouseholdItem], signed_out), + "/token-or-header": ( + list[HouseholdItem], + [{"UserAccessToken": []}, *signed_out], + ), + "/optional-token": (list[HouseholdItem], [{}, {"UserAccessToken": []}]), + "/token-only": (list[HouseholdItem], [{"UserAccessToken": []}]), + "/one-household": (HouseholdDetailResponse, None), + } + for path, (response_model, security) in shapes.items(): + app.add_api_route( + path, + lambda: None, + methods=["GET"], + response_model=response_model, + openapi_extra=None if security is None else {"security": security}, + ) + + assert _open_household_collections(app.openapi()) == { + ("GET", "/list"), + ("GET", "/optional-items"), + ("GET", "/map"), + ("GET", "/wrapped"), + ("GET", "/signed-out-header"), + ("GET", "/token-or-header"), + ("GET", "/optional-token"), + } + + +def test_no_open_v2_operation_returns_many_households() -> None: + service = FakeHouseholdService() + client, _flask_calls = _client(service) + schema = client.get("/v2/openapi.json").json() + + assert _open_household_collections(schema) == set() + assert set(schema["paths"]["/v2/households"]) == {"post"} @pytest.mark.parametrize( @@ -356,7 +597,7 @@ def test_base_household_mutations_are_not_exposed(method: str) -> None: assert flask_calls["count"] == 0 -def test_openapi_publishes_query_body_item_page_and_error_schemas() -> None: +def test_openapi_publishes_query_body_item_and_error_schemas() -> None: service = FakeHouseholdService() client, _flask_calls = _client(service) schema = client.get("/v2/openapi.json").json() @@ -365,12 +606,11 @@ def test_openapi_publishes_query_body_item_page_and_error_schemas() -> None: "/v2/households", "/v2/households/{household_id}", } - collection = schema["paths"]["/v2/households"]["get"] - parameters = {item["name"]: item for item in collection["parameters"]} + detail = schema["paths"]["/v2/households/{household_id}"]["get"] + parameters = {item["name"]: item for item in detail["parameters"]} + assert set(parameters) == {"household_id", "country_id"} + assert parameters["household_id"]["schema"]["format"] == "uuid" assert parameters["country_id"]["required"] is True - assert parameters["default_year"]["schema"]["anyOf"][0]["minimum"] == 1900 - assert parameters["offset"]["schema"]["default"] == 0 - assert parameters["limit"]["schema"]["maximum"] == 500 create = schema["paths"]["/v2/households"]["post"] assert {"200", "201", "400", "404", "409", "413", "422", "500", "503"} <= set( @@ -424,8 +664,9 @@ def test_browser_preflight_and_cross_origin_errors_use_application_cors() -> Non "Access-Control-Request-Headers": "content-type", }, ) - error = client.get( + error = client.post( "/v2/households?country_id=us&search=age", + json=_body(), headers={"Origin": origin}, ) diff --git a/uv.lock b/uv.lock index 094aec459..f2caa51a5 100644 --- a/uv.lock +++ b/uv.lock @@ -1435,6 +1435,94 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/c1/1fa4162f6dd53259daf2ad31385273341821fa0acce164cd03971937a60e/huggingface_hub-1.12.2-py3-none-any.whl", hash = "sha256:7968e897fdbc6343c871c240d87d4434efe0ad9f80d57daa1cc5678c6d148529", size = 647757, upload-time = "2026-04-29T09:45:07.63Z" }, ] +[[package]] +name = "hypothesis" +version = "6.168.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/a8/bd70d7c2966e561228b9fdc075ee77c0ba577dcbbfbf921edf614db14f6a/hypothesis-6.168.5.tar.gz", hash = "sha256:76b9226962fe11d40858253a967eda95bb65811365286317e0118f4ec8f808c7", size = 511115, upload-time = "2026-10-05T23:26:35.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/0c/7f04c8d277dfc828ba584b7d9d10dbac5e91fce673fa5328f7bd5bf64609/hypothesis-6.168.5-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:ca43a751410a9c6685f029fd5126cc5507664cafaa76017922aa8ae2e17b6620", size = 791539, upload-time = "2026-10-05T23:24:25.544Z" }, + { url = "https://files.pythonhosted.org/packages/11/5c/660906d83db74eb86feda715d0f2df14836205b14a183332116676733e6f/hypothesis-6.168.5-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c8b98707cbe9f430d100a945bbe17612fd3aa44eac1b0ac5299669fe3b8e4128", size = 787208, upload-time = "2026-10-05T23:25:14.028Z" }, + { url = "https://files.pythonhosted.org/packages/01/85/36e19492bc4ff354c2be9c8fa7c6ace0c65f9d2c7116656b741680c6ca55/hypothesis-6.168.5-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4dde52a0b696c642e7f988a03026c7c29f90daf21e74507b6f865c3ccc9d536e", size = 1115200, upload-time = "2026-10-05T23:25:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/d4/82/3273fb0a3567c09b767bb8fe2824d65e16ae2abb92cf1f43762df723df94/hypothesis-6.168.5-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42f02e4541fe0c17a1320617effc0ab8a8aca2a9af15e3358d4150acf3bbdc00", size = 1145113, upload-time = "2026-10-05T23:25:17.502Z" }, + { url = "https://files.pythonhosted.org/packages/74/59/5c5904555a0bbd4b2898d73ea90c6d03f5be0d8ff0756ac1d519ace6ae66/hypothesis-6.168.5-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bf6dd7e537a12763c9afa017f7a6159e5cda608e98670621fa44596a1e8e9288", size = 1139564, upload-time = "2026-10-05T23:25:56.681Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ce/55654ff9575587a401e304f08ad1d43b7e6318f81c66bd866fdc5ab4665b/hypothesis-6.168.5-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df2c04cd30abf42c52580184216162a75b5508b214a472b86670f6dd50659a3b", size = 1192142, upload-time = "2026-10-05T23:26:06.565Z" }, + { url = "https://files.pythonhosted.org/packages/48/91/4cc9d6e8a950473e07e3ebf00cbb8ee0d76b14d193f94c3de20f1c09e2b1/hypothesis-6.168.5-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:278662eb21aaec9eaae71ea4dabd4fe390c2af11ec58a6a0606687cf6d7689b0", size = 1157262, upload-time = "2026-10-05T23:24:59.229Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3a/4b8aa3be788ea81b9a7bc6b673ed89edd72fd0645c6aa691d4c159ff971a/hypothesis-6.168.5-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:6bcedc4ab8ab92dd0f3af0cfe24dce184d225751d7bc870a9cddb9a557de847f", size = 1113779, upload-time = "2026-10-05T23:24:12.327Z" }, + { url = "https://files.pythonhosted.org/packages/f9/98/2eb4c79d1851195e6a083568b065235680ab984e984bbd472f2a7d02ba33/hypothesis-6.168.5-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8b58097cc3b98d8616f635ac73888fc9f859311875f2adc043f1544c40c3c466", size = 1152639, upload-time = "2026-10-05T23:25:43.635Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9c/68f7e99b43c6f37c077669a4d3bd88f48c042444ced9e7cff0eaf44bc70a/hypothesis-6.168.5-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f8a387d9ee7f804e830b31f2e2e339ab5731665e922cfda4f6f6fbdb05e191b4", size = 1290068, upload-time = "2026-10-05T23:25:28.45Z" }, + { url = "https://files.pythonhosted.org/packages/b4/04/d4f87164a0d028ab102cea345b601d9dafb3196358df5448caa88ac3c1e2/hypothesis-6.168.5-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:326f6383fdf2e37ac69773589a8238a3bf396ca8ac8efacb0fb9ed42dd08e426", size = 1418551, upload-time = "2026-10-05T23:24:51.25Z" }, + { url = "https://files.pythonhosted.org/packages/a8/32/6b518a25514f0e643f95610c77e279bfbf0e0b3bd423aac0187d6f039b9a/hypothesis-6.168.5-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:5d33fc74e43bbd7c3a8f6f7161a8b93b676924286e97e70e828c6e0dcee5c01f", size = 1372130, upload-time = "2026-10-05T23:25:32.359Z" }, + { url = "https://files.pythonhosted.org/packages/48/c2/32538e14e63193ca894ba584696805d1eb45cfc27e15fccd47acfb87531c/hypothesis-6.168.5-cp310-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:1994923cf5e5220ae6bf19645302504b27c0289d83e5d8690df71dcae63d8416", size = 1268950, upload-time = "2026-10-05T23:25:02.544Z" }, + { url = "https://files.pythonhosted.org/packages/86/3b/e50e7e98af9489aa05203c2ab38c95d891dd8d1ed08fad972dcdb6955332/hypothesis-6.168.5-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:501038fd24d3bc95239cfd093a23cf1151f29dd82382a3554dac5dfdab9729ae", size = 1283907, upload-time = "2026-10-05T23:24:29.909Z" }, + { url = "https://files.pythonhosted.org/packages/71/46/41c460a7d2148a04b212b2d594d39992fb52e0b844e13bf6784573fc8dea/hypothesis-6.168.5-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e2292ddc24fe6d04b7d30fa6a7e2c9e280ad5078fe671d0bf4aa6df6e143b5ac", size = 1323762, upload-time = "2026-10-05T23:24:18.984Z" }, + { url = "https://files.pythonhosted.org/packages/68/4f/37a7fc1fe445e3589e0f56ff4573c28de1d6e6a03009cba2f99f04e46ffa/hypothesis-6.168.5-cp310-abi3-win32.whl", hash = "sha256:925d67c69b719d416334aa961c0cdfc4a58a471af1ebd2d7101bd515a70f4e5f", size = 677903, upload-time = "2026-10-05T23:25:07.129Z" }, + { url = "https://files.pythonhosted.org/packages/81/e6/7b25ca7845a60522ebc5f8054f6bba68d47126fb5d940c784fc528a4be4a/hypothesis-6.168.5-cp310-abi3-win_amd64.whl", hash = "sha256:2311590eccba452de863dfe3466daa86a05c25f072ab31ed8bb4d3313ee68439", size = 684616, upload-time = "2026-10-05T23:25:04.028Z" }, + { url = "https://files.pythonhosted.org/packages/c3/00/40e7c36b46c8788eddc7a322ad324e6db53c8ab9a8b9a95d6535ee7bdaaf/hypothesis-6.168.5-cp310-abi3-win_arm64.whl", hash = "sha256:222a6d23a2a824b0f9f73761c2fb9cd2aca96cf3e5b441617625bce4f7eb4fd4", size = 683061, upload-time = "2026-10-05T23:25:19.403Z" }, + { url = "https://files.pythonhosted.org/packages/de/95/f1149d913d685809c016b2a3ae9d727741ae22f52376c6d0ed51eecb5ac8/hypothesis-6.168.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8c35e5d4a85d0d6071cc267a6cbb8fd7ae23ca8a0f745ea5a52c0064d7c1c4b8", size = 792108, upload-time = "2026-10-05T23:25:12.323Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/7e5ffb6bbfc033c85746243dc4d1541876082e136ee44c02f843bb77427e/hypothesis-6.168.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:244a8d14c0a8a3be0345ad0b120deafb94517cc1d74a961d14b5b5eb041b4c0c", size = 788035, upload-time = "2026-10-05T23:26:26.557Z" }, + { url = "https://files.pythonhosted.org/packages/38/df/022129d3e16d19a84e7a5a35ebf7baca07d3482fb34f0faaab865b14fe66/hypothesis-6.168.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e68e1d43b7c9c7a1aa659dfe1c0ecc2de79391b20db853c1e18ea7e3d2ce31f", size = 1115438, upload-time = "2026-10-05T23:24:52.639Z" }, + { url = "https://files.pythonhosted.org/packages/da/09/b3e45b0386d8f643a304105883c5bfce79fd530b2dfe3a70564e1d7aa0bd/hypothesis-6.168.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01a4d3773f285e75551eeef12df058e6316b666bcc3ec187c5eb52a893fbb015", size = 1157503, upload-time = "2026-10-05T23:25:05.609Z" }, + { url = "https://files.pythonhosted.org/packages/ee/4a/aba5a74ddb20c9f41ba5b8f2918c5a12660146cab2120f14122122715060/hypothesis-6.168.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cc327005f2fbb55db81d132948ee7c6cec0589694bed04b1e45fc8fc317e12bd", size = 1290235, upload-time = "2026-10-05T23:25:08.982Z" }, + { url = "https://files.pythonhosted.org/packages/34/f4/7204aa6117a38085e6f1dbefd5cd98050a58c847f2bdecc917422cdb2b1c/hypothesis-6.168.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:62f21c74ad83fe77abc72e82c54114148fb01396769c234e26c9b9dbc21344a9", size = 1324204, upload-time = "2026-10-05T23:26:16.239Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4b/15a46ced6d999148d1b718c5488c243bd56dfcd687a61404fe371192dfd5/hypothesis-6.168.5-cp311-cp311-win_amd64.whl", hash = "sha256:bd3ff6e53e29b86ec6078f123284e65e1c678fe7b30c2b52512244faf266502c", size = 684414, upload-time = "2026-10-05T23:26:18.231Z" }, + { url = "https://files.pythonhosted.org/packages/90/43/a04a727578cbef9f75c11fa6fbad66d13aaffc354f4f979506219814c7d4/hypothesis-6.168.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ddee1ef4bab47e315b705e42d2f4354e789973d11f9620d2df242aef4cfa42b2", size = 793156, upload-time = "2026-10-05T23:25:49.433Z" }, + { url = "https://files.pythonhosted.org/packages/f4/91/55de4e2a12fe98ebd5bc8f35e59870c897ab360cbfe5aa63862cdbef56ad/hypothesis-6.168.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:81ceb49b0dc3a4b6126cd0d3bf2b634af4e91513c8f1e2daee16041414ed8e3d", size = 784788, upload-time = "2026-10-05T23:26:20.188Z" }, + { url = "https://files.pythonhosted.org/packages/f4/61/230abc6320540bdf73baf9a1c025fb0aa27cfd5a3791a2e0c95114239a70/hypothesis-6.168.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a09caa95d2d7e6546f727f703de606145835d9ca215fb3134a21353c69afaac", size = 1113853, upload-time = "2026-10-05T23:25:30.593Z" }, + { url = "https://files.pythonhosted.org/packages/f7/4d/3bf0a7806b3fa12ed076f2daeb3db0e6f9738994e879432ffd8dbcffd634/hypothesis-6.168.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97ac1d516a42a3b1f13b36a1aa6a5f842e43d67e69d4dc664a9645b28de411ef", size = 1156695, upload-time = "2026-10-05T23:24:28.607Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a0/603f918fcf8f74f81ea593b04e3a9a9fcd426bbf389ed52cb340249bdc14/hypothesis-6.168.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e4819fba78c6cbaa6e2f9fd5a69a413817446943f286763819b5ac52391bff3e", size = 1288792, upload-time = "2026-10-05T23:25:36.354Z" }, + { url = "https://files.pythonhosted.org/packages/69/7c/711ef5be6e889dcd40d9b03cdd85cd42ae39af75835bced3c374730291a9/hypothesis-6.168.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87334b95dfbc101652fa48a427a742b0715b814506d9a10f621c29e476b4a2c1", size = 1323090, upload-time = "2026-10-05T23:25:58.753Z" }, + { url = "https://files.pythonhosted.org/packages/66/66/0377d7d13ff3e2c16efd141942649edcdb568caec4576f86ac779545dd85/hypothesis-6.168.5-cp312-cp312-win_amd64.whl", hash = "sha256:2fcec23ff4eb526ee85d3510f564b938ca74f6011f1eec1050e4eb55280b0468", size = 681999, upload-time = "2026-10-05T23:25:41.86Z" }, + { url = "https://files.pythonhosted.org/packages/7b/b3/1f7f72cd28d02a5ca99c432fbffe4b750a375df2284af9d916943dd3aa4f/hypothesis-6.168.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:714337b25ca9137bc359c570b868269462307e120999412ca1946f997f4b9db5", size = 793075, upload-time = "2026-10-05T23:25:15.905Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ba/5b0874828695c4d49e3858d0967254f783e563cd0e211a6db27d11d48a1f/hypothesis-6.168.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7f1c3617155fcf5b5259a1f2e4c775d3eec7bfa80b162b2f6f145b08f871ab08", size = 784693, upload-time = "2026-10-05T23:24:16.559Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5f/ca777becba5251b0d778bb9d83d15524c559a07e4b5d4e6211473855bae2/hypothesis-6.168.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ebee70b7a026210bb47c86c89e5bfb42effd5bd630080e76bc084f29c01c7f7a", size = 1113693, upload-time = "2026-10-05T23:24:44.262Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e7/5a74bf329e405db3edc5639a2595eccf33ad6f5aaa191019e9f824d630f4/hypothesis-6.168.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cfb06b31cca005345b8ad63f88986d21fd359a7dc3dba2965dd3515b720e5c9", size = 1156530, upload-time = "2026-10-05T23:24:47.153Z" }, + { url = "https://files.pythonhosted.org/packages/34/7d/e79cf67f03f212a1394abac21053bd6887aa70f557be1da3f9c9c73e58ae/hypothesis-6.168.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4a4c244d7ab64963fb575f0ec2d813630e1d14cefc39e7c460d5d778e5af4118", size = 1288713, upload-time = "2026-10-05T23:25:22.763Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/df452159ac8d7b278071a3e81fafc69da833ec4302b8c85f5b6e530aea21/hypothesis-6.168.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8e59d519f6fb38b3fa4fcde046767b03a24740fe827d261ee7ff9a721c06169b", size = 1323134, upload-time = "2026-10-05T23:26:02.485Z" }, + { url = "https://files.pythonhosted.org/packages/af/fb/f07d8d09fb57eb14555cad64dfbe29bfdcecff3806f1e01268258088e741/hypothesis-6.168.5-cp313-cp313-win_amd64.whl", hash = "sha256:c103f655644afa4ef6bf7efbf86e44b78ee475fd0691da2db86e2cfe72c07234", size = 682019, upload-time = "2026-10-05T23:24:22.888Z" }, + { url = "https://files.pythonhosted.org/packages/de/e9/7c3c2262b8cfa825c4c1764d62aa15e628bae257ccfd2ee4f3ffa4f81eaa/hypothesis-6.168.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c4dc037d8001bc6eccb8636f4a38d16ea6b250d6bf0a89075aaa5e5069f751cc", size = 793253, upload-time = "2026-10-05T23:26:14.331Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a6/7909ed7d29302491e9b7bc0e7ac3287c20736c05a0cc35bae65024aeec3b/hypothesis-6.168.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c90743321f29b65491d146adfc2ece85869bacb71ce18b47674795e896c81ee3", size = 784837, upload-time = "2026-10-05T23:25:24.728Z" }, + { url = "https://files.pythonhosted.org/packages/91/8c/57742c459349052e6a3e0c011855840f8cbbbadca91079d5b591f08b25ae/hypothesis-6.168.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09debb7f7f0f229da5f7e2ad515a5be7a8dc607ec204074775f8ab6731a447f0", size = 1114268, upload-time = "2026-10-05T23:24:27.35Z" }, + { url = "https://files.pythonhosted.org/packages/55/80/07bd2449f91f9426f705fb689429bab6e26d1365f8ac4ef7d7c1cec9055e/hypothesis-6.168.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d227f8ac497eca0bde4e8562d32dd4e82fc9566526020bbd567f76b833b923b0", size = 1156838, upload-time = "2026-10-05T23:24:35.211Z" }, + { url = "https://files.pythonhosted.org/packages/fe/75/7f3dda517e5134f73e2ae41821bf40b3fd3ac6551a9a43ea9287471738a1/hypothesis-6.168.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc6ebd35601c72c842e5899c3f760f9ed26c69e786ee40a9a64fb5a4a3058315", size = 1288864, upload-time = "2026-10-05T23:24:42.645Z" }, + { url = "https://files.pythonhosted.org/packages/c8/cd/4b1364140642cf3f1431ca59b5841fc322872dfa7197b2facb97692da234/hypothesis-6.168.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:503e103ad49e702bad200157d82778eebbc14d3045e9700a8e8fe5db40912953", size = 1323300, upload-time = "2026-10-05T23:24:14.826Z" }, + { url = "https://files.pythonhosted.org/packages/20/e7/47d7cffcaf15318a4308516b6b3d2fd0db599f18eacc0f2dc553be2206a7/hypothesis-6.168.5-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:bc5cc310f9f86ec62f0d0dd7eea5a4788f18ec793b70ee2c7163b916768e1057", size = 624239, upload-time = "2026-10-05T23:24:48.453Z" }, + { url = "https://files.pythonhosted.org/packages/97/6e/2ca0f68150be175b7cfa7bfb6692260638d86aeb9313478ba82e198186e6/hypothesis-6.168.5-cp314-cp314-win_amd64.whl", hash = "sha256:71ce0599e806ce3a68f9f118edf450bf091e11b134f6bcc5f8dd706b42c91ebc", size = 681884, upload-time = "2026-10-05T23:25:00.757Z" }, + { url = "https://files.pythonhosted.org/packages/bd/4b/4fc2b5970df0c27668ec08abc505f1d01314a69953f89dc0edc6528ff5a0/hypothesis-6.168.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f66b02c9e95e916a2c58f725a92377ec988146ed7b5aeccd5e78ceecac1eae6f", size = 791636, upload-time = "2026-10-05T23:26:22.365Z" }, + { url = "https://files.pythonhosted.org/packages/04/b2/03cdf5f052dcb441e045be1fd0aa531e85cde1a1cbabab60968625c570a3/hypothesis-6.168.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bab27926e1d1575fb43b70d4aeece05b74a5e477af0509b56cb6fd778070dd93", size = 783280, upload-time = "2026-10-05T23:26:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d8/615557af244e2f3ce4763029c03a62ed82dcbbd646b72a8c479ef0408b33/hypothesis-6.168.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edeb42c3009b5652dc1c44907ec91bfe9284100ad5e57993dfebabb76f2961a1", size = 1112149, upload-time = "2026-10-05T23:24:13.526Z" }, + { url = "https://files.pythonhosted.org/packages/9f/67/a6707fcd51dc5f2531bf88ac072e99f31ab9d8020488b01349a6d2981081/hypothesis-6.168.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8977456328147c521a16a089325017b2c728fddc23351693a4fd924cc7fc7001", size = 1155637, upload-time = "2026-10-05T23:24:24.047Z" }, + { url = "https://files.pythonhosted.org/packages/85/d4/ac2e852d2f163afd398854662bcbb0b849a767abbf2f95a75de6f685f821/hypothesis-6.168.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:36ecf7ac351f9c0b5489ba800884b607da754e88ef40713fbfcc170d2151e6eb", size = 1287040, upload-time = "2026-10-05T23:25:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/23/07/f77b1602704bda6ff3d9d0817120bd7fb94fd792bd25508b36ce4b8bd2a2/hypothesis-6.168.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0333aa5129ba3019a83fb81a7f0fc238180e415a9edddd9a15101f8deaaa517e", size = 1322166, upload-time = "2026-10-05T23:25:45.39Z" }, + { url = "https://files.pythonhosted.org/packages/6d/2e/94138a73e0906b31cb5968d20be58688f582a09e5958f2c75d45a7049545/hypothesis-6.168.5-cp314-cp314t-win_amd64.whl", hash = "sha256:2fcb87341d76ae0183e8219c9a14d55957c50d14973879db5fea3e81da45ba1a", size = 681599, upload-time = "2026-10-05T23:24:37.827Z" }, + { url = "https://files.pythonhosted.org/packages/92/13/92cb8092b680be2b6ec5ffe83b9f1a98dbf414117566f9e3ba4e8b569214/hypothesis-6.168.5-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:453ab7d0a1fadbaa54ae8722d22463cc2046fa8ef25b9b88715d28279bf79fc1", size = 790950, upload-time = "2026-10-05T23:24:45.852Z" }, + { url = "https://files.pythonhosted.org/packages/e6/22/78aea12694e3d1177e2980d44798b6d93e191faf59155b18bf5ae315f6a2/hypothesis-6.168.5-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:bbdbc43d1f9dad595b249b7bbe8ee5102bc94a4fcb0a79ff76d20e41fcfe342a", size = 782939, upload-time = "2026-10-05T23:25:39.961Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/5ef9947b2d149f773428e555bdf66688405aa5167510bdbe97c8ec5c6090/hypothesis-6.168.5-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2bc36194d7b6083591060836c7872711a6820217b325bf432dd7e10b3d4af5cb", size = 1111380, upload-time = "2026-10-05T23:24:39.087Z" }, + { url = "https://files.pythonhosted.org/packages/e1/65/7e668e203fb2659c6214dc0c24cc09b7dea8a02c7c8d0ad338f644a054c4/hypothesis-6.168.5-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:22425e2b1543a43c157a81472c713ba8f291cbaf054c70ffe128e2cacc294f65", size = 1142644, upload-time = "2026-10-05T23:26:30.697Z" }, + { url = "https://files.pythonhosted.org/packages/c0/77/b112978676e795658d58c4294bf90cdbb8cb56cb8292c8c4874650468cf9/hypothesis-6.168.5-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eea0bc513d0e38d1d5ddfb581132928871cd02dc54dfe4511a5396727c48e9d0", size = 1135966, upload-time = "2026-10-05T23:24:49.806Z" }, + { url = "https://files.pythonhosted.org/packages/e6/27/cd3bf01e8246c4318ec3df15f5eeeee3214f444df0129a6c7f9a62859ee8/hypothesis-6.168.5-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eb142bc70bbf6645e15c7ca72de3f7c8dae198aa2743a609f4f3e3bb4f9c3a52", size = 1189646, upload-time = "2026-10-05T23:26:04.471Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a6/4d3e882f31c289e432dfec34dbb9029296038a8c69e8b28cebb0a5fb7ea8/hypothesis-6.168.5-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a27b758707bd37f5a1759cca6eef83fe1a212c38dc4ca0a203434004c5647d15", size = 1154770, upload-time = "2026-10-05T23:25:10.814Z" }, + { url = "https://files.pythonhosted.org/packages/7f/89/96f5455e1b3d0409cbbb1434c98e792bcceefd614a4b11e600072520b487/hypothesis-6.168.5-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:77a111cb50c330fa7098f65852fa17a01ecd781a85be3cf5e5871bdeeeb0ecbc", size = 1109925, upload-time = "2026-10-05T23:25:26.369Z" }, + { url = "https://files.pythonhosted.org/packages/3d/64/0758985d9d36f0c5ec981a1457ea1c8173f62d46a94531417aec117df4d7/hypothesis-6.168.5-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cdd0afc13e86ec76cae3d3659569c1f601f4e9ca52b5cf91c1685979eae64d7b", size = 1148476, upload-time = "2026-10-05T23:24:54.552Z" }, + { url = "https://files.pythonhosted.org/packages/43/5c/a9b8953e1d8aefcd3c22cf8d10dd8acf93e602b903278e2e51cf8544ccea/hypothesis-6.168.5-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:5fefb02035864c3d322e3b0969b296250923fdcfb574ea1ad4374f1a6333f663", size = 1285856, upload-time = "2026-10-05T23:25:38.239Z" }, + { url = "https://files.pythonhosted.org/packages/bf/37/66098444dc832523ddc4f2e05723662834e5f99bba3c759615d059f6420e/hypothesis-6.168.5-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:9db8aa1f5529e1b577ec18b775c2fb4225821712e946f7762b90c966604faf83", size = 1415500, upload-time = "2026-10-05T23:24:21.682Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d3/e971b6fe20ef8d7c2019cbf24b4f6149468efc88c42a744f5bc99e6ca0ed/hypothesis-6.168.5-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:59e07d2f62b5ff573b0059959ae9cef9edfb0f5393fdb35ea81fce1ee77b27ac", size = 1368623, upload-time = "2026-10-05T23:26:11.292Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ac/b279dfbd2c06cdb3030ba7eea042cb2cf0171d0013563d103d5207dde63b/hypothesis-6.168.5-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:8a03ca128bea29d6826fc545f1f6289fb1ea2e83a5bb811321761b2d515ca575", size = 1264457, upload-time = "2026-10-05T23:25:21.073Z" }, + { url = "https://files.pythonhosted.org/packages/55/57/16ac9f8ddfada1cd278bd2185234d0d36ebd304926b69ad0497c210c6fed/hypothesis-6.168.5-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:5c03f2d3f84f626f3fd07f54573ab40455e1a1996e98a4f4971caf8b7e796afe", size = 1281744, upload-time = "2026-10-05T23:24:31.263Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d1/99a44430b82998fdef0ffd7d353f64ee5f078c2805ff70f8677ee102cb6c/hypothesis-6.168.5-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:2bdf8ce9b72a620cd5ec4dd6b1c1837ff6971489a863851d11d9b0f58dd4062a", size = 1321504, upload-time = "2026-10-05T23:26:00.583Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6a/58ef2564d1985a5c1a1dc57906b8363a767094abca180e80a0aca4cb635f/hypothesis-6.168.5-cp315-abi3.abi3t-win32.whl", hash = "sha256:5c3abbef7b17571fd713b0922407d9cd8cbc652254c0f462875f15199fcb29f7", size = 674959, upload-time = "2026-10-05T23:24:36.482Z" }, + { url = "https://files.pythonhosted.org/packages/a3/90/153414f55eb0c85bd9d891bd7811d746978c7ad3de81ea79eeb4e62e088b/hypothesis-6.168.5-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:38172199abab94a04bc017613e055faa796d7175fbc6221aac504d406c960b60", size = 681329, upload-time = "2026-10-05T23:26:08.897Z" }, + { url = "https://files.pythonhosted.org/packages/6d/63/117c82f08ab3ba1dcfbf6562ac43b8deb8efa8106646494fadd15122cc1b/hypothesis-6.168.5-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:0600ddc24c32dab5ca8e780630ab6e2561df6d7f594f781d0608b38e04c4da91", size = 679632, upload-time = "2026-10-05T23:24:33.753Z" }, + { url = "https://files.pythonhosted.org/packages/73/25/5c38b739fb778d4de48aab6509b9cf0afd0317bb0459741afdcd0ad44aed/hypothesis-6.168.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:6786049db92275e0c5cfac7dfcda6d4bbc80bdf84cbc8c9c7171ca17f47b5aac", size = 792028, upload-time = "2026-10-05T23:24:56.365Z" }, + { url = "https://files.pythonhosted.org/packages/7b/3f/91071d53240f5f13ab1dda286e3ddb33177537dbf55cede76e7f4a3856db/hypothesis-6.168.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ffbde24430dcd73231fd03324a934e0f638f7c0899fc566f3ef8c851534f8030", size = 787324, upload-time = "2026-10-05T23:24:17.822Z" }, + { url = "https://files.pythonhosted.org/packages/10/ef/eb262e50d7741de6c49d27923e2c282d079273b8bcdacde33165ea39488d/hypothesis-6.168.5-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea967baaedfd532f1a521aaedafc66bb9de09795071492b0e7252139df38479f", size = 1115118, upload-time = "2026-10-05T23:24:20.43Z" }, + { url = "https://files.pythonhosted.org/packages/87/67/a655a8666164aa896516f919af272fa3a3a00d2786be880c31bb638e79e2/hypothesis-6.168.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b2f98289a5da876c08b9eeb68d1cfdfbd0fcc110cf364d33c3cc32cf229ffe8", size = 1157894, upload-time = "2026-10-05T23:26:28.641Z" }, + { url = "https://files.pythonhosted.org/packages/57/4d/71c422a29446c03e9a052f10b8ee527044242e71e3c0139100991f721e16/hypothesis-6.168.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:e313a01ce580180dc3bb8fa98ddd0ffb20e51e108d9fa747ba6c1596790dc3fa", size = 685030, upload-time = "2026-10-05T23:24:09.964Z" }, +] + [[package]] name = "idna" version = "3.13" @@ -2815,6 +2903,7 @@ dependencies = [ dev = [ { name = "build" }, { name = "coverage" }, + { name = "hypothesis" }, { name = "mypy" }, { name = "pytest" }, { name = "pytest-snapshot" }, @@ -2841,6 +2930,7 @@ requires-dist = [ { name = "google-cloud-secret-manager", specifier = ">=2,<3" }, { name = "gunicorn" }, { name = "httpx", specifier = ">=0.27.0" }, + { name = "hypothesis", marker = "extra == 'dev'", specifier = ">=6.100" }, { name = "markupsafe", specifier = ">=3,<4" }, { name = "microdf-python", specifier = ">=1.0.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.15,<2" },