diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f577ce1..82c01c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,20 @@ jobs: run: make check-dependency-licenses + typecheck: + name: Type checking + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + with: + version-file: "pyproject.toml" + python-version: "3.12" + enable-cache: true + - run: make install-ci + - run: make typecheck + test: name: Tests (Python ${{ matrix.python-version }}) runs-on: ubuntu-latest @@ -173,7 +187,7 @@ jobs: status: name: CI Status if: always() - needs: [static, test, packaging, e2e, container] + needs: [static, typecheck, test, packaging, e2e, container] runs-on: ubuntu-latest timeout-minutes: 5 env: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f2d6b9d..deacff2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -46,6 +46,12 @@ repos: language: system pass_filenames: false always_run: true + - id: typecheck + name: type checking (Python 3.11) + entry: make typecheck + language: system + pass_filenames: false + always_run: true - id: spdx-headers name: SPDX headers (staged files) entry: uv run python scripts/update_license_headers.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 56f7613..51b1108 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -280,9 +280,23 @@ Your PR title must follow [Conventional Commits](https://www.conventionalcommits the settings in the [contribution policy](docs/engineering/contribution-policy.md), the title check alone does not guarantee the commit subject on `main`. +## Type checking + +`make typecheck` runs the locked `ty` version against shipped `src/usersim`, +repository gate scripts, and installed CLI e2e helpers. The checker targets +Python 3.11, the supported minimum, even when the development interpreter is +newer. Ordinary unit-test fixtures are outside the initial type-check scope. + +Type checking runs in pre-commit, `make check-all`, the native validation +bundle, and CI. Keep annotations consistent with runtime contracts; the +existing tests still establish behavior. Dynamic Pydantic schema construction +and upstream library annotation limitations have local, rule-specific +suppressions with explanations. New suppressions need the same justification; +do not disable a rule globally to make the gate pass. + ## What CI runs -`CI Status` aggregates static checks, all supported Python versions, packaging, +`CI Status` aggregates static checks, type checking, all supported Python versions, packaging, installed CLI e2e tests, and CPU container tests. Missing, skipped, cancelled, or failed mandatory jobs fail that status. Run the same Make targets locally; see [local and CI validation](docs/engineering/ci.md) for commands, diagnostics, and diff --git a/Makefile b/Makefile index 892198a..9c68f00 100644 --- a/Makefile +++ b/Makefile @@ -60,7 +60,7 @@ COV_FAIL_UNDER ?= 84 .DEFAULT_GOAL := help -.PHONY: help bootstrap check-tools check-lock install-dev install-ci install-kernel health-check check-notebooks-clean lint lint-fix format format-check check-all \ +.PHONY: typecheck help bootstrap check-tools check-lock install-dev install-ci install-kernel health-check check-notebooks-clean lint lint-fix format format-check check-all \ check-ci-status check-hooks check-all-fix validate-native validate-ci test test-fast test-e2e coverage smoke dist check-dist check-release-tag check-extensions check-doc-links clean-notebooks check-license-headers update-license-headers check-dependency-licenses clean @@ -99,6 +99,9 @@ install-ci: check-tools ## Sync exactly what CI needs, enforcing the lockfile # --- lint / format --------------------------------------------------------- +typecheck: check-tools ## Check shipped code, gate scripts and e2e helpers against Python 3.11 + $(UV) run --locked ty check --error-on-warning + lint: ## Check lint rules (no changes written) $(UV) run ruff check --output-format=full $(PY_ALL) @echo "Lint clean. Run 'make lint-fix' to auto-fix issues." @@ -113,7 +116,7 @@ format-check: ## Check formatting (no changes written) format: ## Reformat in place $(UV) run ruff format $(PY_ALL) -check-all: check-tools check-lock lint format-check check-license-headers check-doc-links check-notebooks-clean check-hooks ## Run ordinary static gates (hooks may rewrite files) +check-all: check-tools check-lock typecheck lint format-check check-license-headers check-doc-links check-notebooks-clean check-hooks ## Run ordinary static gates (hooks may rewrite files) check-hooks: ## Run hooks on every tracked file; review rewrites and rerun after failure $(UV) run pre-commit run --all-files --show-diff-on-failure diff --git a/docs/engineering/ci.md b/docs/engineering/ci.md index c0ce013..ccc23e4 100644 --- a/docs/engineering/ci.md +++ b/docs/engineering/ci.md @@ -6,7 +6,7 @@ bundle, including distribution checks and installed CLI tests. | Target | Checks | | --- | --- | -| `make check-all` | Static checks defined by the repository. | +| `make check-all` | Static checks, including the configured type-check scope. | | `make check-dependency-licenses` | Dependency license policy. | | `make coverage` | Ordinary test suite with an 84% minimum total coverage. | | `make smoke` | Offline configuration, registry, asset, and reporting self-check. | @@ -27,6 +27,18 @@ Python 3.12 through `make test-e2e`. It retains failure diagnostics under `WHEEL_DIR=dist` after archive validation so the e2e test installs that same wheel without rebuilding. +## Type checking + +`make typecheck` uses the locked `ty==0.0.69` against `src/usersim`, `scripts`, +and `tests_e2e`, targeting Python 3.11 with `src` as the module root. The +dedicated CI job runs this target after locked setup; hooks, static validation, +and release validation inherit it through `check-all`. +Ordinary unit-test fixtures are outside this initial scope. + +Keep annotations aligned with runtime behavior. Rule-specific suppressions +cover documented runtime schema construction or upstream annotation limits; +do not disable a rule across the repository. + ## Coverage The Python 3.12 CI matrix job runs `make coverage`; the other Python versions @@ -54,7 +66,7 @@ setup stops before XML is produced, inspect the failed step's logs. ## Mandatory jobs and aggregate status -`CI Status` waits for static checks, the complete Python test matrix, packaging, +`CI Status` waits for static checks, the dedicated `Type checking` job, the complete Python test matrix, packaging, installed CLI e2e, and CPU container tests. Every job must report `success`. Failures, cancellation, missing results, malformed results, and skipped jobs fail the aggregate; there are no intentional job skips. Conditional coverage steps diff --git a/pyproject.toml b/pyproject.toml index 10903f2..3a40058 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -90,6 +90,7 @@ dev = [ "ruff>=0.14,<0.15", "pytest-cov>=7.0,<8", "pre-commit>=4.0,<5", + "ty==0.0.69", # Distributes the test suite across cores. It is CPU-bound with every LLM # call mocked, so it parallelises well. `make test` passes -n and fails # without this. @@ -233,3 +234,10 @@ exclude_lines = [ "if TYPE_CHECKING:", "raise NotImplementedError", ] + +[tool.ty.src] +include = ["src/usersim", "scripts", "tests_e2e"] + +[tool.ty.environment] +python-version = "3.11" +root = ["src"] diff --git a/scripts/check_ci_results.py b/scripts/check_ci_results.py index 95d6419..1014189 100644 --- a/scripts/check_ci_results.py +++ b/scripts/check_ci_results.py @@ -9,7 +9,7 @@ import os import sys -REQUIRED_JOBS = frozenset({"static", "test", "packaging", "e2e", "container"}) +REQUIRED_JOBS = frozenset({"static", "typecheck", "test", "packaging", "e2e", "container"}) def validate_results(raw: str) -> list[str]: diff --git a/src/usersim/asset_gen/financial_services/pipeline.py b/src/usersim/asset_gen/financial_services/pipeline.py index 70fb53f..9717a9a 100644 --- a/src/usersim/asset_gen/financial_services/pipeline.py +++ b/src/usersim/asset_gen/financial_services/pipeline.py @@ -35,7 +35,7 @@ import uuid from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Iterator +from typing import Any, Iterator, cast from pydantic import BaseModel, Field @@ -1403,6 +1403,8 @@ def _embed_documents( time.sleep(delay) # ORDERED seed + reset_index preserves row order, so ids align by position. + # A completed embedding attempt assigns result; failures re-raise above. + result = cast(pd.DataFrame, result) id_to_vec = {doc_id: r["body_embedding"]["embeddings"][0] for doc_id, (_, r) in zip(edf["id"], result.iterrows())} for ib in institutions: ib.embeddings = {d["id"]: id_to_vec[d["id"]] for d in ib.documents if d["id"] in id_to_vec} diff --git a/src/usersim/cli/_async.py b/src/usersim/cli/_async.py index adaab79..9ef49b0 100644 --- a/src/usersim/cli/_async.py +++ b/src/usersim/cli/_async.py @@ -12,24 +12,28 @@ import asyncio import concurrent.futures -from collections.abc import Coroutine -from typing import Any, TypeVar +from collections.abc import Awaitable +from typing import TypeVar _T = TypeVar("_T") -def run_coroutine(coro: Coroutine[Any, Any, _T]) -> _T: - """Run ``coro`` to completion and return its result. +def run_coroutine(coro: Awaitable[_T]) -> _T: + """Run a scorer awaitable to completion and return its result. A notebook already owns the loop on its thread, and ``asyncio.run`` refuses to nest inside one. Where that is the case the work moves to a thread with a loop of its own, which is the only way a synchronous call can wait for a coroutine from inside a live loop. """ + + async def await_result() -> _T: + return await coro + try: asyncio.get_running_loop() except RuntimeError: - return asyncio.run(coro) + return asyncio.run(await_result()) with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: - return pool.submit(asyncio.run, coro).result() + return pool.submit(lambda: asyncio.run(await_result())).result() diff --git a/src/usersim/cli/_env.py b/src/usersim/cli/_env.py index c3e549b..7fc031e 100644 --- a/src/usersim/cli/_env.py +++ b/src/usersim/cli/_env.py @@ -11,6 +11,7 @@ import logging import os +from collections.abc import Iterable from pathlib import Path logger = logging.getLogger("usersim.cli.env") @@ -69,6 +70,6 @@ def describe_loaded_env(paths: list[Path]) -> str: return f"read {names} (existing environment variables take precedence)" -def missing_keys(names: object) -> list[str]: +def missing_keys(names: Iterable[str]) -> list[str]: """Return which of ``names`` are absent or empty in the environment.""" - return [n for n in names if not os.environ.get(str(n))] # type: ignore[union-attr] + return [n for n in names if not os.environ.get(str(n))] diff --git a/src/usersim/cli/_inputs.py b/src/usersim/cli/_inputs.py index ac655fe..33b2f30 100644 --- a/src/usersim/cli/_inputs.py +++ b/src/usersim/cli/_inputs.py @@ -30,7 +30,7 @@ import tempfile import time from collections import Counter -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from dataclasses import dataclass from pathlib import Path from typing import Any @@ -123,7 +123,7 @@ def _plain(value: Any) -> Any: def prepare_episode_inputs( - rows: list[Mapping[str, Any]], + rows: Sequence[Mapping[str, Any]], *, assets_dir: str | Path | None = None, source: str | Path | None = None, diff --git a/src/usersim/cli/_models.py b/src/usersim/cli/_models.py index 6548ccd..c04d752 100644 --- a/src/usersim/cli/_models.py +++ b/src/usersim/cli/_models.py @@ -57,7 +57,7 @@ import tomllib from dataclasses import dataclass from pathlib import Path -from typing import Any, Iterable +from typing import Any, Iterable, Mapping from usersim.cli._errors import ConfigError @@ -396,7 +396,7 @@ def apply_parallel_overrides(config: ModelsConfig, overrides: dict[str, int]) -> def apply_model_overrides( config: ModelsConfig, - overrides: dict[str, str | dict[str, Any]], + overrides: Mapping[str, str | dict[str, Any]], ) -> ModelsConfig: """Return a new ``ModelsConfig`` with ``model`` strings (and optionally other per-model knobs) swapped per alias. @@ -1002,7 +1002,9 @@ def smoke_test_models( if key_pair in seen: # Inherit the prior result for the same (model, provider). prior_alias = next( - a for a, (_, m) in results.items() if (config.get(a).model, config.get(a).provider) == key_pair + a + for a, (_, m) in results.items() + if (prior_spec := config.get(a)) is not None and (prior_spec.model, prior_spec.provider) == key_pair ) results[spec.alias] = results[prior_alias] continue diff --git a/src/usersim/cli/_persona_language.py b/src/usersim/cli/_persona_language.py index e79940b..f7f1118 100644 --- a/src/usersim/cli/_persona_language.py +++ b/src/usersim/cli/_persona_language.py @@ -145,7 +145,8 @@ def _column_value_counts( frequencies = tuple( sorted( (str(entry["values"]), int(entry["counts"])) - for entry in pc.value_counts(table[column]).to_pylist() + # Arrow installs its compute kernels dynamically; the attribute is public. + for entry in pc.value_counts(table[column]).to_pylist() # ty: ignore[unresolved-attribute] if entry["values"] is not None ) ) diff --git a/src/usersim/cli/_pipeline.py b/src/usersim/cli/_pipeline.py index 2045d53..021460c 100644 --- a/src/usersim/cli/_pipeline.py +++ b/src/usersim/cli/_pipeline.py @@ -418,7 +418,8 @@ def build_simulator_config_builder( ) config_builder.add_column( - ConversationSimulatorConfig( + # Registered plugin configs are accepted at runtime, outside DD's closed type union. + ConversationSimulatorConfig( # ty: ignore[invalid-argument-type] name="conversation_messages", persona_column="persona", probe_type_column="probe_type", @@ -672,7 +673,8 @@ def build_evaluator_config_builder( _warn_on_undispatched_scorers(scorers or []) config_builder.add_column( - TrajectoryEvaluatorConfig( + # Registered plugin configs are accepted at runtime, outside DD's closed type union. + TrajectoryEvaluatorConfig( # ty: ignore[invalid-argument-type] name=output_column, judges=judge_specs, axes=axes, diff --git a/src/usersim/cli/select.py b/src/usersim/cli/select.py index 9666e93..f16a1f7 100644 --- a/src/usersim/cli/select.py +++ b/src/usersim/cli/select.py @@ -227,7 +227,7 @@ def run(args: argparse.Namespace) -> int: if args.stratify_by and args.stratify_by.strip().lower() != "none": stratify_by = tuple(c.strip() for c in args.stratify_by.split(",") if c.strip()) # --run accepts a single id, 'latest', or a comma-separated list. - runs: object = args.run + runs: str | list[str] | None = args.run if isinstance(args.run, str) and "," in args.run: runs = [r.strip() for r in args.run.split(",") if r.strip()] diff --git a/src/usersim/cli/simulate.py b/src/usersim/cli/simulate.py index 5a2c00a..fd47855 100644 --- a/src/usersim/cli/simulate.py +++ b/src/usersim/cli/simulate.py @@ -35,6 +35,7 @@ from collections import Counter from fractions import Fraction from pathlib import Path +from typing import cast from usersim.cli._pipeline import resolve_toolset_seed_for_mix, verify_probe_assets @@ -738,6 +739,8 @@ def _simulate_to_parquet( logger.info("run=%s is the bare legacy layout; no run manifest written", run_id) return 0 + run_id = cast(str, run_id) # At least one successful save_sampled_rows assigned it. + # Write the run manifest. ``started_at_epoch`` is when the invocation # that made the run started, kept in the run's record. First-writer-wins: # a resume into a run that already has a manifest is a noop. diff --git a/src/usersim/engine/core/behavioral.py b/src/usersim/engine/core/behavioral.py index 4ae83ce..ac76bb9 100644 --- a/src/usersim/engine/core/behavioral.py +++ b/src/usersim/engine/core/behavioral.py @@ -15,7 +15,7 @@ import json import random import re -from typing import Any +from typing import Any, TypedDict _OCEAN_TRAITS = ("openness", "conscientiousness", "extraversion", "agreeableness", "neuroticism") @@ -596,7 +596,14 @@ def format_disclosure_instructions(style: str) -> str: # Sim2Real: User interaction style (Option C + G) # --------------------------------------------------------------------------- -INTERACTION_STYLES = { + +class _InteractionStyle(TypedDict): + base_frustration: int + escalation_rate: float + description: str + + +INTERACTION_STYLES: dict[str, _InteractionStyle] = { "confrontational": { "base_frustration": 2, "escalation_rate": 2.0, @@ -715,7 +722,7 @@ def compute_frustration_level( base = info["base_frustration"] rate = info["escalation_rate"] - frustration = base + frustration: float = base if assistant_failures >= 2: frustration += assistant_failures * rate diff --git a/src/usersim/engine/core/finance_bank.py b/src/usersim/engine/core/finance_bank.py index 9f72869..837a347 100644 --- a/src/usersim/engine/core/finance_bank.py +++ b/src/usersim/engine/core/finance_bank.py @@ -244,7 +244,7 @@ def verify_identity_schema( } -def framework_primitive_tool(name: str) -> dict[str, Any] | None: +def framework_primitive_tool(name: str | None) -> dict[str, Any] | None: """Canonical ``{description, parameters}`` for a framework primitive. Returns ``None`` for non-primitive (per-institution DB) tools. Callers get a diff --git a/src/usersim/engine/core/manifest.py b/src/usersim/engine/core/manifest.py index f9eaac3..22ca171 100644 --- a/src/usersim/engine/core/manifest.py +++ b/src/usersim/engine/core/manifest.py @@ -201,7 +201,7 @@ class ScopeInfo: #: (``hi_Deva_IN`` filters on "\u0939\u093f\u0902\u0926\u0940", #: ``hi_Latn_IN`` on "Hindi"). Which people were eligible is the #: thing that decides whether two runs are comparable. - persona_language_filters: dict[str, dict[str, list[str]]] = field(default_factory=dict) + persona_language_filters: Mapping[str, Mapping[str, Sequence[str]]] = field(default_factory=dict) @dataclass(frozen=True) diff --git a/src/usersim/engine/core/probes.py b/src/usersim/engine/core/probes.py index a332fd3..211f5ac 100644 --- a/src/usersim/engine/core/probes.py +++ b/src/usersim/engine/core/probes.py @@ -50,10 +50,13 @@ import logging import sys from typing import ( + TYPE_CHECKING, Any, Callable, Protocol, Sequence, + TypeVar, + cast, runtime_checkable, ) @@ -245,6 +248,18 @@ def get_assistant_system_prompt(self) -> str: (the dispatcher calls every probe class with the same kwarg set). """ + if TYPE_CHECKING: + + async def run_dispatch( + self, + *, + models: dict[str, Any], + data: dict[str, Any], + cfg: Any, + state: Any = None, + seed_state: bool = True, + ) -> dict: ... + label: str = "" # Whether the simulator may discard a judge-rejected assistant @@ -740,7 +755,7 @@ def _load_bank_for_locale(self, locale: str) -> Any: bank = loader(cand) except TypeError: # Shared, locale-independent loader. - bank = loader() + bank = cast(Callable[[], Any], loader)() self._asset_locale = base if base is not None else locale return bank except Exception as e: # noqa: BLE001 — try the next candidate @@ -749,7 +764,7 @@ def _load_bank_for_locale(self, locale: str) -> Any: self._asset_locale = cand return bank raise BankLoadError( - f"{type(self).__name__}: bank loader {loader.__qualname__!r} raised {type(last_exc).__name__}: {last_exc}" + f"{type(self).__name__}: bank loader {getattr(loader, '__qualname__')!r} raised {type(last_exc).__name__}: {last_exc}" ) from last_exc async def _localize_verbatim(self, text: str, *, rules: tuple[str, ...] = (), warn: bool = True) -> str: @@ -1046,6 +1061,21 @@ class ToolExecutionMixin: # including ``financial_services``. supports_assistant_resampling: bool = False + if TYPE_CHECKING: + + def get_tools_for_assistant(self) -> list | None: ... + + def on_tool_call_executed( + self, + *, + tool_call_id: str, + tool_name: str, + arguments: dict[str, Any], + payload: str, + turn_idx: int, + call_idx: int, + ) -> None: ... + tool_loop_mode: str = "single" tool_max_calls_per_turn: int = 8 @@ -1305,12 +1335,15 @@ class ProbeRegistrationError(ValueError): """Raised when ``@register_probe`` cannot register a class.""" +_ProbeT = TypeVar("_ProbeT", bound=BaseProbe) + + def register_probe( *, family: str, prompt_version: str, variants: Sequence[str], -) -> Callable[[type[BaseProbe]], type[BaseProbe]]: +) -> Callable[[type[_ProbeT]], type[_ProbeT]]: """Single-annotation registration of a ``BaseProbe`` subclass. Sets ``PROBE_FAMILY`` / ``PROMPT_VERSION`` / ``PROBE_VARIANTS`` @@ -1336,7 +1369,7 @@ class OpenEndedProbe(BaseProbe): ... """ - def decorator(cls: type[BaseProbe]) -> type[BaseProbe]: + def decorator(cls: type[_ProbeT]) -> type[_ProbeT]: if not isinstance(cls, type) or not issubclass(cls, BaseProbe): raise ProbeRegistrationError(f"@register_probe: {cls!r} is not a subclass of BaseProbe") label = getattr(cls, "label", "") @@ -1347,9 +1380,9 @@ def decorator(cls: type[BaseProbe]) -> type[BaseProbe]: module = sys.modules.get(cls.__module__) if module is not None: - module.PROBE_FAMILY = family - module.PROMPT_VERSION = prompt_version - module.PROBE_VARIANTS = tuple(variants) + setattr(module, "PROBE_FAMILY", family) + setattr(module, "PROMPT_VERSION", prompt_version) + setattr(module, "PROBE_VARIANTS", tuple(variants)) if label in _PROBE_REGISTRY and _PROBE_REGISTRY[label] is not cls: logger.debug( diff --git a/src/usersim/engine/core/simulation.py b/src/usersim/engine/core/simulation.py index b4cbde9..f763639 100644 --- a/src/usersim/engine/core/simulation.py +++ b/src/usersim/engine/core/simulation.py @@ -14,8 +14,9 @@ import json import logging import time +from collections.abc import Awaitable, Callable from dataclasses import dataclass, field -from typing import Any +from typing import Any, cast logger = logging.getLogger("usersim.engine") @@ -1114,7 +1115,7 @@ async def run( # path runs — both preserve pre-seeded metadata via setdefault. if seed_state and hasattr(probe, "seed_state_metadata"): try: - probe.seed_state_metadata(state) + cast(Callable[[ConversationState], None], probe.seed_state_metadata)(state) except Exception as e: logger.warning( f" |-- {probe.label}: seed_state_metadata raised " @@ -1125,7 +1126,9 @@ async def run( query_instruction = USER_QUERY_INSTRUCTION_GROUNDED if use_grounded else USER_QUERY_INSTRUCTION if hasattr(probe, "get_user_query_instruction"): try: - query_instruction = probe.get_user_query_instruction(0) or query_instruction + query_instruction = ( + cast(Callable[[int], str | None], probe.get_user_query_instruction)(0) or query_instruction + ) except Exception as e: logger.warning( f" |-- {probe.label}: get_user_query_instruction raised " @@ -1145,7 +1148,9 @@ async def run( verbatim_first = None if hasattr(probe, "get_verbatim_first_user_turn"): try: - verbatim_first = await probe.get_verbatim_first_user_turn(state) + verbatim_first = await cast( + Callable[[ConversationState], Awaitable[str | None]], probe.get_verbatim_first_user_turn + )(state) except Exception as e: logger.warning( f" |-- {probe.label}: get_verbatim_first_user_turn " @@ -1385,7 +1390,9 @@ async def run( # how frustration_events / assistant_judge_ratings / # user_judge_ratings are written by the simulator today. try: - capitulated = await probe.is_capitulation_detected(state) + capitulated = await cast( + Callable[[ConversationState], Awaitable[bool]], getattr(probe, "is_capitulation_detected") + )(state) except Exception as e: logger.warning( f" |-- {probe.label}: is_capitulation_detected raised {type(e).__name__}: {e}; treating as False" @@ -1458,7 +1465,10 @@ async def run( # bank-driven reframings for safety_chat_pressure). if hasattr(probe, "format_followup_user_instructions"): try: - extras = await probe.format_followup_user_instructions( + extras = await cast( + Callable[[int, ConversationState], Awaitable[list[str]]], + probe.format_followup_user_instructions, + )( turn_idx + 1, state, ) @@ -1722,7 +1732,7 @@ async def run( policy = "skip" if hasattr(probe, "on_followup_failure"): try: - policy = probe.on_followup_failure( + policy = cast(Callable[[ConversationState, str], str], probe.on_followup_failure)( state, "gate_exhausted", ) @@ -1779,7 +1789,9 @@ async def run( # the generic LLM check on the user follow-up message # would mis-fire on polite "Thanks!" reframing closers). try: - allow_early_stop = probe.allow_early_stop_at_turn( + allow_early_stop = cast( + Callable[[int, ConversationState], bool], getattr(probe, "allow_early_stop_at_turn") + )( turn_idx, state, ) diff --git a/src/usersim/engine/evaluator/axes.py b/src/usersim/engine/evaluator/axes.py index 676dd0a..e44557d 100644 --- a/src/usersim/engine/evaluator/axes.py +++ b/src/usersim/engine/evaluator/axes.py @@ -209,7 +209,7 @@ class AxisCategory(str, Enum): # every other probe, so the evaluator has no import-time dependency on a probe # package — a broken probe import must not take down scoring for every family. # Each client probe is its own family, so each gets its own rubric. -_MD_LADDER: dict[int, str] = { +_MD_LADDER: dict[int | str, str] = { 1: "Very poor", 2: "Poor", 3: "Adequate", diff --git a/src/usersim/engine/evaluator/generator.py b/src/usersim/engine/evaluator/generator.py index 08de685..3f28e0e 100644 --- a/src/usersim/engine/evaluator/generator.py +++ b/src/usersim/engine/evaluator/generator.py @@ -61,6 +61,7 @@ create_judge_response_model, create_judge_structured_output_model, ) +from pydantic import BaseModel from usersim.engine.core.identity import resolve_model_name from usersim.engine.core.llm import acall_llm @@ -186,7 +187,8 @@ def generate(self, data: dict) -> dict: f"Await agenerate(data) instead of calling generate(data)." ) - async def agenerate(self, data: dict) -> dict: + # This cell generator intentionally specializes DD's batch-or-cell overload. + async def agenerate(self, data: dict) -> dict: # ty: ignore[invalid-method-override] cfg = self.config col_name = cfg.name # The defaults below and every scorer treat a falsy cell as empty, but @@ -384,7 +386,7 @@ async def _call_judge( models: dict[str, Any], judge_alias: str, prompt: str, - schema_model: type, + schema_model: type[BaseModel], max_tokens: int, ) -> dict[str, Any]: """One judge call, structured-output enforced. Returns parsed dict.""" @@ -418,7 +420,7 @@ async def _call_judge( return parsed if isinstance(parsed, dict) else {} -def _build_schema_for(axes: Iterable) -> type: +def _build_schema_for(axes: Iterable) -> type[BaseModel]: """Build a Pydantic schema for the given axis list using DD's factory.""" response_models = [create_judge_response_model(s) for s in axes] return create_judge_structured_output_model(response_models) diff --git a/src/usersim/engine/evaluator/scorers/identity_disclosure.py b/src/usersim/engine/evaluator/scorers/identity_disclosure.py index 9a9a35b..385866c 100644 --- a/src/usersim/engine/evaluator/scorers/identity_disclosure.py +++ b/src/usersim/engine/evaluator/scorers/identity_disclosure.py @@ -175,7 +175,8 @@ def extraction_schema(developer_ids: tuple[str, ...]) -> type[BaseModel]: ``developer_guess`` is an enum of the row's developer ids plus ``unknown``, so the decoder cannot invent an id. """ - guess = Literal[(*developer_ids, UNKNOWN_GUESS)] # type: ignore[valid-type] + # Runtime vocabulary determines the Pydantic enum; static Literal requires constants. + guess = Literal[(*developer_ids, UNKNOWN_GUESS)] # ty: ignore[invalid-type-form] claim = create_model( "IdentityClaimExtraction", claim_type=(ClaimType, Field(..., description="What the turn claims; see the instructions.")), @@ -194,15 +195,22 @@ def extraction_schema(developer_ids: tuple[str, ...]) -> type[BaseModel]: "TurnExtraction", turn=(int, Field(..., ge=1, description="The assistant turn number, starting at 1.")), premise_stance=(PremiseStance, Field(...)), - claims=(list[claim], Field(...)), - self_details=(list[detail], Field(...)), + # Pydantic consumes the class returned by create_model at runtime. + claims=(list[claim], Field(...)), # ty: ignore[invalid-type-form] + self_details=(list[detail], Field(...)), # ty: ignore[invalid-type-form] ) passage = create_model( "ReasoningPassageExtraction", passage=(int, Field(..., ge=1, description="The reasoning passage number, starting at 1.")), - claims=(list[claim], Field(...)), + # Pydantic consumes the class returned by create_model at runtime. + claims=(list[claim], Field(...)), # ty: ignore[invalid-type-form] + ) + # Both nested schema types are created from the runtime vocabulary above. + return create_model( + "IdentityExtraction", + turns=(list[turn], Field(...)), # ty: ignore[invalid-type-form] + reasoning=(list[passage], Field(...)), # ty: ignore[invalid-type-form] ) - return create_model("IdentityExtraction", turns=(list[turn], Field(...)), reasoning=(list[passage], Field(...))) _SYSTEM_PROMPT = ( @@ -274,7 +282,7 @@ async def score_identity_disclosure_trajectory(trajectory: dict[str, Any], model if not turns: return _envelope(judge_alias, error="no_assistant_turns") - context = { + context: dict[str, Any] = { "expected_developers": list(expected.get("developers") or []), "expected_lineage": list(expected.get("lineage") or []), "spec_version": spec.get("version"), diff --git a/src/usersim/engine/evaluator/scorers/identity_grading.py b/src/usersim/engine/evaluator/scorers/identity_grading.py index 5b157b8..7646fb8 100644 --- a/src/usersim/engine/evaluator/scorers/identity_grading.py +++ b/src/usersim/engine/evaluator/scorers/identity_grading.py @@ -15,7 +15,7 @@ import re import unicodedata from dataclasses import asdict, dataclass, field, replace -from typing import Any, Iterable +from typing import Any, Iterable, cast from usersim.engine.core.identity_spec_types import Vocabulary, fold_name, name_pattern @@ -292,7 +292,8 @@ def _summarise( since a model already wrong has nothing to give in on. """ n = len(per_turn) - claimed = sorted({c.developer for c in countable if c.claim_type == "self_identity"}) + # countable already excludes claims without a developer identifier. + claimed = sorted({cast(str, c.developer) for c in countable if c.claim_type == "self_identity"}) if n and not any(t["names_expected_developer"] for t in per_turn): notes[NAMES_DEVELOPER_AXIS].append(f"No turn names {expected_names}.") attributing = [t for t in per_turn if t["attributes_itself"]] @@ -454,8 +455,9 @@ def _reasoning_spill( return [], "" who = " and ".join(dict.fromkeys(_who(c, vocabulary) for c in claimed)) passage = next((p for p in passages if _is_excerpt(claimed[0].quote, p)), claimed[0].quote) + # claimed excludes None identifiers in the predicate above. return sorted( - {c.developer for c in claimed} + {cast(str, c.developer) for c in claimed} ), f"Turn {ctx.turn}'s reasoning claims {who}: {_quoted(passage, 300)}." if not flagged: return [], "" diff --git a/src/usersim/engine/evaluator/scorers/safety_chat_pressure.py b/src/usersim/engine/evaluator/scorers/safety_chat_pressure.py index 9c65fd7..4094127 100644 --- a/src/usersim/engine/evaluator/scorers/safety_chat_pressure.py +++ b/src/usersim/engine/evaluator/scorers/safety_chat_pressure.py @@ -72,7 +72,7 @@ import json import logging import typing as _typing -from typing import Any, Literal +from typing import Any, Literal, cast from pydantic import BaseModel, Field @@ -471,6 +471,9 @@ async def score_safety_chat_pressure_trajectory( "error": judge_error, } + # _call_judge returns a judgment whenever its error is None. + judgment = cast(_PressureJudgment, judgment) + # Defence in depth: if the judge populated per-turn flips but # forgot turn_of_flip, derive it from the per-turn list rather than # reporting a False-but-missing-turn-of-flip mismatch downstream. diff --git a/src/usersim/engine/generator.py b/src/usersim/engine/generator.py index 705a061..c9697f3 100644 --- a/src/usersim/engine/generator.py +++ b/src/usersim/engine/generator.py @@ -90,7 +90,8 @@ def with_models( ``acompletion``. """ registry = SimpleNamespace(get_model=lambda *, model_alias: dict(models)[model_alias]) - return cls(config, SimpleNamespace(model_registry=registry)) + # Standalone mode supplies exactly the model_registry resource used by this generator. + return cls(config, SimpleNamespace(model_registry=registry)) # ty: ignore[invalid-argument-type] def _initialize(self) -> None: """Prepare process-wide state once, before any row runs. @@ -120,7 +121,8 @@ def generate(self, data: dict) -> dict: f"Await agenerate(data) instead of calling generate(data)." ) - async def agenerate(self, data: dict) -> dict: + # This cell generator intentionally specializes DD's batch-or-cell overload. + async def agenerate(self, data: dict) -> dict: # ty: ignore[invalid-method-override] """Run one row with the configured asset root installed for its duration. The root is scoped to the row rather than to the process, so a row that diff --git a/src/usersim/engine/probes/health_disclosure/clients.py b/src/usersim/engine/probes/health_disclosure/clients.py index 4bc966a..acd0d24 100644 --- a/src/usersim/engine/probes/health_disclosure/clients.py +++ b/src/usersim/engine/probes/health_disclosure/clients.py @@ -23,7 +23,7 @@ from __future__ import annotations -from typing import Any, Callable +from typing import Any, Callable, Mapping from usersim.engine.probes.health_disclosure.prompts import ( CDS_STANDIN_SYSTEM_PROMPT, @@ -260,11 +260,11 @@ def get(label: str) -> dict: return CLIENTS[label] -def gated_topics(client: dict) -> tuple: +def gated_topics(client: Mapping[str, Any]) -> tuple: return tuple(client["topics"]["gated"]) -def topics_for(profile: dict, client: dict) -> list[str]: +def topics_for(profile: dict, client: Mapping[str, Any]) -> list[str]: """Topics this persona carries: all general topics + gated topics whose presence predicate holds for the clinical profile (absent profile → all).""" spec = client["topics"] diff --git a/src/usersim/engine/probes/health_disclosure/mixin.py b/src/usersim/engine/probes/health_disclosure/mixin.py index 5179b1f..ef1934c 100644 --- a/src/usersim/engine/probes/health_disclosure/mixin.py +++ b/src/usersim/engine/probes/health_disclosure/mixin.py @@ -35,8 +35,9 @@ import logging import os import threading -from typing import Any, Mapping +from typing import Any, Mapping, cast +from usersim.engine.core.probes import ProbeAdapter from usersim.engine.probes.health_disclosure import move_runtime as mr logger = logging.getLogger("usersim.engine") @@ -98,6 +99,7 @@ class GuardedMoveMixin: #: ``probe_variant`` label that turns the guarded move-space ON. GUARDED_VARIANT: str = "guarded" + _models: dict[str, Any] # Supplied by the existing BaseProbe host. #: Optional env var name that overrides the variant (local dev only). Set on #: the host class to keep a convenience switch; leave ``None`` to rely solely #: on the per-trajectory variant. @@ -273,7 +275,7 @@ def allow_early_stop_at_turn(self, turn_idx: int, state: Any) -> bool: def build_result_extras(self, state: Any) -> dict: """Add the move/Guard audit columns on top of the host's extras.""" - extras = super().build_result_extras(state) # type: ignore[misc] + extras = cast(ProbeAdapter, super()).build_result_extras(state) if not getattr(self, "_moves_on", False): return extras moves = state.metadata.get("disclosure_moves", []) or [] diff --git a/src/usersim/engine/probes/health_disclosure/move_runtime.py b/src/usersim/engine/probes/health_disclosure/move_runtime.py index 79686c0..9bd768a 100644 --- a/src/usersim/engine/probes/health_disclosure/move_runtime.py +++ b/src/usersim/engine/probes/health_disclosure/move_runtime.py @@ -31,7 +31,7 @@ import json import math import os -from typing import Any, Callable +from typing import Any, Callable, Mapping from usersim.engine.core.llm import acall_llm from usersim.engine.core.tool_calls import recover_tool_call @@ -205,7 +205,7 @@ def build_env( num_turns: int, profile: dict, disclosure_style: str, - client: dict, + client: Mapping[str, Any], ) -> tuple[PatientEnv, Guard, list[str], bool]: """Construct the PatientEnv + Guard + topic list for a user, from client config. @@ -235,7 +235,7 @@ def _proposal_messages( topics: list[str], has_risk: bool, counterpart_msg: str, - client: dict, + client: Mapping[str, Any], locale: str, ) -> list[dict[str, str]]: prop = client.get("proposal", {}) @@ -388,7 +388,7 @@ async def propose_and_guard( topics: list[str], has_risk: bool, state: Any, - client: dict, + client: Mapping[str, Any], locale: str = "en_US", *, emit: MoveHook | None = None, diff --git a/src/usersim/engine/probes/identity_disclosure/generator.py b/src/usersim/engine/probes/identity_disclosure/generator.py index 60f1954..853a914 100644 --- a/src/usersim/engine/probes/identity_disclosure/generator.py +++ b/src/usersim/engine/probes/identity_disclosure/generator.py @@ -50,7 +50,7 @@ import logging from collections.abc import Callable from dataclasses import asdict -from typing import Any +from typing import Any, cast from usersim.engine.config import MODEL_ASSISTANT from usersim.engine.core.behavioral import ( @@ -62,6 +62,7 @@ PROBE_LABEL, ExpectedIdentity, IdentitySpec, + LocalizedText, TacticMode, Vocabulary, load_identity_spec_default, @@ -148,7 +149,8 @@ def __init_subclass__(cls, **kwargs: Any) -> None: cls.bank_version_key = cls.label @classmethod - def bank_loader(cls) -> IdentitySpec: # type: ignore[override] + # Identity specs are locale independent; BankBackedProbe supports a no-argument loader. + def bank_loader(cls) -> IdentitySpec: # ty: ignore[invalid-method-override] """The spec named by the probe's label. One spec serves every locale, so it takes none.""" return load_identity_spec_default(cls.label) @@ -211,7 +213,7 @@ def derive_task(self, persona: dict[str, Any], bank: Any, *, cfg: Any) -> Identi opening=fill(tactic.text.for_locale(self._asset_locale), values) if tactic.text is not None else "", values=values, instruction=( - fill(tactic.instruction.for_locale(self._asset_locale), values) + fill(cast(LocalizedText, tactic.instruction).for_locale(self._asset_locale), values) if tactic.mode is TacticMode.GENERATED else "" ), @@ -391,7 +393,7 @@ async def _opening_in_conversation_language(self, task: IdentityTask) -> str: """ if self._asset_locale == self._locale: return task.opening - frame, names = opening_frame(task.tactic.text.for_locale(self._asset_locale), task.values) + frame, names = opening_frame(cast(LocalizedText, task.tactic.text).for_locale(self._asset_locale), task.values) rules = (_POLITE_YOU,) if names: rules += ( diff --git a/src/usersim/engine/probes/sov_ai_facts/task_derivation.py b/src/usersim/engine/probes/sov_ai_facts/task_derivation.py index dd79002..89f2322 100644 --- a/src/usersim/engine/probes/sov_ai_facts/task_derivation.py +++ b/src/usersim/engine/probes/sov_ai_facts/task_derivation.py @@ -139,7 +139,8 @@ def _age_tag(persona: dict[str, Any]) -> str | None: """Extract an age-bin tag from a persona's numeric age if possible.""" raw = persona.get("age") try: - age = int(raw) # type: ignore[arg-type] + # Invalid input is intentionally sent through int() and caught below. + age = int(raw) # ty: ignore[invalid-argument-type] except (TypeError, ValueError): return None for lo, hi, tag in _AGE_BINS: diff --git a/src/usersim/reporting/runs.py b/src/usersim/reporting/runs.py index 46dcbd0..5058ce2 100644 --- a/src/usersim/reporting/runs.py +++ b/src/usersim/reporting/runs.py @@ -22,6 +22,10 @@ from datetime import datetime, timezone from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from rich.console import Console # Lead with assistant_model in the manifest-derived "models" block; # everything else follows in the order users care about (the simulator @@ -125,7 +129,7 @@ def print_available_runs( report_root: Path | str, *, eval_column: str = "assistant_eval", - console: object | None = None, + console: Console | None = None, ) -> None: """Render a ``rich.Table`` of trajectory runs with their eval/report status + actor models (from each run's manifest, when present). @@ -213,7 +217,7 @@ def print_available_runs( def print_comparison_runs( paths: list[Path | str], *, - console: object | None = None, + console: Console | None = None, ) -> None: """Render a ``rich.Table`` of report manifests selected for comparison. @@ -230,10 +234,10 @@ def print_comparison_runs( from rich.console import Console from rich.table import Table - paths = [Path(p) for p in paths] + normalized_paths = [Path(p) for p in paths] console = console or Console(width=200) - if not paths: + if not normalized_paths: console.print( "[yellow]No reports selected for comparison. Build at least two " "via cell 14 (Capability Dashboard), or check COMPARE_RUN_IDS.[/yellow]" @@ -241,7 +245,7 @@ def print_comparison_runs( return table = Table( - title=f"Selected for comparison ({len(paths)} run{'s' if len(paths) != 1 else ''})", + title=f"Selected for comparison ({len(normalized_paths)} run{'s' if len(normalized_paths) != 1 else ''})", title_style="bold cyan", header_style="bold", show_lines=True, @@ -253,7 +257,7 @@ def print_comparison_runs( table.add_column("sample mode", justify="center", no_wrap=True, min_width=12) table.add_column("eval column", style="dim", no_wrap=True, min_width=14) - for path in paths: + for path in normalized_paths: try: manifest = _json.loads(Path(path).read_text()) except (OSError, ValueError) as exc: @@ -278,5 +282,5 @@ def print_comparison_runs( ) console.print(table) - if len(paths) < 2: - console.print(f"[yellow]\u26a0 Comparison requires \u22652 runs; found {len(paths)}.[/yellow]") + if len(normalized_paths) < 2: + console.print(f"[yellow]\u26a0 Comparison requires \u22652 runs; found {len(normalized_paths)}.[/yellow]") diff --git a/src/usersim/testing/__init__.py b/src/usersim/testing/__init__.py index b27e7d1..2c5498d 100644 --- a/src/usersim/testing/__init__.py +++ b/src/usersim/testing/__init__.py @@ -229,7 +229,7 @@ async def scorer_conformance_problems( emptied = sorted(key for key in sample_row or {} if key not in SCORER_FILLED_KEYS) if emptied: try: - await fn({**sample_row, **dict.fromkeys(emptied)}, {}) + await fn({**(sample_row or {}), **dict.fromkeys(emptied)}, {}) except Exception as exc: problems.append( f"raised {type(exc).__name__} when {', '.join(emptied)} held None: {exc}. " diff --git a/tests/test_cli_async.py b/tests/test_cli_async.py new file mode 100644 index 0000000..6fa8293 --- /dev/null +++ b/tests/test_cli_async.py @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""The synchronous CLI boundary honors the scorer's Awaitable contract.""" + +from usersim.cli._async import run_coroutine + + +class Result: + def __await__(self): + async def resolve(): + return {"score": 4} + + return resolve().__await__() + + +def test_cli_awaits_scorer_result_without_a_running_loop(): + assert run_coroutine(Result()) == {"score": 4} + + +async def test_cli_awaits_scorer_result_inside_a_running_loop(): + assert run_coroutine(Result()) == {"score": 4} diff --git a/tests/test_typecheck_contract.py b/tests/test_typecheck_contract.py new file mode 100644 index 0000000..091efa0 --- /dev/null +++ b/tests/test_typecheck_contract.py @@ -0,0 +1,46 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""The configured checker rejects invalid assignments through the installed tool.""" + +from __future__ import annotations + +import subprocess +import sys +import tomllib +from pathlib import Path + +import pytest +import yaml +from packaging.specifiers import SpecifierSet + + +@pytest.mark.parametrize( + "text,diagnostic", + [('value: int = "invalid"\n', "invalid-assignment"), ("type Alias = int\n", "invalid-syntax")], +) +def test_typecheck_rejects_invalid_code(tmp_path: Path, text: str, diagnostic: str) -> None: + root = Path(__file__).resolve().parents[1] + source = tmp_path / "invalid.py" + source.write_text(text) + result = subprocess.run( + [sys.executable, "-m", "ty", "check", "--project", str(root), str(source), "--output-format", "concise"], + cwd=root, + capture_output=True, + text=True, + check=False, + timeout=30, + ) + assert result.returncode != 0 + assert diagnostic in result.stdout + + +def test_typecheck_target_matches_supported_minimum() -> None: + root = Path(__file__).resolve().parents[1] + config = tomllib.loads((root / "pyproject.toml").read_text()) + supported = SpecifierSet(config["project"]["requires-python"]) + floor = next(f"3.{minor}" for minor in range(30) if f"3.{minor}" in supported) + assert config["tool"]["ty"]["environment"]["python-version"] == floor + hooks = yaml.safe_load((root / ".pre-commit-config.yaml").read_text()) + hook = next(hook for repo in hooks["repos"] for hook in repo["hooks"] if hook["id"] == "typecheck") + assert f"Python {floor}" in hook["name"] diff --git a/tests_e2e/network_guard/sitecustomize.py b/tests_e2e/network_guard/sitecustomize.py index bbda4a8..4630f4e 100644 --- a/tests_e2e/network_guard/sitecustomize.py +++ b/tests_e2e/network_guard/sitecustomize.py @@ -36,12 +36,21 @@ def connect_ex(sock, address): return _connect_ex(sock, address) -def getaddrinfo(host, *args, **kwargs): +def getaddrinfo( + host: bytes | str | None, + port: bytes | str | int | None, + family: int = 0, + type: int = 0, + proto: int = 0, + flags: int = 0, +): if host is not None: local(host) - return _getaddrinfo(host, *args, **kwargs) + return _getaddrinfo(host, port, family, type, proto, flags) socket.socket.connect = connect socket.socket.connect_ex = connect_ex -socket.getaddrinfo = getaddrinfo +# The wrapper preserves the stdlib call signature and forwards its return unchanged. +# ty cannot infer the stdlib alias return through this monkeypatch. +socket.getaddrinfo = getaddrinfo # ty: ignore[invalid-assignment] diff --git a/tests_e2e/test_cli_workflow.py b/tests_e2e/test_cli_workflow.py index 629754f..71a696b 100644 --- a/tests_e2e/test_cli_workflow.py +++ b/tests_e2e/test_cli_workflow.py @@ -33,7 +33,9 @@ def __init__(self): class Handler(BaseHTTPRequestHandler): - def log_message(self, *args): + server: Provider + + def log_message(self, format: str, *args: object) -> None: pass def send(self, code, body): diff --git a/uv.lock b/uv.lock index e05de59..286809a 100644 --- a/uv.lock +++ b/uv.lock @@ -3469,6 +3469,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" }, ] +[[package]] +name = "ty" +version = "0.0.69" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/5b/7a618632dfe9373b7df572ecd7a08c8f799d772fbc317da82dd3aa363207/ty-0.0.69.tar.gz", hash = "sha256:b65106e9ff24fa76e25e1142fb09c85244e815c40450e3021d2bf652c231bb43", size = 6565094, upload-time = "2026-08-06T10:04:25.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/60/6534092f4d2c15e2491807edd609c2e50d527c1fed957acf40b9f110b64a/ty-0.0.69-py3-none-linux_armv6l.whl", hash = "sha256:98bfd383b273540829af673e7f98b9c1c4bcc8547d12a1a3806cd0bec7f0e087", size = 12364185, upload-time = "2026-08-06T10:03:47.137Z" }, + { url = "https://files.pythonhosted.org/packages/34/2b/5c29689bd4f74c2e3394d983d85e4011b629f2ce3730c9442553b8554bf8/ty-0.0.69-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:964621ddd05771660017c51b4e74078d861d9fc863c21ef2a500db1ab62c9ccf", size = 12042510, upload-time = "2026-08-06T10:03:49.481Z" }, + { url = "https://files.pythonhosted.org/packages/09/46/fa085bde4d23516d7ef14b24736fc5dd7dc498f60f52b3d077e59ffdea20/ty-0.0.69-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3ffea4048dd0da4c9c97393b4be0901098a9065b06fa81be2477cbde65d8a151", size = 11549397, upload-time = "2026-08-06T10:03:51.747Z" }, + { url = "https://files.pythonhosted.org/packages/25/cc/97b9efb2061dcab6fef1e94a4ad99df0bb45bd2cc15d4f5794c787ee0552/ty-0.0.69-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a8684d4a70aadd1eab0f41bdba835e3288ef49db8402a8e6ca81bab52ed5d610", size = 12115567, upload-time = "2026-08-06T10:03:53.79Z" }, + { url = "https://files.pythonhosted.org/packages/e0/c1/a5e0404965093835f3e62544e661784ec0aa8ef0b006ed50af50b19c107e/ty-0.0.69-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:afaaba240ab4122e2069a796836d10be81b4ddb053ae268b3dff962a0b4ca5c7", size = 12149770, upload-time = "2026-08-06T10:03:55.993Z" }, + { url = "https://files.pythonhosted.org/packages/e2/39/8cad6b205a4abe8a044ca0c84aea71e8ccda29b07a75a5f090e310605580/ty-0.0.69-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11ea63ef07d4e33aeb1a775cf5f2c736b3ed22fa6f8b1b608591612c36795044", size = 12941278, upload-time = "2026-08-06T10:03:58.324Z" }, + { url = "https://files.pythonhosted.org/packages/d6/8b/8766d96b732c2a060d70dc8ccafcc4d6a54109a2a95f1deb0705de88892b/ty-0.0.69-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cb3730b1268e92a2907d7aea3afe8dd1b360ae65862f0557080cf479d481b424", size = 13426509, upload-time = "2026-08-06T10:04:00.621Z" }, + { url = "https://files.pythonhosted.org/packages/02/1f/e991b2cde953ea5b94d6a9a4c45c87937bd916bc09235f764407bf471c0a/ty-0.0.69-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a544ff57a752ef186ed40b5a2f44c17402af4cdefeb74a311ca02ebd57c4fca0", size = 13106582, upload-time = "2026-08-06T10:04:02.818Z" }, + { url = "https://files.pythonhosted.org/packages/ea/bb/73538f1b99e3558fd9db87b98698426f0f60fc8666da0b1efd0e70e275eb/ty-0.0.69-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87ed2cbca20caddfdf8e3e14d213ce91b67e75feed78900f4aaf3ef884954028", size = 12708931, upload-time = "2026-08-06T10:04:05.233Z" }, + { url = "https://files.pythonhosted.org/packages/87/cd/484a5208d74c4ad1155933906295ccdce9aa81a257d8df2ab9e41bd60133/ty-0.0.69-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2684efcbce5b6fe45045faf610b377b50781b6d2aa7e61ea23ecf5b3d2bce421", size = 12985322, upload-time = "2026-08-06T10:04:07.587Z" }, + { url = "https://files.pythonhosted.org/packages/6e/81/b75003f0d4da9ab3bc8fd4f4802f836cb9921ff7e70f460604f7b769a0b5/ty-0.0.69-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:da9aeb26fdac1d2214937542b59e0d4d1ba94ec7a3f45444f33c846de1eb1d63", size = 12063910, upload-time = "2026-08-06T10:04:09.835Z" }, + { url = "https://files.pythonhosted.org/packages/8a/76/088469f547ef63dceefc4a75826aedee5014f9371dc5171cde931896a82c/ty-0.0.69-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:00e7677cd14ede381f705f71104ea7b8ea0ce217a8634e19a89781953de0e9ad", size = 12166823, upload-time = "2026-08-06T10:04:12.114Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c9/ce88a0bec0d46d8ae180b99c6ec014866fecc4cba1727b5feec8877b2765/ty-0.0.69-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d91965eb799649833d0d6042db09cd03d15289125245337cc46a2606effb7bda", size = 12483136, upload-time = "2026-08-06T10:04:14.33Z" }, + { url = "https://files.pythonhosted.org/packages/63/9e/6fae0ff225a0012642cf72c077e20f8f448c0a80771bc3360e8178fe2f32/ty-0.0.69-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1f03359cd8e5c412aa0c181118fa9b9061a4dddaedbb61bac0a424fb0814d402", size = 12799025, upload-time = "2026-08-06T10:04:16.445Z" }, + { url = "https://files.pythonhosted.org/packages/e4/43/78a658d18b2a4ccf35b053392f2213bf12e3c63b2abea512d3b6751d1f4c/ty-0.0.69-py3-none-win32.whl", hash = "sha256:ec460e01586b1eb91894c4a8403bee3e045a47e7a4ada943cc27ce8e348e88cf", size = 11787774, upload-time = "2026-08-06T10:04:18.622Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5e/88db1f674403f2b81316a853a44a81ed220621fa96f8f7ae586fb6ca7513/ty-0.0.69-py3-none-win_amd64.whl", hash = "sha256:18976ca26a4e28fc3249477f79a695d5502e670803f2e080d89ac905baef3c6e", size = 12864038, upload-time = "2026-08-06T10:04:20.748Z" }, + { url = "https://files.pythonhosted.org/packages/4d/7b/6fc6efd00c69103d70f2bdbe824343089cd70b17b3079170057d3e5a3ac0/ty-0.0.69-py3-none-win_arm64.whl", hash = "sha256:7d4ca3bb74d91cb9947ba3f3b4cb131ad6a2b3ecc76d34040c4ec6092d2e411d", size = 12196693, upload-time = "2026-08-06T10:04:22.902Z" }, +] + [[package]] name = "typer" version = "0.24.1" @@ -3563,6 +3588,7 @@ dev = [ { name = "pytest-env" }, { name = "pytest-xdist" }, { name = "ruff" }, + { name = "ty" }, ] [package.metadata] @@ -3592,6 +3618,7 @@ dev = [ { name = "pytest-env", specifier = ">=1.1,<2" }, { name = "pytest-xdist", specifier = ">=3.6,<4" }, { name = "ruff", specifier = ">=0.14,<0.15" }, + { name = "ty", specifier = "==0.0.69" }, ] license-check = []