diff --git a/api/docs/adr/000-vertical-slice-architecture.md b/api/docs/adr/000-vertical-slice-architecture.md index 43981105..5f6e7547 100644 --- a/api/docs/adr/000-vertical-slice-architecture.md +++ b/api/docs/adr/000-vertical-slice-architecture.md @@ -66,14 +66,14 @@ damnit_api/ │ ├── core/ # framework-free, imports nothing app-specific: │ ├── errors.py # Shared error classes; see ADR-001 -│ ├── types.py # `ProposalNumber`, pure value types (no I/O) +│ ├── types.py # `ProposalNumber`, pure value types (no I/O); see ADR-004 │ ├── const.py # DamnitType etc. │ └── conversions.py # b64image, blob2numpy, type mapping │ ├── proposals/ # proposal identity, metadata, discovery │ ├── models.py # `ProposalMeta` + domain models │ ├── services.py # fetch/cache/upsert proposal metadata (auth-free) -│ ├── locator.py # `ProposalPathLocator` implementations +│ ├── locator.py # `ProposalPathLocator` implementations; see ADR-004 │ ├── routers.py │ └── gql.py │ diff --git a/api/docs/adr/004-proposal-path-locator.md b/api/docs/adr/004-proposal-path-locator.md new file mode 100644 index 00000000..d7687014 --- /dev/null +++ b/api/docs/adr/004-proposal-path-locator.md @@ -0,0 +1,35 @@ +--- +date: 2026-07-07 +--- + +# ADR-004 - Proposal path resolution: pure value type, injectable locator + +## Context and Problem Statement + +A proposal number arrives as an untyped string or integer and is used in three ways: as a GraphQL input, as a filesystem path to the proposal's DAMNIT directory, and as a persistence key. Turning a number into a directory requires filesystem probing on GPFS - globbing under the proposal root, checking candidate directories, stat-ing for read-only status. GPFS is a network filesystem, so those calls can stall for seconds, and a hung mount can stall them indefinitely. + +Two questions follow. Where does a proposal number get validated and formatted? Where does number-to-directory resolution live? + +## Considered Options + +- One `ProposalNumber` type that both validates and resolves its own path, reading configuration directly (the reference tree's approach: a blocking `find_damnit_path_sync` method). +- A pure `ProposalNumber` value type plus a separate injectable `ProposalPathLocator` that owns resolution. + +## Decision Outcome + +Chosen option: a pure value type plus a separate locator, because it keeps the value type free of I/O and settings, and isolates slow GPFS access behind a swappable, testable seam. + +### Consequences + +- Good: `ProposalNumber` validation and formatting are pure and trivially unit-testable, with no settings coupling. +- Good: path heuristics live in one place behind a protocol, swappable for local mode and for tests. +- Bad: resolution becomes asynchronous once fully adopted, which will ripple into repository acquisition and the resolvers that call it. + +## Details + +### The rules + +1. `ProposalNumber` (target `core/types.py`, today `shared/models.py`) is a pure value type. It validates (1-999999, rejects floats), formats to the canonical `p{n:06d}` form, and carries a pydantic schema. It does no I/O and reads no settings. +2. Number-to-directory resolution belongs in a `ProposalPathLocator` (target `proposals/locator.py`), built by a factory, held on `AppState`, and injected where resolution is needed. Implementations are a GPFS locator (production glob heuristics) and a fixed locator (local mode and tests). +3. Filesystem calls in request paths run off the event loop with timeouts. A hung GPFS mount degrades one request, not the whole server. +4. A failed resolution raises `ProposalNotFoundError` (see [ADR-001](001-error-classes.md)) at the edge, not a bare error from a constructor. diff --git a/api/docs/architecture.md b/api/docs/architecture.md index 18a87b12..32d44162 100644 --- a/api/docs/architecture.md +++ b/api/docs/architecture.md @@ -19,13 +19,13 @@ For more information, see [ADR-000](adr/000-vertical-slice-architecture.md). | Package | Capability | What belongs there | Status | Today | | --------------------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ------- | ------------------------------------------------------------------------- | | `runs/` | Run/variable data - the core domain | Domain models, repository interface + implementations, serialisation, preview extraction, its GraphQL types and resolvers | Planned | `db.py` + `data.py`; resolvers in `graphql/queries.py`/`subscriptions.py` | -| `proposals/` | Proposal metadata and lookup | Proposal models, MyMdC-backed metadata services, path locator | Planned | `metadata/` | +| `proposals/` | Proposal metadata and lookup | Proposal models, MyMdC-backed metadata services, path locator (see [ADR-004](adr/004-proposal-path-locator.md)) | Planned | `metadata/` | | `auth/` | Authentication and authorisation | OAuth flow, sessions, token store, `User`, permission classes, the membership policy | Partial | Policy still in `metadata/services.py` | | `contextfile/` | Context-file viewing | File reading, watching, its routes | Done | As-is | | `graphql/` | GraphQL transport only | Schema assembly, context, directives, controller binding - no resolvers, no domain logic | Partial | Assembly still in `shared/gql.py`; resolvers still here | | `appdb/` | The app's own database (infrastructure) | Models, engine/session plumbing for `dw_api.sqlite` | Planned | `_db/` | | `mymdc/` | MyMdC client (infrastructure) | Ports, clients, vendored models | Planned | `_mymdc/` | -| `core/` | Cross-cutting, framework-free | Shared error classes (see [ADR-001](adr/001-error-classes.md)), `DamnitType`, value types, converters | Planned | `shared/` + `utils.py` | +| `core/` | Cross-cutting, framework-free | Shared error classes (see [ADR-001](adr/001-error-classes.md)), `DamnitType`, value types (see [ADR-004](adr/004-proposal-path-locator.md)), converters | Planned | `shared/` + `utils.py` | | `main.py` / `app.py` / `state.py` | Composition root | `AppState`, `create_*` factories (see [ADR-002](adr/002-no-global-mutable-state.md)), `create_app()` - the only place that may import everything and read settings | Partial | `main.py` + `state.py` | Where new code goes: diff --git a/api/pyproject.toml b/api/pyproject.toml index b6f26779..ff1f62df 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -51,7 +51,11 @@ test = [ "pytest-recording>=0.13.4", "syrupy>=5.5.2", ] -lint = ["pyright>=1.1.406", "ruff>=0.7"] +lint = [ + "pyright>=1.1.406", + "ruff>=0.7", + "ty>=0.0.42", +] docs = [ "zensical>=0.0.47", "mkdocstrings-python>=2.0.3", diff --git a/api/src/damnit_api/_mymdc/clients.py b/api/src/damnit_api/_mymdc/clients.py index d625a0b5..5324c10e 100644 --- a/api/src/damnit_api/_mymdc/clients.py +++ b/api/src/damnit_api/_mymdc/clients.py @@ -108,7 +108,7 @@ def __init__(self, auth: MyMdCAuth) -> None: super().__init__(auth=auth, base_url=api_url) async def _get_proposal_by_number(self, no: models.ProposalNumber): - response = await self.get(f"proposals/by_number/{no}") + response = await self.get(f"proposals/by_number/{no:d}") response.raise_for_status() return response.json() @@ -179,7 +179,7 @@ async def _replay(self, path: str) -> dict: return orjson.loads(body) async def _get_proposal_by_number(self, no: models.ProposalNumber): - return await self._replay(f"proposals/by_number/{no}") + return await self._replay(f"proposals/by_number/{no:d}") async def _get_user_by_id(self, id: models.UserId): return await self._replay(f"users/{id}") diff --git a/api/src/damnit_api/auth/gql.py b/api/src/damnit_api/auth/gql.py index aff3ad38..7faa1e75 100644 --- a/api/src/damnit_api/auth/gql.py +++ b/api/src/damnit_api/auth/gql.py @@ -7,6 +7,7 @@ from ..metadata.gql import ProposalMeta from ..metadata.services import _get_proposal_meta_many +from ..shared.models import ProposalNumber from .models import OAuthUserInfo if TYPE_CHECKING: @@ -43,7 +44,9 @@ async def proposals( proposals = await mymdc.get_user_proposals(self.preferred_username) proposal_numbers = [ - p.proposal_number for p in proposals.root if p.proposal_number is not None + ProposalNumber(p.proposal_number) + for p in proposals.root + if p.proposal_number is not None ] proposals_meta = await _get_proposal_meta_many( diff --git a/api/src/damnit_api/auth/models.py b/api/src/damnit_api/auth/models.py index a2769b36..2cfc226e 100644 --- a/api/src/damnit_api/auth/models.py +++ b/api/src/damnit_api/auth/models.py @@ -8,6 +8,7 @@ from .. import get_logger from .._db.dependencies import DBSession from .._mymdc.dependencies import MyMdCClient +from ..shared.models import ProposalNumber logger = get_logger() @@ -64,17 +65,17 @@ def from_connection(cls, connection: HTTPConnection) -> Self: class ProposalsByYearHalf(RootModel): """Mapping of year half (e.g. 202401, 202402) to list of proposal numbers.""" - root: dict[int, list[int]] + root: dict[int, list[ProposalNumber]] class User(BaseUserInfo): """Full user information including list of proposals.""" - _member_proposals: list[int] = PrivateAttr(default_factory=list) + _member_proposals: list[ProposalNumber] = PrivateAttr(default_factory=list) proposals_by_year_half: ProposalsByYearHalf @property - def proposals(self) -> list[int]: + def proposals(self) -> list[ProposalNumber]: """Proposals the user is a member of (raw MyMdC membership).""" return self._member_proposals @@ -113,7 +114,9 @@ async def from_oauth_user( proposals = await mymdc.get_user_proposals(oauth.preferred_username) member_proposals = [ - p.proposal_number for p in proposals.root if p.proposal_number is not None + ProposalNumber(p.proposal_number) + for p in proposals.root + if p.proposal_number is not None ] proposals_meta = await _get_proposal_meta_many( diff --git a/api/src/damnit_api/auth/permissions.py b/api/src/damnit_api/auth/permissions.py index e8998b9b..8938ae46 100644 --- a/api/src/damnit_api/auth/permissions.py +++ b/api/src/damnit_api/auth/permissions.py @@ -32,26 +32,17 @@ async def has_permission(self, source, info: Info, **kwargs) -> bool: msg = "Field is misconfigured for proposal authorization." raise StrawberryGraphQLError(msg) - proposal_str = getattr(kwargs["database"], "proposal", None) - if not proposal_str: + proposal = getattr(kwargs["database"], "proposal", None) + if not proposal: return False - try: - proposal = int(proposal_str.strip("p")) - except (ValueError, TypeError): - logger.info("Invalid proposal identifier", proposal=proposal_str) - # NOTE: Strawberry shares one permission instance across all requests, so a - # per-call message must be raised, not stored on `self`. - msg = "Invalid proposal identifier." - raise StrawberryGraphQLError(msg) from None - try: user = await info.context.get_user() except Exception as exc: # Do not respond with upstream errors directly, might contain internal # info that shouldn't be sent to client. msg = "Could not verify proposal access" - logger.exception(msg, proposal=proposal_str) + logger.exception(msg, proposal=proposal) raise StrawberryGraphQLError(msg) from exc try: diff --git a/api/src/damnit_api/graphql/queries.py b/api/src/damnit_api/graphql/queries.py index faa6aad7..8692fda0 100644 --- a/api/src/damnit_api/graphql/queries.py +++ b/api/src/damnit_api/graphql/queries.py @@ -10,6 +10,7 @@ from ..runs.preview import get_preview_data from ..runs.sqlite import async_table, get_session from ..runs.types import KNOWN_DTYPES, DamnitRun +from ..shared.models import ProposalNumber from .metadata import fetch_metadata from .utils import DatabaseInput, fetch_info @@ -20,7 +21,7 @@ RUN_INFO_NAMES = frozenset(KNOWN_DTYPES) - {"proposal", "run"} -async def _ensure_damnit_path(info: Info, proposal: str) -> None: +async def _ensure_damnit_path(info: Info, proposal: ProposalNumber) -> None: """Ensure the proposal has a DAMNIT path, refreshing from MyMdC if needed. Authorization is handled separately by IsProposalMember before this runs. @@ -31,12 +32,12 @@ async def _ensure_damnit_path(info: Info, proposal: str) -> None: return meta = await _get_proposal_meta( - info.context.mymdc, int(proposal), info.context.session + info.context.mymdc, proposal, info.context.session ) if not meta.damnit_path: logger.info("No damnit path found, updating proposal metadata") meta = await _update_proposal_meta( - info.context.mymdc, int(proposal), info.context.session + info.context.mymdc, proposal, info.context.session ) if not meta.damnit_path: msg = "No damnit path found after updating proposal metadata." @@ -210,11 +211,6 @@ async def metadata( database: DatabaseInput, ) -> JSON: # FIX: # pyright: ignore[reportInvalidTypeForm] proposal = database.proposal - if not proposal: - msg = "Proposal number is required." - # TODO: custom exceptions - raise ValueError(msg) - await _ensure_damnit_path(info, proposal) snapshot = await fetch_metadata(info.context.damnit_registry, proposal) @@ -235,7 +231,7 @@ async def extracted_data( # TODO: Convert to Strawberry type # and make it analogous to DamitVariable; e.g. `data` return get_preview_data( # FIX: # pyright: ignore[reportReturnType] - proposal=database.proposal, + proposal_number=database.proposal, run=run, variable=variable, ) diff --git a/api/src/damnit_api/graphql/utils.py b/api/src/damnit_api/graphql/utils.py index 6f3738bc..71d6c99b 100644 --- a/api/src/damnit_api/graphql/utils.py +++ b/api/src/damnit_api/graphql/utils.py @@ -7,11 +7,14 @@ from ..runs.sqlite import DamnitDBRegistry, async_table, get_session from ..shared.const import DEFAULT_PROPOSAL +from ..shared.models import ProposalNumber @strawberry.input class DatabaseInput: - proposal: str | None = strawberry.field(default=DEFAULT_PROPOSAL) + proposal: ProposalNumber = strawberry.field( + default=ProposalNumber(DEFAULT_PROPOSAL) + ) path: str | None = strawberry.field(default=strawberry.UNSET) diff --git a/api/src/damnit_api/metadata/gql.py b/api/src/damnit_api/metadata/gql.py index 3bc1cb7f..a17b1880 100644 --- a/api/src/damnit_api/metadata/gql.py +++ b/api/src/damnit_api/metadata/gql.py @@ -9,6 +9,9 @@ from .. import get_logger from ..auth.permissions import IsAuthenticated +from ..shared.models import ( + ProposalNumber, # noqa: TC001 (Strawberry resolves at runtime) +) from . import models, services if TYPE_CHECKING: @@ -37,7 +40,7 @@ class Query: async def proposal_metadata( self, info: strawberry.Info[Context], - proposal_numbers: list[int], + proposal_numbers: list[ProposalNumber], ) -> list[ProposalMeta] | None: """Fetch metadata for the given proposal numbers. diff --git a/api/src/damnit_api/metadata/services.py b/api/src/damnit_api/metadata/services.py index 3610886d..f8798dd1 100644 --- a/api/src/damnit_api/metadata/services.py +++ b/api/src/damnit_api/metadata/services.py @@ -43,23 +43,24 @@ def _local_proposal_meta(proposal_number: ProposalNumber) -> ProposalMeta: ) -async def _local_proposal_number(registry: "DamnitDBRegistry") -> int | None: +async def _local_proposal_number(registry: "DamnitDBRegistry") -> ProposalNumber | None: from sqlalchemy import select from ..runs.sqlite import async_table, get_session from ..shared.const import DEFAULT_PROPOSAL - table = await async_table(registry, DEFAULT_PROPOSAL, name="metameta") + proposal = ProposalNumber(DEFAULT_PROPOSAL) + table = await async_table(registry, proposal, name="metameta") if table is None: return None - async with get_session(registry, DEFAULT_PROPOSAL) as session: + async with get_session(registry, proposal) as session: result = await session.execute( select(table.c.value).where(table.c.key == "proposal") ) value = result.scalar() - return int(value) if value else None + return ProposalNumber(int(value)) if value else None async def _fetch_proposal_meta( diff --git a/api/src/damnit_api/runs/preview.py b/api/src/damnit_api/runs/preview.py index 71041e33..90e9ad8f 100644 --- a/api/src/damnit_api/runs/preview.py +++ b/api/src/damnit_api/runs/preview.py @@ -6,14 +6,15 @@ from PIL import Image from ..shared.const import DamnitType +from ..shared.models import ProposalNumber from ..utils import b64image from .sqlite import get_damnit_path NOT_SUPPORTED_MESSAGE = "Not supported." -def get_preview_data(proposal, run, variable): - path = get_damnit_path(str(proposal)) +def get_preview_data(proposal_number: ProposalNumber, run, variable): + path = get_damnit_path(proposal_number) try: var_data = Damnit(path)[run, variable] except KeyError: diff --git a/api/src/damnit_api/runs/sqlite/session.py b/api/src/damnit_api/runs/sqlite/session.py index e690cae3..18cee099 100644 --- a/api/src/damnit_api/runs/sqlite/session.py +++ b/api/src/damnit_api/runs/sqlite/session.py @@ -14,9 +14,12 @@ from sqlalchemy.pool import NullPool from ...shared.const import DEFAULT_PROPOSAL +from ...shared.errors import ProposalNotFoundError +from ...shared.models import ProposalNumber from ...utils import find_proposal DAMNIT_PATH = "usr/Shared/amore/" +_DEFAULT_PROPOSAL = ProposalNumber(DEFAULT_PROPOSAL) # ----------------------------------------------------------------------------- @@ -24,7 +27,7 @@ class DatabaseSessionManager: - def __init__(self, proposal: str = DEFAULT_PROPOSAL): + def __init__(self, proposal: ProposalNumber = _DEFAULT_PROPOSAL): self.proposal = proposal self.root_path = get_damnit_path(proposal) self._engine = create_async_engine( @@ -97,16 +100,16 @@ class DamnitDBRegistry: """Per-proposal DAMNIT database registry.""" def __init__(self) -> None: - self._managers: dict[str, DatabaseSessionManager] = {} + self._managers: dict[ProposalNumber, DatabaseSessionManager] = {} - def get(self, proposal: str) -> DatabaseSessionManager: + def get(self, proposal: ProposalNumber) -> DatabaseSessionManager: if proposal not in self._managers: self._managers[proposal] = DatabaseSessionManager(proposal) return self._managers[proposal] def get_session( - registry: DamnitDBRegistry, proposal: str + registry: DamnitDBRegistry, proposal: ProposalNumber ) -> AbstractAsyncContextManager[AsyncSession]: return registry.get(proposal).session() @@ -115,15 +118,15 @@ def get_session( # Etc. -def get_damnit_path(proposal_number: str = DEFAULT_PROPOSAL) -> str: +def get_damnit_path(proposal: ProposalNumber = _DEFAULT_PROPOSAL) -> str: """Returns the directory of the given proposal.""" from ...shared.settings import settings if settings.is_local: return str(settings.damnit_path) - path = find_proposal(proposal_number) + path = find_proposal(proposal) if not path: - msg = f"Proposal '{proposal_number}' is not found." - raise RuntimeError(msg) + msg = f"Proposal '{proposal}' is not found." + raise ProposalNotFoundError(msg) return str(Path(path) / DAMNIT_PATH) diff --git a/api/src/damnit_api/runs/types.py b/api/src/damnit_api/runs/types.py index 8e4c44b9..21381d36 100644 --- a/api/src/damnit_api/runs/types.py +++ b/api/src/damnit_api/runs/types.py @@ -6,6 +6,7 @@ from .. import get_logger from ..shared.const import DamnitType +from ..shared.models import ProposalNumber from ..utils import ( create_map, python_type_to_damnit_type, @@ -42,6 +43,12 @@ class KnownVariable: name="Timestamp", parse_value=lambda value: value / 1000, ), + ProposalNumber: strawberry.scalar( + name="ProposalNo", + serialize=int, + parse_value=ProposalNumber, + description="Proposal number (positive integer, 1-999999)", + ), } strawberry.enum(DamnitType, graphql_name_from="value") diff --git a/api/src/damnit_api/shared/models.py b/api/src/damnit_api/shared/models.py index 44a8aaa8..bb509970 100644 --- a/api/src/damnit_api/shared/models.py +++ b/api/src/damnit_api/shared/models.py @@ -1,11 +1,50 @@ """Shared Models for Damnit API.""" -from typing import Annotated +import re +from typing import Annotated, Self from pydantic import Field -ProposalNumber = Annotated[int, Field(gt=0, lt=999_9999)] +_PROPOSAL_STR_PATTERN = re.compile(r"^p?(\d{1,6})$") + + +class ProposalNumber(int): + """Validated proposal number (1-999999). + + ``str(pn)`` returns the canonical ``p{n:06d}`` form (e.g. ``"p001234"``). + Format specs (``f"{pn:d}"``, ``"%d" % pn``) bypass ``__str__`` and produce + plain integer output. + """ + + def __new__(cls, value: int | str) -> Self: + if isinstance(value, float): + msg = f"Expected int or str, got float: {value!r}" + raise TypeError(msg) + if isinstance(value, str): + m = _PROPOSAL_STR_PATTERN.fullmatch(value) + if not m: + msg = f"Invalid proposal number: {value!r}" + raise ValueError(msg) + value = int(m.group(1)) + if not (0 < value < 1_000_000): + msg = f"Proposal number out of range: {value}" + raise ValueError(msg) + return super().__new__(cls, value) + + def __str__(self) -> str: + return f"p{self:06d}" + + def __repr__(self) -> str: + return f"ProposalNumber({int(self)!r})" + + @classmethod + def __get_pydantic_core_schema__(cls, source_type, handler): + from pydantic_core import core_schema + + return core_schema.no_info_plain_validator_function( + cls, + serialization=core_schema.plain_serializer_function_ser_schema(int), + ) -ProposalId = Annotated[int, Field(gt=0, lt=999_9999)] ProposalCycle = Annotated[str, Field(pattern=r"^\d{6}$")] diff --git a/api/src/damnit_api/utils.py b/api/src/damnit_api/utils.py index 39bf0235..ede15b38 100644 --- a/api/src/damnit_api/utils.py +++ b/api/src/damnit_api/utils.py @@ -8,6 +8,7 @@ import numpy as np from .shared.const import DamnitType +from .shared.models import ProposalNumber DEFAULT_ARRAY_NAME = "__xarray_dataarray_variable__" @@ -57,31 +58,13 @@ def blob2numpy(data: bytes) -> np.ndarray: DATA_ROOT_DIR = "/gpfs/exfel/exp" -def format_proposal_number(proposal): - """Format a given unformatted proposal number." - - Lifted and modified from extra_data.reader.py - https://github.com/European-XFEL/EXtra-data/blob/master/extra_data/reader.py - """ - if not proposal.startswith("p"): - proposal = "p" + proposal.rjust(6, "0") - - return proposal - - -def find_proposal(propno): - """Find the proposal directory for a given proposal on Maxwell +def find_proposal(proposal: ProposalNumber) -> str: + """Find the proposal directory for a given proposal on Maxwell. Lifted and modified from extra_data.read_machinery.py https://github.com/European-XFEL/EXtra-data/blob/master/extra_data/read_machinery.py """ - - if "/" in propno: - # Already passed a proposal directory - return propno - - propno = format_proposal_number(propno) - for d in iglob(osp.join(DATA_ROOT_DIR, f"*/*/{propno}")): # noqa: PTH118, PTH207 + for d in iglob(osp.join(DATA_ROOT_DIR, f"*/*/{proposal}")): # noqa: PTH118, PTH207 return d return "" diff --git a/api/tests/graphql/test_permissions.py b/api/tests/graphql/test_permissions.py index af53849f..c309dad4 100644 --- a/api/tests/graphql/test_permissions.py +++ b/api/tests/graphql/test_permissions.py @@ -13,6 +13,7 @@ from damnit_api.auth.permissions import IsAuthenticated, IsProposalMember from damnit_api.shared.errors import ForbiddenError +from damnit_api.shared.models import ProposalNumber def _info(context: Any) -> Any: @@ -68,18 +69,6 @@ async def test_is_proposal_member_none_proposal(): assert result is False -@pytest.mark.asyncio -async def test_is_proposal_member_malformed_proposal(): - # Parsing fails before any user resolution / network call, so no mocks needed. - perm = IsProposalMember() - info = _info(_context()) - database = SimpleNamespace(proposal="not-a-number") - - with pytest.raises(StrawberryGraphQLError, match=r"Invalid proposal identifier\."): - await perm.has_permission(None, info, database=database) - assert perm.message == "Access to this proposal is forbidden." - - @pytest.mark.asyncio async def test_is_proposal_member_allowed(mocker): check = mocker.patch( @@ -87,14 +76,14 @@ async def test_is_proposal_member_allowed(mocker): new_callable=mocker.AsyncMock, ) ctx = _context(user="resolved-user") - database = SimpleNamespace(proposal="p1234") + database = SimpleNamespace(proposal=ProposalNumber(1234)) result = await IsProposalMember().has_permission( None, _info(ctx), database=database ) assert result is True ctx.get_user.assert_awaited_once() - check.assert_awaited_once_with(1234, "resolved-user") + check.assert_awaited_once_with(ProposalNumber(1234), "resolved-user") @pytest.mark.asyncio @@ -104,7 +93,7 @@ async def test_is_proposal_member_safe_upstream_error(): ctx.get_user.side_effect = RuntimeError( "https://in.xfel.eu/metadata/api/ sensitive error beep boop" ) - database = SimpleNamespace(proposal="p1234") + database = SimpleNamespace(proposal=ProposalNumber(1234)) with pytest.raises(StrawberryGraphQLError) as excinfo: await IsProposalMember().has_permission(None, _info(ctx), database=database) @@ -118,7 +107,7 @@ async def test_is_proposal_member_forbidden(mocker): new_callable=mocker.AsyncMock, side_effect=ForbiddenError("nope"), ) - database = SimpleNamespace(proposal="1234") + database = SimpleNamespace(proposal=ProposalNumber(1234)) result = await IsProposalMember().has_permission( None, _info(_context()), database=database diff --git a/api/tests/graphql/test_queries.py b/api/tests/graphql/test_queries.py index c8a827bb..adc7431e 100644 --- a/api/tests/graphql/test_queries.py +++ b/api/tests/graphql/test_queries.py @@ -4,6 +4,7 @@ from damnit_api.runs.sqlite import DAMNIT_PATH, DatabaseSessionManager from damnit_api.runs.types import DamnitRun +from damnit_api.shared.models import ProposalNumber from .const import ( EXAMPLE_DATA, @@ -123,7 +124,7 @@ async def real_damnit_db(mocker, tmp_path): """Real on-disk sqlite with a populated `run_variables` table, wired via `find_proposal`. Yields the proposal id. """ - proposal = "999999" + proposal = ProposalNumber(999999) proposal_root = tmp_path / "proposal" (proposal_root / DAMNIT_PATH).mkdir(parents=True) @@ -255,7 +256,7 @@ async def test_runs_query_fetches_run_info_when_metadata_requested( @pytest.mark.asyncio async def test_metadata_query(graphql_schema): query = """ - query TableMetadataQuery($proposal: String) { + query TableMetadataQuery($proposal: ProposalNo!) { metadata(database: { proposal: $proposal }) } """ @@ -331,3 +332,36 @@ async def test_extracted_data_unauthorized(graphql_schema_no_auth): assert result.errors is not None assert result.errors[0].message == "Authentication required." + + +# ----------------------------------------------------------------------------- +# ProposalNo scalar boundary: out-of-range proposals are rejected at input +# coercion, before any resolver or permission runs. + + +@pytest.mark.asyncio +async def test_runs_rejects_out_of_range_proposal(graphql_schema): + query = """ + query { + runs(database: {proposal: 1000000}) { + variables { name } + } + } + """ + result = await graphql_schema.execute(query) + + assert result.errors is not None + + +@pytest.mark.asyncio +async def test_runs_rejects_negative_proposal(graphql_schema): + query = """ + query { + runs(database: {proposal: -1}) { + variables { name } + } + } + """ + result = await graphql_schema.execute(query) + + assert result.errors is not None diff --git a/api/tests/graphql/test_subscriptions.py b/api/tests/graphql/test_subscriptions.py index 9afaad03..5e271ec9 100644 --- a/api/tests/graphql/test_subscriptions.py +++ b/api/tests/graphql/test_subscriptions.py @@ -76,7 +76,7 @@ async def test_latest_data( subscription = await graphql_schema.subscribe( """ subscription LatestDataSubscription( - $proposal: String, + $proposal: ProposalNo!, $timestamp: Timestamp!) { latest_data(database: { proposal: $proposal }, timestamp: $timestamp) } @@ -133,7 +133,7 @@ async def test_latest_data_with_concurrent_subscriptions( ): query = """ subscription LatestDataSubscription( - $proposal: String, + $proposal: ProposalNo!, $timestamp: Timestamp!) { latest_data(database: { proposal: $proposal }, timestamp: $timestamp) } @@ -178,7 +178,7 @@ async def test_latest_data_with_nonconcurrent_subscriptions( ): query = """ subscription LatestDataSubscription( - $proposal: String, + $proposal: ProposalNo!, $timestamp: Timestamp!) { latest_data(database: { proposal: $proposal }, timestamp: $timestamp) } diff --git a/api/tests/refactor/snapshots/schema.graphql b/api/tests/refactor/snapshots/schema.graphql index ace766d8..7045a94c 100644 --- a/api/tests/refactor/snapshots/schema.graphql +++ b/api/tests/refactor/snapshots/schema.graphql @@ -35,7 +35,7 @@ type DamnitVariableError { } input DatabaseInput { - proposal: String = "2956" + proposal: ProposalNo! = 2956 path: String } @@ -52,7 +52,7 @@ type ProposalMeta { updated_at: DateTime created_at: DateTime! id: Int! - number: Int! + number: ProposalNo! cycle: String! instrument: String! path: String! @@ -66,12 +66,15 @@ type ProposalMeta { damnit_path_last_check: DateTime } +"""Proposal number (positive integer, 1-999999)""" +scalar ProposalNo + type Query { get_user: User! runs(database: DatabaseInput!, page: Int! = 1, per_page: Int! = 10): [DamnitRun!]! metadata(database: DatabaseInput!): JSON! extracted_data(database: DatabaseInput!, run: Int!, variable: String!): JSON! - proposal_metadata(proposal_numbers: [Int!]!): [ProposalMeta!] + proposal_metadata(proposal_numbers: [ProposalNo!]!): [ProposalMeta!] } type Subscription { diff --git a/api/tests/refactor/test_gql_parity.py b/api/tests/refactor/test_gql_parity.py index a6a13489..a46537f9 100644 --- a/api/tests/refactor/test_gql_parity.py +++ b/api/tests/refactor/test_gql_parity.py @@ -149,7 +149,7 @@ async def test_runs_query_wire_shape_unchanged( @pytest.mark.asyncio async def test_metadata_query_wire_shape_unchanged(graphql_schema): query = """ - query($proposal: String) { + query($proposal: ProposalNo!) { metadata(database: { proposal: $proposal }) } """ @@ -221,7 +221,7 @@ async def test_latest_data_subscription_wire_shape_unchanged( subscription = await graphql_schema.subscribe( """ subscription( - $proposal: String, + $proposal: ProposalNo!, $timestamp: Timestamp!) { latest_data( database: { proposal: $proposal }, diff --git a/api/tests/test_data.py b/api/tests/test_data.py index a8bbd740..50e02f30 100644 --- a/api/tests/test_data.py +++ b/api/tests/test_data.py @@ -14,6 +14,7 @@ to_dataarray, ) from damnit_api.shared.const import DamnitType +from damnit_api.shared.models import ProposalNumber # ----------------------------------------------------------------------------- # get_damnit_type @@ -243,7 +244,9 @@ def test_get_preview_data_ndarray(mocker): data = np.random.rand(4) mock_damnit_class(mocker, data=data, type_hint=None) - actual = get_preview_data(proposal=1234, run=1, variable=name) + actual = get_preview_data( + proposal_number=ProposalNumber(1234), run=1, variable=name + ) assert actual == { "name": name, @@ -265,7 +268,9 @@ def test_get_preview_data_dataarray(mocker): ) mock_damnit_class(mocker, data=data, type_hint=DataType.DataArray) - actual = get_preview_data(proposal=1234, run=1, variable=name) + actual = get_preview_data( + proposal_number=ProposalNumber(1234), run=1, variable=name + ) assert actual["name"] == name assert actual["dtype"] == dtype.value @@ -282,7 +287,9 @@ def test_get_preview_data_png(mocker): data = np.random.randint(0, 256, (2, 3, 4), dtype=np.uint8) mock_damnit_class(mocker, data=data, type_hint=DataType.Image) - actual = get_preview_data(proposal=1234, run=1, variable=name) + actual = get_preview_data( + proposal_number=ProposalNumber(1234), run=1, variable=name + ) assert actual["name"] == name assert actual["dtype"] == dtype.value diff --git a/api/tests/test_db.py b/api/tests/test_db.py index f0b49069..601b307b 100644 --- a/api/tests/test_db.py +++ b/api/tests/test_db.py @@ -19,8 +19,13 @@ DAMNIT_PATH, DatabaseSessionManager, async_table, + get_damnit_path, get_session, ) +from damnit_api.shared.errors import ProposalNotFoundError +from damnit_api.shared.models import ProposalNumber + +_TEST_PROPOSAL = ProposalNumber(999999) # ----------------------------------------------------------------------------- # Fixtures @@ -35,7 +40,7 @@ @pytest.fixture -def damnit_db(tmp_path): +def damnit_db(mocker, tmp_path): root = tmp_path / DAMNIT_PATH root.mkdir(parents=True) db_file = root / "runs.sqlite" @@ -45,7 +50,13 @@ def damnit_db(tmp_path): conn.commit() finally: conn.close() - return str(tmp_path) + # Path resolution is exercised in its own tests; here the manager just + # needs to point at the tmp DAMNIT directory holding runs.sqlite. + mocker.patch( + "damnit_api.runs.sqlite.session.get_damnit_path", + return_value=str(root), + ) + return _TEST_PROPOSAL def _open_file_descriptors_to(db_file: Path): @@ -81,8 +92,18 @@ def test_engine_uses_nullpool_and_autocommit(damnit_db): # this test verifies (no file descriptors leak after the loop dies). # alru_cached async_table sees that loop change; warning is intrinsic. @pytest.mark.filterwarnings("ignore::async_lru.AlruCacheLoopResetWarning") -def test_no_lingering_file_descriptor_after_read(damnit_db, damnit_registry): - db_file = Path(damnit_db) / DAMNIT_PATH / "runs.sqlite" +def test_get_damnit_path_raises_when_proposal_not_found(mocker): + """A proposal with no resolvable directory raises ProposalNotFoundError.""" + mocker.patch("damnit_api.runs.sqlite.session.find_proposal", return_value="") + # Force non-local mode so resolution falls through to find_proposal. + mocker.patch("damnit_api.shared.settings.settings.damnit_path", new=None) + + with pytest.raises(ProposalNotFoundError): + get_damnit_path(_TEST_PROPOSAL) + + +def test_no_lingering_file_descriptor_after_read(damnit_db, damnit_registry, tmp_path): + db_file = tmp_path / DAMNIT_PATH / "runs.sqlite" async def do_read(): table = await async_table(damnit_registry, damnit_db, name="runs") diff --git a/api/tests/test_models.py b/api/tests/test_models.py new file mode 100644 index 00000000..c3b2f2fb --- /dev/null +++ b/api/tests/test_models.py @@ -0,0 +1,140 @@ +"""Unit tests for shared models, particularly ProposalNumber.""" + +import pytest + +from damnit_api.shared.models import ProposalNumber + +# ----------------------------------------------------------------------------- +# Valid int inputs + + +def test_proposal_number_min_boundary(): + assert int(ProposalNumber(1)) == 1 + + +def test_proposal_number_typical_int(): + assert int(ProposalNumber(900485)) == 900485 + + +def test_proposal_number_max_boundary(): + assert int(ProposalNumber(999999)) == 999999 + + +# ----------------------------------------------------------------------------- +# Invalid int inputs + + +def test_proposal_number_zero_raises(): + with pytest.raises(ValueError, match="out of range"): + ProposalNumber(0) + + +def test_proposal_number_too_large_raises(): + with pytest.raises(ValueError, match="out of range"): + ProposalNumber(1_000_000) + + +def test_proposal_number_negative_raises(): + with pytest.raises(ValueError, match="out of range"): + ProposalNumber(-1) + + +# ----------------------------------------------------------------------------- +# Float rejection: floats must raise, never be silently truncated. + + +def test_proposal_number_float_raises(): + with pytest.raises(TypeError): + ProposalNumber(1.9) # ty: ignore[invalid-argument-type] + + +def test_proposal_number_float_in_range_raises(): + with pytest.raises(TypeError): + ProposalNumber(999999.0) # ty: ignore[invalid-argument-type] + + +# ----------------------------------------------------------------------------- +# Valid string inputs + + +def test_proposal_number_str_with_p_prefix(): + assert int(ProposalNumber("p001234")) == 1234 + + +def test_proposal_number_str_no_prefix(): + assert int(ProposalNumber("1234")) == 1234 + + +def test_proposal_number_str_leading_zeros(): + assert int(ProposalNumber("p000001")) == 1 + + +def test_proposal_number_str_short(): + assert int(ProposalNumber("p1")) == 1 + + +# ----------------------------------------------------------------------------- +# Invalid string inputs + + +def test_proposal_number_str_too_many_digits_raises(): + with pytest.raises(ValueError, match="Invalid proposal number"): + ProposalNumber("p1234567") + + +def test_proposal_number_str_double_p_raises(): + with pytest.raises(ValueError, match="Invalid proposal number"): + ProposalNumber("pp1234") + + +def test_proposal_number_str_not_a_number_raises(): + with pytest.raises(ValueError, match="Invalid proposal number"): + ProposalNumber("not-a-number") + + +def test_proposal_number_str_empty_raises(): + with pytest.raises(ValueError, match="Invalid proposal number"): + ProposalNumber("") + + +def test_proposal_number_str_zero_raises(): + with pytest.raises(ValueError, match="out of range"): + ProposalNumber("p000000") + + +def test_proposal_number_str_too_large_raises(): + with pytest.raises(ValueError, match="Invalid proposal number"): + ProposalNumber("p1000000") + + +# ----------------------------------------------------------------------------- +# Canonical str / repr + + +def test_proposal_number_str_min(): + assert str(ProposalNumber(1)) == "p000001" + + +def test_proposal_number_str_max(): + assert str(ProposalNumber(999999)) == "p999999" + + +def test_proposal_number_str_typical(): + assert str(ProposalNumber(1234)) == "p001234" + + +def test_proposal_number_repr(): + assert repr(ProposalNumber(1234)) == "ProposalNumber(1234)" + + +# ----------------------------------------------------------------------------- +# Hash / dict-key interchangeability with plain int + + +def test_proposal_number_as_dict_key_interchangeable(): + d: dict[int, str] = {ProposalNumber(900485): "proposal-data"} + assert d[900485] == "proposal-data" + + +def test_proposal_number_hash_equals_int_hash(): + assert hash(ProposalNumber(42)) == hash(42) diff --git a/api/tests/test_state.py b/api/tests/test_state.py index 8e43dcb8..a0b7386f 100644 --- a/api/tests/test_state.py +++ b/api/tests/test_state.py @@ -5,6 +5,7 @@ from damnit_api.auth.token_store import InMemoryTokenStore from damnit_api.runs.sqlite import DamnitDBRegistry +from damnit_api.shared.models import ProposalNumber from damnit_api.shared.settings import Settings from damnit_api.state import create_oauth_client @@ -51,10 +52,10 @@ def __init__(self, proposal): ) registry = DamnitDBRegistry() - first = registry.get("1234") - assert registry.get("1234") is first - assert registry.get("5678") is not first - assert created == ["1234", "5678"] + first = registry.get(ProposalNumber(1234)) + assert registry.get(ProposalNumber(1234)) is first + assert registry.get(ProposalNumber(5678)) is not first + assert created == [ProposalNumber(1234), ProposalNumber(5678)] def test_token_store_stores_and_pops_fields(): diff --git a/uv.lock b/uv.lock index 76248107..1f981486 100644 --- a/uv.lock +++ b/uv.lock @@ -434,6 +434,7 @@ dev = [ { name = "ruff" }, { name = "strawberry-graphql" }, { name = "syrupy" }, + { name = "ty" }, { name = "zensical" }, ] develop = [ @@ -449,6 +450,7 @@ docs = [ lint = [ { name = "pyright" }, { name = "ruff" }, + { name = "ty" }, ] test = [ { name = "asgi-lifespan" }, @@ -509,6 +511,7 @@ dev = [ { name = "ruff", specifier = ">=0.7" }, { name = "strawberry-graphql", extras = ["debug-server"], specifier = ">=0.283.3" }, { name = "syrupy", specifier = ">=5.5.2" }, + { name = "ty", specifier = ">=0.0.42" }, { name = "zensical", specifier = ">=0.0.47" }, ] develop = [ @@ -524,6 +527,7 @@ docs = [ lint = [ { name = "pyright", specifier = ">=1.1.406" }, { name = "ruff", specifier = ">=0.7" }, + { name = "ty", specifier = ">=0.0.42" }, ] test = [ { name = "asgi-lifespan", specifier = ">=2.1.0" }, @@ -1926,6 +1930,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] +[[package]] +name = "ty" +version = "0.0.56" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/55/07/fb29aea5235b0aa8ecfc4d1cc6ddf9fba8b863d67d96c6d345694d644c43/ty-0.0.56.tar.gz", hash = "sha256:84d114dc3796361c0fc72945016eabd74d46b9ee64f198cb0e485719704681e5", size = 6050123, upload-time = "2026-07-01T16:44:56.036Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/48/bce79e7ca5c1cc529d3e0d37ddd1121aea4b68a4f749974ad1cc77161871/ty-0.0.56-py3-none-linux_armv6l.whl", hash = "sha256:186d4a53e15747c947e1ec3d7eec8e345d8e40a1ca10e634c585db52497e87dd", size = 11643066, upload-time = "2026-07-01T16:44:18.374Z" }, + { url = "https://files.pythonhosted.org/packages/80/d1/22555d8a1d719661f10050f3865d877bbf497da908961c75fe22217dd18a/ty-0.0.56-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aae1a980fd9535da0469b7ba2b2e1b54a907743a5e0f442dd57eee9f5bfd034c", size = 11407487, upload-time = "2026-07-01T16:44:20.956Z" }, + { url = "https://files.pythonhosted.org/packages/cf/2d/b3b7a74ce8bc59ef48843ad80179bb0d9598bbd6cfc0d11d519bdf6b1352/ty-0.0.56-py3-none-macosx_11_0_arm64.whl", hash = "sha256:afd3058c0a6c5f241e814734f133008c93ee805f61c9cf4ce7412b8822b5d9ad", size = 10962270, upload-time = "2026-07-01T16:44:22.959Z" }, + { url = "https://files.pythonhosted.org/packages/64/ac/6c2fd7de0304a8a7218a756af74f7e62a5e8540fdb175e0a869e51042345/ty-0.0.56-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:058b52f7a823ac13aae3cae30809dd6b5145794b64d8478f9ef38c75d79b4483", size = 11471406, upload-time = "2026-07-01T16:44:25.327Z" }, + { url = "https://files.pythonhosted.org/packages/50/b6/11d861156861c03c7726b74558f9a0e0092661aff83a4fda1279df28c425/ty-0.0.56-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c66e00c1522add1f2bbdd2e45828c953b35c306b7bef03ec9169c75a63699a0", size = 11445612, upload-time = "2026-07-01T16:44:27.531Z" }, + { url = "https://files.pythonhosted.org/packages/fb/ba/09df108582090f3c0770ec4bc8675affed60248f6793a78d909be16211d9/ty-0.0.56-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40903d71c669a30691b5a5d5728056c7877a1bd6be4f233a38883a8b28cf34d7", size = 12093889, upload-time = "2026-07-01T16:44:29.548Z" }, + { url = "https://files.pythonhosted.org/packages/d7/f7/dbb4b4ccb69cd64c209ae55b1ab788ace8222c2bc1f6845be9e7cbedbf25/ty-0.0.56-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63fe3947fe0c46c69a7d950e6832ee70a9ec17321fefbff3d2e3c20baf9e5bd0", size = 12666337, upload-time = "2026-07-01T16:44:31.586Z" }, + { url = "https://files.pythonhosted.org/packages/86/e9/73f903fe4a3d9ea02f26f57c1eb07e3b1029ec92b0e8c2364718893440e3/ty-0.0.56-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71a0c1a72f9854532e710e119b6871ffe4542c8a65146f1f65dcd78fecd885b4", size = 12280247, upload-time = "2026-07-01T16:44:33.637Z" }, + { url = "https://files.pythonhosted.org/packages/d6/90/cebd222495832f1a00dcd321ba25f3cab804221a4991b992c2178bec68ee/ty-0.0.56-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70d1665596494e24d8ebd198438872b5a56ec3cae5f2bcf6c673be797acc4e3c", size = 11991107, upload-time = "2026-07-01T16:44:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/b7/07/8f7337a07250f42d975cdb6decf47fc5b421e6c7da5e3e7be1e85f63a7e5/ty-0.0.56-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:778f99e51558afc1dbbe48ee38ab6aae7b31390ed8c1a1ef1499b295e9f1e82f", size = 12298970, upload-time = "2026-07-01T16:44:38.243Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b9/a52cd59034a48f5f18c6b155cc2cc36861d874b6d0af204b12c898024c3d/ty-0.0.56-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:867bc5708e0066bb4ff6c7db524bd5deea2676c62bfe71d3303138b3be850af0", size = 11425683, upload-time = "2026-07-01T16:44:40.473Z" }, + { url = "https://files.pythonhosted.org/packages/1d/2e/48e42d33357d52eefb695c0c3fcfc96879b73668a7447d1d1e0ad774fedc/ty-0.0.56-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a6012f4189c928edb330a37deb9930f982380bd4aa7c4b8e0428eec9651c7551", size = 11469258, upload-time = "2026-07-01T16:44:42.513Z" }, + { url = "https://files.pythonhosted.org/packages/d5/01/ad1b4138be1e3fa97863af3925aa2134f17a593240c35dc38c3429fb5ad1/ty-0.0.56-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8ee83de1a7ff4cc32837ec06134ce391d441bc5b35ecd8d3cfe053f120f3e4c1", size = 11758736, upload-time = "2026-07-01T16:44:44.567Z" }, + { url = "https://files.pythonhosted.org/packages/09/34/9d81967ff240eaa57e9249728ef7b7790747cf6d3c9a98ec86b2cfdcc8ee/ty-0.0.56-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:62619b3b0e2c6248ef30d3f0e2f2217ae9893040585be07f32324242f197cd6f", size = 12100242, upload-time = "2026-07-01T16:44:46.584Z" }, + { url = "https://files.pythonhosted.org/packages/c3/36/f51d4666d2de6cf33c1f3a1fcc4bb6b70b197dd6ceaa491eef71d78fe8e8/ty-0.0.56-py3-none-win32.whl", hash = "sha256:b30687bb5cd9729d34c889a289edf32770388d9bb05243e534e723fb45e0381b", size = 11093759, upload-time = "2026-07-01T16:44:49.171Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b4/8fb5d4acfa4afb152245b20fa263069a7547bd1f8e4bfca4eda280c897d7/ty-0.0.56-py3-none-win_amd64.whl", hash = "sha256:ad4c8c47b6f4e3f9ed3fc0b1a5d650088d229e17dd8f63c1826d6bbe94cc3235", size = 12100327, upload-time = "2026-07-01T16:44:51.26Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fc/6a183e71edde90d0c35c2303f23f7a45b6891d1a2c45daf7b8f869831e19/ty-0.0.56-py3-none-win_arm64.whl", hash = "sha256:57538f273d444a5f1293fa7860e967178afe3917611fc5eff16b64e1204fe0d6", size = 11538780, upload-time = "2026-07-01T16:44:53.8Z" }, +] + [[package]] name = "typeguard" version = "4.5.1"