diff --git a/.gitignore b/.gitignore index 3c5ab37..2d0f6d1 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ .idea .coverage .claude +.devcontainer *.pyc \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 6667218..83475f4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,7 +46,7 @@ Read that file for the full list. The most important ones for day-to-day work a | `make test` | Runs the full pipeline: unit tests, feature tests, style, type checks, security. Run this before marking work complete. | | `make format` | Formats all Python files with Ruff. Run this before committing. | | `make lint` | Lints with safe fixes. | -| `make type_checks` | Runs mypy across all packages. | +| `make type_checks` | Runs ty type checks across all packages. | | `make uv_lock` | Updates `uv.lock` in the dev container after changing dependencies. | | `make test_pypi` | Runs unit and feature tests for `pypi_package` only. Faster than `make test`. | | `make test_build_support` | Runs unit and feature tests for `build_support` only. | diff --git a/Makefile b/Makefile index a4f8d2c..cdd6cb2 100644 --- a/Makefile +++ b/Makefile @@ -190,6 +190,9 @@ setup_prod_env: setup_build_env setup_pulumi_env: setup_build_env $(EXECUTE_BUILD_STEPS_COMMAND) setup_pulumi_env +# Suppress initial docker build output unless LOG_LEVEL is TRACE +DOCKER_BUILD_QUIET = $(if $(filter TRACE,$(LOG_LEVEL)), , > /dev/null 2>&1) + .PHONY: setup_build_env setup_build_env: ifeq ($(CI_CD_FEATURE_TEST_MODE_FLAG), ) @@ -202,7 +205,7 @@ ifeq ($(CI_CD_FEATURE_TEST_MODE_FLAG), ) -f $(DOCKERFILE) \ --target build \ --build-arg BUILDKIT_INLINE_CACHE=1 \ --t $(DOCKER_BUILD_IMAGE) $(MAKEFILE_DIR) +-t $(DOCKER_BUILD_IMAGE) $(MAKEFILE_DIR)$(DOCKER_BUILD_QUIET) else echo "Skipping building build docker image in CI/CD mode." endif diff --git a/build_support/src/build_support/ci_cd_tasks/env_setup_tasks.py b/build_support/src/build_support/ci_cd_tasks/env_setup_tasks.py index b1dc0e4..24f3898 100644 --- a/build_support/src/build_support/ci_cd_tasks/env_setup_tasks.py +++ b/build_support/src/build_support/ci_cd_tasks/env_setup_tasks.py @@ -152,15 +152,18 @@ def run(self) -> None: get_feature_test_scratch_folder(project_root=self.docker_project_root), ] ) - run_process( - args=["rm", "-rf", self.docker_project_root.joinpath(".mypy_cache")] - ) + run_process(args=["rm", "-rf", self.docker_project_root.joinpath(".ty_cache")]) run_process( args=["rm", "-rf", self.docker_project_root.joinpath(".pytest_cache")] ) run_process( args=["rm", "-rf", self.docker_project_root.joinpath(".ruff_cache")] ) + run_process( + args=["rm", "-rf", str(self.docker_project_root.joinpath(".coverage"))] + ) + for path in self.docker_project_root.glob(".coverage.*"): + run_process(args=["rm", "-rf", str(path)]) class GitInfo(BaseModel): diff --git a/build_support/src/build_support/ci_cd_tasks/validation_tasks.py b/build_support/src/build_support/ci_cd_tasks/validation_tasks.py index cf6658d..f1543db 100644 --- a/build_support/src/build_support/ci_cd_tasks/validation_tasks.py +++ b/build_support/src/build_support/ci_cd_tasks/validation_tasks.py @@ -9,7 +9,7 @@ from copy import deepcopy from dataclasses import dataclass from pathlib import Path -from typing import override +from typing import cast, override from junitparser import JUnitXml from tomlkit import TOMLDocument, document, dumps, table @@ -27,7 +27,7 @@ get_base_docker_command_for_image, get_docker_command_for_image, get_docker_image_name, - get_mypy_path_env, + get_ty_extra_search_path_args, ) from build_support.ci_cd_vars.file_and_dir_path_vars import ( get_all_non_test_folders, @@ -184,17 +184,16 @@ def run(self) -> None: docker_project_root=self.docker_project_root, target_image=DockerTarget.DEV, ), - "-e", - get_mypy_path_env( - docker_project_root=self.docker_project_root, - target_image=DockerTarget.DEV, - ), get_docker_image_name( project_root=self.docker_project_root, target_image=DockerTarget.DEV, ), - "mypy", - "--explicit-package-bases", + "ty", + "check", + get_ty_extra_search_path_args( + docker_project_root=self.docker_project_root, + target_image=DockerTarget.DEV, + ), self.subproject.get_root_dir(), ] ) @@ -414,7 +413,8 @@ def get_coverage_settings(self) -> TOMLDocument: TOMLDocument. """ pyproject_data = get_pyproject_toml_data(project_root=self.docker_project_root) - return pyproject_data["tool"]["coverage"] # type: ignore[index, return-value] + tool = cast(TOMLDocument, pyproject_data["tool"]) + return cast(TOMLDocument, tool["coverage"]) def build_omit_list(self, unit_test_info: UnitTestInfo) -> list[str]: """Builds the list of omit patterns for a coverage config file. @@ -475,11 +475,10 @@ def write_coverage_config_file( coverage_config = deepcopy(coverage_settings) if "run" not in coverage_config: coverage_config["run"] = table() - existing_omit = coverage_config["run"].get( # type: ignore[union-attr] - "omit", [] - ) + run_table = cast(TOMLDocument, coverage_config["run"]) + existing_omit = run_table.get("omit", []) merged_omit = list(dict.fromkeys(list(existing_omit) + new_omit_list)) - coverage_config["run"]["omit"] = merged_omit # type: ignore[index] + run_table["omit"] = merged_omit # Write TOML file config_doc = document() diff --git a/build_support/src/build_support/ci_cd_vars/docker_vars.py b/build_support/src/build_support/ci_cd_vars/docker_vars.py index c7b9aa6..6035f97 100644 --- a/build_support/src/build_support/ci_cd_vars/docker_vars.py +++ b/build_support/src/build_support/ci_cd_vars/docker_vars.py @@ -3,7 +3,7 @@ from enum import StrEnum from os import environ from pathlib import Path -from typing import Any +from typing import Any, cast from build_support.ci_cd_vars.file_and_dir_path_vars import ( get_all_python_folders, @@ -118,23 +118,23 @@ def get_python_path_env(docker_project_root: Path, target_image: DockerTarget) - ) -def get_mypy_path_for_target_image( +def get_ty_extra_search_paths_for_target_image( docker_project_root: Path, target_image: DockerTarget -) -> str: - """Gets the mypy path to use with this project. +) -> list[Path]: + """Gets ty extra search paths for this project. Args: docker_project_root (Path): Path to this project's root when running in docker containers. target_image (DockerTarget): An enum specifying which type of docker image we - are requesting the mypy path for. + are requesting extra search paths for. Returns: - str: The MYPYPATH for the specified docker image. + list[Path]: Extra search paths for ty for the specified docker image. """ match target_image: case DockerTarget.BUILD: - python_folders = [ + search_paths = [ get_python_subproject( subproject_context=SubprojectContext.BUILD_SUPPORT, project_root=docker_project_root, @@ -143,16 +143,16 @@ def get_mypy_path_for_target_image( case DockerTarget.DEV: src_folders = get_all_src_folders(project_root=docker_project_root) test_folders = get_all_test_folders(project_root=docker_project_root) - python_folders = src_folders + test_folders + search_paths = src_folders + test_folders case DockerTarget.PROD: - python_folders = [ + search_paths = [ get_python_subproject( subproject_context=SubprojectContext.PYPI, project_root=docker_project_root, ).get_src_dir() ] case DockerTarget.INFRA: - python_folders = [ + search_paths = [ get_python_subproject( subproject_context=SubprojectContext.INFRA, project_root=docker_project_root, @@ -161,25 +161,29 @@ def get_mypy_path_for_target_image( case _: # pragma: no cov - can't hit if all enums are implemented msg = f"{target_image!r} is not a valid enum of DockerType." raise ValueError(msg) - return ":".join(concatenate_args(args=[python_folders])) + return search_paths -def get_mypy_path_env(docker_project_root: Path, target_image: DockerTarget) -> str: - """Gets the mypy path ENV to use with this project. +def get_ty_extra_search_path_args( + docker_project_root: Path, target_image: DockerTarget +) -> list[str]: + """Gets CLI args for ty extra search paths. Args: docker_project_root (Path): Path to this project's root when running in docker containers. target_image (DockerTarget): An enum specifying which type of docker image we - are requesting the mypy path for. + are requesting extra search path args for. Returns: - str: The MYPYPATH for the specified docker image in the form on an ENV variable - that can be used on the command line. + list[str]: Flattened ``--extra-search-path`` arguments for ty. """ - return "MYPYPATH=" + get_mypy_path_for_target_image( + extra_search_args: list[str] = [] + for search_path in get_ty_extra_search_paths_for_target_image( docker_project_root=docker_project_root, target_image=target_image - ) + ): + extra_search_args.extend(["--extra-search-path", str(search_path)]) + return concatenate_args(args=cast(list[Any | list[Any]], extra_search_args)) def get_base_docker_command_for_image( diff --git a/build_support/src/build_support/ci_cd_vars/file_and_dir_path_vars.py b/build_support/src/build_support/ci_cd_vars/file_and_dir_path_vars.py index 54b5363..cb7a66c 100644 --- a/build_support/src/build_support/ci_cd_vars/file_and_dir_path_vars.py +++ b/build_support/src/build_support/ci_cd_vars/file_and_dir_path_vars.py @@ -76,7 +76,7 @@ def get_test_utils_dirs(project_root: Path) -> list[Path]: The test_utils folder contains utility Python source files that are used by test files but are not test files themselves. These directories need to be in - MYPYPATH so mypy can resolve imports from test files. + ty extra search paths so imports from test files can be resolved. Args: project_root (Path): Path to this project's root. diff --git a/build_support/src/build_support/ci_cd_vars/project_setting_vars.py b/build_support/src/build_support/ci_cd_vars/project_setting_vars.py index f273ecd..62185c4 100644 --- a/build_support/src/build_support/ci_cd_vars/project_setting_vars.py +++ b/build_support/src/build_support/ci_cd_vars/project_setting_vars.py @@ -7,6 +7,7 @@ import re import tomllib from pathlib import Path +from typing import cast from tomlkit import TOMLDocument, parse @@ -46,7 +47,7 @@ def get_project_version(project_root: Path) -> str: """ pyproject_data = get_pyproject_toml_data(project_root=project_root) version_str: str - version_str = str(pyproject_data["project"]["version"]) # type: ignore[index] + version_str = str(cast(TOMLDocument, pyproject_data["project"])["version"]) if not ALLOWED_VERSION_REGEX.match(version_str): msg = ( "Project version in pyproject.toml must match the regex " @@ -90,7 +91,7 @@ def get_project_name(project_root: Path) -> str: str: The name of the project. """ pyproject_data = get_pyproject_toml_data(project_root=project_root) - return str(pyproject_data["project"]["name"]) # type: ignore[index] + return str(cast(TOMLDocument, pyproject_data["project"])["name"]) ######################################## diff --git a/build_support/src/build_support/ci_cd_vars/project_structure.py b/build_support/src/build_support/ci_cd_vars/project_structure.py index 510ac6f..e8e5321 100644 --- a/build_support/src/build_support/ci_cd_vars/project_structure.py +++ b/build_support/src/build_support/ci_cd_vars/project_structure.py @@ -86,18 +86,18 @@ def get_feature_test_scratch_folder(project_root: Path) -> Path: return maybe_build_dir(dir_to_build=project_root.joinpath("test_scratch_folder")) -def get_feature_test_log_name(project_root: Path, test_name: str) -> Path: +def get_feature_test_log_file(project_root: Path, log_name: str) -> Path: """Gets the path to a log file for a feature test. Args: project_root (Path): Path to this project's root. - test_name (str): Name of the test (will be sanitized for filename). + log_name (str): Name of the test (will be sanitized for filename). Returns: Path: Path to the log file for the test in test_scratch_folder/test_logs/. """ # Sanitize test name for use as filename - safe_test_name = test_name.replace("[", "_").replace("]", "_").replace("::", "_") + safe_test_name = log_name.replace("[", "_").replace("]", "_").replace("::", "_") # Remove trailing underscores that may result from sanitization safe_test_name = safe_test_name.rstrip("_") log_dir = maybe_build_dir( diff --git a/build_support/src/build_support/new_project_setup/update_pyproject_toml.py b/build_support/src/build_support/new_project_setup/update_pyproject_toml.py index f5cd55e..e4a513a 100644 --- a/build_support/src/build_support/new_project_setup/update_pyproject_toml.py +++ b/build_support/src/build_support/new_project_setup/update_pyproject_toml.py @@ -1,6 +1,7 @@ """Module exists to conceptually organize all changes to the pyproject.toml file.""" from pathlib import Path +from typing import cast from tomlkit import TOMLDocument, dumps @@ -24,15 +25,15 @@ def update_pyproject_toml( """ path_to_pyproject_toml = get_pyproject_toml(project_root=project_root) pyproject_data: TOMLDocument = get_pyproject_toml_data(project_root=project_root) - project = pyproject_data["project"] - project["name"] = new_project_settings.name # type: ignore[index] - project["version"] = "0.0.0" # type: ignore[index] - project["license"] = new_project_settings.license # type: ignore[index] - project["authors"] = [ # type: ignore[index] - new_project_settings.organization.as_pyproject_author() - ] - hatch = pyproject_data["tool"]["hatch"] # type: ignore[index] - hatch["build"]["targets"]["wheel"]["packages"] = [ # type: ignore[index] - f"pypi_package/src/{new_project_settings.name}" - ] + project = cast(TOMLDocument, pyproject_data["project"]) + project["name"] = new_project_settings.name + project["version"] = "0.0.0" + project["license"] = new_project_settings.license + project["authors"] = [new_project_settings.organization.as_pyproject_author()] + tool = cast(TOMLDocument, pyproject_data["tool"]) + hatch = cast(TOMLDocument, tool["hatch"]) + hatch_build = cast(TOMLDocument, hatch["build"]) + targets = cast(TOMLDocument, hatch_build["targets"]) + wheel = cast(TOMLDocument, targets["wheel"]) + wheel["packages"] = [f"pypi_package/src/{new_project_settings.name}"] path_to_pyproject_toml.write_text(dumps(pyproject_data)) diff --git a/build_support/test/feature_tests/conftest.py b/build_support/test/feature_tests/conftest.py index 44bbc3f..065dff8 100644 --- a/build_support/test/feature_tests/conftest.py +++ b/build_support/test/feature_tests/conftest.py @@ -30,6 +30,7 @@ get_python_subproject, ) from git import Head, Repo +from test_utils.command_runner import FeatureTestCommandContext def remove_dir_and_all_contents(path: Path) -> None: @@ -114,6 +115,27 @@ def make_command_prefix(make_command_prefix_without_tag_suffix: list[str]) -> li return [*make_command_prefix_without_tag_suffix, f"TAG_SUFFIX={tag_suffix}"] +@pytest.fixture +def default_command_context( + mock_project_root: Path, + make_command_prefix: list[str], + real_project_root_dir: Path, + request: SubRequest, +) -> FeatureTestCommandContext: + """Default feature test context for run_command_and_save_logs. + + Tests call run_command_and_save_logs(context=..., command_args=...). Copy and + override fields when needed (e.g. expect_failure, log_name, args_prefix). + """ + return FeatureTestCommandContext( + args_prefix=make_command_prefix, + mock_project_root=mock_project_root, + real_project_root_dir=real_project_root_dir, + test_name=request.node.name, + log_name=request.node.name, + ) + + @pytest.fixture(scope="session") def mock_lightweight_project_copy_dir(real_build_dir: Path) -> Path: """Return the directory path for the lightweight project cache. @@ -347,7 +369,9 @@ def mock_lightweight_project_with_unit_tests_and_feature_tests( Repo: The updated mock project repository. """ for subproject in [ - get_python_subproject(SubprojectContext.PYPI, mock_project_root) + get_python_subproject( + subproject_context=SubprojectContext.PYPI, project_root=mock_project_root + ) ]: subproject_pkg_dir = subproject.get_python_package_dir() subproject_pkg_init_file = subproject_pkg_dir.joinpath("__init__.py") diff --git a/build_support/test/feature_tests/test_100_template_python_project.py b/build_support/test/feature_tests/test_100_template_python_project.py index 7d32a07..e877f3e 100644 --- a/build_support/test/feature_tests/test_100_template_python_project.py +++ b/build_support/test/feature_tests/test_100_template_python_project.py @@ -1,30 +1,25 @@ from pathlib import Path import pytest -from _pytest.fixtures import SubRequest from build_support.ci_cd_tasks.env_setup_tasks import GitInfo from build_support.ci_cd_vars.project_setting_vars import get_project_name from git import Repo -from test_utils.command_runner import run_command_and_save_logs +from test_utils.command_runner import ( + FeatureTestCommandContext, + run_command_and_save_logs, +) @pytest.mark.usefixtures("mock_new_branch", "dummy_feature_test") def test_process_checks_fail_without_ticket_file_for_feature_branch( - mock_project_root: Path, - current_ticket_file: Path, - make_command_prefix: list[str], - real_project_root_dir: Path, - request: SubRequest, + default_command_context: FeatureTestCommandContext, current_ticket_file: Path ) -> None: if current_ticket_file.exists(): current_ticket_file.unlink() + default_command_context.expect_failure = True return_code, _, _ = run_command_and_save_logs( - args=[*make_command_prefix, "check_process"], - cwd=mock_project_root, - test_name=request.node.name, - real_project_root_dir=real_project_root_dir, - expect_failure=True, + context=default_command_context, command_args=["check_process"] ) assert return_code != 0 @@ -33,65 +28,49 @@ def test_process_checks_fail_without_ticket_file_for_feature_branch( "mock_new_branch", "dummy_feature_test", "ticket_for_current_branch" ) def test_process_checks_pass_with_ticket_file_for_feature_branch( - mock_project_root: Path, - make_command_prefix: list[str], - real_project_root_dir: Path, - request: SubRequest, + default_command_context: FeatureTestCommandContext, ) -> None: return_code, _, _ = run_command_and_save_logs( - args=[*make_command_prefix, "check_process"], - cwd=mock_project_root, - test_name=request.node.name, - real_project_root_dir=real_project_root_dir, + context=default_command_context, command_args=["check_process"] ) assert return_code == 0 def test_process_checks_pass_on_main_branch_without_ticket_file( - mock_project_root: Path, - mock_lightweight_project: Repo, - make_command_prefix: list[str], - real_project_root_dir: Path, - request: SubRequest, + default_command_context: FeatureTestCommandContext, mock_lightweight_project: Repo ) -> None: primary_branch_name = GitInfo.get_primary_branch_name() mock_lightweight_project.git.checkout(primary_branch_name) - project_name = get_project_name(project_root=real_project_root_dir) - main_ticket_path = mock_project_root.joinpath( + project_name = get_project_name( + project_root=default_command_context.real_project_root_dir + ) + main_ticket_path = default_command_context.mock_project_root.joinpath( "docs", "tickets", project_name, f"{primary_branch_name}.rst" ) if main_ticket_path.exists(): main_ticket_path.unlink() return_code, _, _ = run_command_and_save_logs( - args=[*make_command_prefix, "check_process"], - cwd=mock_project_root, - test_name=request.node.name, - real_project_root_dir=real_project_root_dir, + context=default_command_context, command_args=["check_process"] ) assert return_code == 0 def test_process_checks_pass_on_main_branch_with_ticket_file( - mock_project_root: Path, - mock_lightweight_project: Repo, - make_command_prefix: list[str], - real_project_root_dir: Path, - request: SubRequest, + default_command_context: FeatureTestCommandContext, mock_lightweight_project: Repo ) -> None: primary_branch_name = GitInfo.get_primary_branch_name() mock_lightweight_project.git.checkout(primary_branch_name) - project_name = get_project_name(project_root=real_project_root_dir) - main_ticket_path = mock_project_root.joinpath( + project_name = get_project_name( + project_root=default_command_context.real_project_root_dir + ) + main_ticket_path = default_command_context.mock_project_root.joinpath( "docs", "tickets", project_name, f"{primary_branch_name}.rst" ) main_ticket_path.parent.mkdir(parents=True, exist_ok=True) main_ticket_path.write_text("Main Ticket\n===========\n") return_code, _, _ = run_command_and_save_logs( - args=[*make_command_prefix, "check_process"], - cwd=mock_project_root, - test_name=request.node.name, - real_project_root_dir=real_project_root_dir, + context=default_command_context, command_args=["check_process"] ) assert return_code == 0 diff --git a/build_support/test/feature_tests/test_107_template_python_project.py b/build_support/test/feature_tests/test_107_template_python_project.py index a987d6b..03e76c0 100644 --- a/build_support/test/feature_tests/test_107_template_python_project.py +++ b/build_support/test/feature_tests/test_107_template_python_project.py @@ -1,12 +1,16 @@ """Feature tests for branch-scoped Docker image tags.""" +import copy from pathlib import Path from subprocess import run from build_support.ci_cd_vars.git_status_vars import PRIMARY_BRANCH_NAME, get_ticket_id from build_support.ci_cd_vars.project_setting_vars import get_project_name from git import Head, Repo -from test_utils.command_runner import run_command_and_save_logs +from test_utils.command_runner import ( + FeatureTestCommandContext, + run_command_and_save_logs, +) def _parse_echo_image_tags_stdout(stdout: str) -> dict[str, str]: @@ -53,52 +57,46 @@ def _create_and_checkout_branch( def test_make_echo_image_tags_on_main_shows_unsuffixed( - mock_project_root: Path, + default_command_context: FeatureTestCommandContext, mock_lightweight_project: Repo, make_command_prefix_without_tag_suffix: list[str], - real_project_root_dir: Path, ) -> None: """On main, echo_image_tags reports empty TAG_SUFFIX and unsuffixed image names.""" mock_lightweight_project.git.checkout(PRIMARY_BRANCH_NAME) + default_command_context.args_prefix = [ + *make_command_prefix_without_tag_suffix, + "CI_CD_FEATURE_TEST_MODE_FLAG=", + ] return_code, stdout, _ = run_command_and_save_logs( - args=[ - *make_command_prefix_without_tag_suffix, - "CI_CD_FEATURE_TEST_MODE_FLAG=", - "echo_image_tags", - ], - cwd=mock_project_root, - test_name="test_make_echo_image_tags_on_main_shows_unsuffixed", - real_project_root_dir=real_project_root_dir, + context=default_command_context, command_args=["echo_image_tags"] ) assert return_code == 0 parsed = _parse_echo_image_tags_stdout(stdout) assert parsed.get("TAG_SUFFIX") == "", ( f"On main TAG_SUFFIX should be empty, got {parsed.get('TAG_SUFFIX')!r}" ) - expected = _expected_image_names(project_root=mock_project_root, tag_suffix="") + expected = _expected_image_names( + project_root=default_command_context.mock_project_root, tag_suffix="" + ) assert parsed.get("DOCKER_BUILD_IMAGE") == expected[0] assert parsed.get("DOCKER_DEV_IMAGE") == expected[1] assert parsed.get("DOCKER_PROD_IMAGE") == expected[2] def test_make_echo_image_tags_on_non_main_shows_ticket_suffix( - mock_project_root: Path, + default_command_context: FeatureTestCommandContext, make_command_prefix_without_tag_suffix: list[str], mock_new_branch: Head, current_ticket_id: str, - real_project_root_dir: Path, ) -> None: """On non-main branches, reports ``-`` and suffixed names.""" assert mock_new_branch.name.startswith(current_ticket_id) + default_command_context.args_prefix = [ + *make_command_prefix_without_tag_suffix, + "CI_CD_FEATURE_TEST_MODE_FLAG=", + ] return_code, stdout, _ = run_command_and_save_logs( - args=[ - *make_command_prefix_without_tag_suffix, - "CI_CD_FEATURE_TEST_MODE_FLAG=", - "echo_image_tags", - ], - cwd=mock_project_root, - test_name="test_make_echo_image_tags_on_non_main_shows_ticket_suffix", - real_project_root_dir=real_project_root_dir, + context=default_command_context, command_args=["echo_image_tags"] ) assert return_code == 0 parsed = _parse_echo_image_tags_stdout(stdout) @@ -108,7 +106,8 @@ def test_make_echo_image_tags_on_non_main_shows_ticket_suffix( f"{parsed.get('TAG_SUFFIX')!r}" ) expected = _expected_image_names( - project_root=mock_project_root, tag_suffix=expected_suffix + project_root=default_command_context.mock_project_root, + tag_suffix=expected_suffix, ) assert parsed.get("DOCKER_BUILD_IMAGE") == expected[0] assert parsed.get("DOCKER_DEV_IMAGE") == expected[1] @@ -116,14 +115,13 @@ def test_make_echo_image_tags_on_non_main_shows_ticket_suffix( def test_different_ticket_branches_build_different_image_tags( - mock_project_root: Path, + default_command_context: FeatureTestCommandContext, mock_lightweight_project: Repo, mock_remote_git_repo: Repo, make_command_prefix_without_tag_suffix: list[str], - real_project_root_dir: Path, ) -> None: """Different ticket branches should build different image tag names.""" - tid = get_ticket_id(project_root=real_project_root_dir) + tid = get_ticket_id(project_root=default_command_context.real_project_root_dir) first_ticket_id = f"{tid}TEST001" if tid else "TEST001" second_ticket_id = f"{tid}TEST002" if tid else "TEST002" @@ -133,15 +131,13 @@ def test_different_ticket_branches_build_different_image_tags( branch_name=f"{first_ticket_id}-first-branch", ) assert first_branch.name.startswith(first_ticket_id) + default_command_context.args_prefix = [ + *make_command_prefix_without_tag_suffix, + "CI_CD_FEATURE_TEST_MODE_FLAG=", + ] + default_command_context.log_name = f"{default_command_context.test_name}_first" first_setup_build_return_code, _, _ = run_command_and_save_logs( - args=[ - *make_command_prefix_without_tag_suffix, - "CI_CD_FEATURE_TEST_MODE_FLAG=", - "setup_build_env", - ], - cwd=mock_project_root, - test_name="test_different_ticket_branches_build_different_image_tags_first", - real_project_root_dir=real_project_root_dir, + context=default_command_context, command_args=["setup_build_env"] ) assert first_setup_build_return_code == 0 @@ -151,19 +147,16 @@ def test_different_ticket_branches_build_different_image_tags( branch_name=f"{second_ticket_id}-second-branch", ) assert second_branch.name.startswith(second_ticket_id) + second_context = copy.copy(default_command_context) + second_context.log_name = f"{default_command_context.test_name}_second" second_setup_build_return_code, _, _ = run_command_and_save_logs( - args=[ - *make_command_prefix_without_tag_suffix, - "CI_CD_FEATURE_TEST_MODE_FLAG=", - "setup_build_env", - ], - cwd=mock_project_root, - test_name="test_different_ticket_branches_build_different_image_tags_second", - real_project_root_dir=real_project_root_dir, + context=second_context, command_args=["setup_build_env"] ) assert second_setup_build_return_code == 0 - project_name = get_project_name(project_root=mock_project_root) + project_name = get_project_name( + project_root=default_command_context.mock_project_root + ) first_image = f"{project_name}:build-{first_ticket_id}" second_image = f"{project_name}:build-{second_ticket_id}" assert first_image != second_image diff --git a/build_support/test/feature_tests/test_79_template_python_project.py b/build_support/test/feature_tests/test_79_template_python_project.py index 4244b76..72326c2 100644 --- a/build_support/test/feature_tests/test_79_template_python_project.py +++ b/build_support/test/feature_tests/test_79_template_python_project.py @@ -1,71 +1,54 @@ -from pathlib import Path - import pytest -from _pytest.fixtures import SubRequest from build_support.ci_cd_vars.project_setting_vars import get_project_name from build_support.ci_cd_vars.subproject_structure import ( PythonSubproject, SubprojectContext, get_python_subproject, ) -from test_utils.command_runner import run_command_and_save_logs +from test_utils.command_runner import ( + FeatureTestCommandContext, + run_command_and_save_logs, +) @pytest.mark.usefixtures("mock_new_branch", "ticket_for_current_branch") def test_check_feature_test_added( - mock_project_root: Path, - current_ticket_id: str, - make_command_prefix: list[str], - real_project_root_dir: Path, - request: SubRequest, + default_command_context: FeatureTestCommandContext, current_ticket_id: str ) -> None: build_support_subproject = get_python_subproject( - project_root=mock_project_root, + project_root=default_command_context.mock_project_root, subproject_context=SubprojectContext.BUILD_SUPPORT, ) - project_name = get_project_name(project_root=mock_project_root) + project_name = get_project_name( + project_root=default_command_context.mock_project_root + ) build_support_subproject.get_test_suite_dir( test_suite=PythonSubproject.TestSuite.FEATURE_TESTS ).joinpath(f"test_{current_ticket_id}_{project_name}.py").write_text( "def test_something() -> None:\n assert True\n" ) return_code, _, _ = run_command_and_save_logs( - args=[*make_command_prefix, "check_process"], - cwd=mock_project_root, - test_name=request.node.name, - real_project_root_dir=real_project_root_dir, + context=default_command_context, command_args=["check_process"] ) assert return_code == 0 @pytest.mark.usefixtures("mock_new_branch") def test_fail_check_feature_test_not_added_to_branch( - mock_project_root: Path, - make_command_prefix: list[str], - real_project_root_dir: Path, - request: SubRequest, + default_command_context: FeatureTestCommandContext, ) -> None: + default_command_context.expect_failure = True return_code, _, _ = run_command_and_save_logs( - args=[*make_command_prefix, "check_process"], - cwd=mock_project_root, - test_name=request.node.name, - real_project_root_dir=real_project_root_dir, - expect_failure=True, + context=default_command_context, command_args=["check_process"] ) assert return_code != 0 @pytest.mark.usefixtures("mock_lightweight_project") def test_pass_check_feature_test_not_added_to_main( - mock_project_root: Path, - make_command_prefix: list[str], - real_project_root_dir: Path, - request: SubRequest, + default_command_context: FeatureTestCommandContext, ) -> None: return_code, _, _ = run_command_and_save_logs( - args=[*make_command_prefix, "check_process"], - cwd=mock_project_root, - test_name=request.node.name, - real_project_root_dir=real_project_root_dir, + context=default_command_context, command_args=["check_process"] ) assert return_code == 0 diff --git a/build_support/test/feature_tests/test_81_template_python_project.py b/build_support/test/feature_tests/test_81_template_python_project.py index 53301ab..13e302f 100644 --- a/build_support/test/feature_tests/test_81_template_python_project.py +++ b/build_support/test/feature_tests/test_81_template_python_project.py @@ -1,34 +1,32 @@ +import copy from datetime import timedelta -from pathlib import Path import pytest -from _pytest.fixtures import SubRequest from build_support.ci_cd_vars.build_paths import get_build_runtime_report_path from build_support.dag_engine import BuildRunReport -from test_utils.command_runner import run_command_and_save_logs +from test_utils.command_runner import ( + FeatureTestCommandContext, + run_command_and_save_logs, +) @pytest.mark.usefixtures("mock_lightweight_project_with_single_feature_test") def test_feature_tests_execute_faster_when_cached( - mock_project_root: Path, - make_command_prefix: list[str], - real_project_root_dir: Path, - request: SubRequest, + default_command_context: FeatureTestCommandContext, ) -> None: + default_command_context.log_name = f"{default_command_context.test_name}_first" return_code, _, _ = run_command_and_save_logs( - args=[*make_command_prefix, "test_pypi_features"], - cwd=mock_project_root, - test_name=f"{request.node.name}_first", - real_project_root_dir=real_project_root_dir, + context=default_command_context, command_args=["test_pypi_features"] ) assert return_code == 0 - expected_report_yaml = get_build_runtime_report_path(project_root=mock_project_root) + expected_report_yaml = get_build_runtime_report_path( + project_root=default_command_context.mock_project_root + ) first_runtime_report = BuildRunReport.from_yaml(expected_report_yaml.read_text()) + second_context = copy.copy(default_command_context) + second_context.log_name = f"{default_command_context.test_name}_second" return_code, _, _ = run_command_and_save_logs( - args=[*make_command_prefix, "test_pypi_features"], - cwd=mock_project_root, - test_name=f"{request.node.name}_second", - real_project_root_dir=real_project_root_dir, + context=second_context, command_args=["test_pypi_features"] ) assert return_code == 0 second_runtime_report = BuildRunReport.from_yaml(expected_report_yaml.read_text()) diff --git a/build_support/test/feature_tests/test_82_template_python_project.py b/build_support/test/feature_tests/test_82_template_python_project.py index f0313a7..4531973 100644 --- a/build_support/test/feature_tests/test_82_template_python_project.py +++ b/build_support/test/feature_tests/test_82_template_python_project.py @@ -1,28 +1,24 @@ -from pathlib import Path - import pytest -from _pytest.fixtures import SubRequest from build_support.ci_cd_vars.build_paths import get_build_runtime_report_path from build_support.dag_engine import BuildRunReport -from test_utils.command_runner import run_command_and_save_logs +from test_utils.command_runner import ( + FeatureTestCommandContext, + run_command_and_save_logs, +) @pytest.mark.usefixtures( "mock_lightweight_project", "mock_lightweight_project_on_feature_branch" ) def test_pass_generate_runtime_report_after_dag_execution( - mock_project_root: Path, - make_command_prefix: list[str], - real_project_root_dir: Path, - request: SubRequest, + default_command_context: FeatureTestCommandContext, ) -> None: - expected_report_yaml = get_build_runtime_report_path(project_root=mock_project_root) + expected_report_yaml = get_build_runtime_report_path( + project_root=default_command_context.mock_project_root + ) assert not expected_report_yaml.exists() return_code, _, _ = run_command_and_save_logs( - args=[*make_command_prefix, "format"], - cwd=mock_project_root, - test_name=request.node.name, - real_project_root_dir=real_project_root_dir, + context=default_command_context, command_args=["format"] ) assert return_code == 0 parsed_report = BuildRunReport.from_yaml(expected_report_yaml.read_text()) diff --git a/build_support/test/feature_tests/test_85_template_python_project.py b/build_support/test/feature_tests/test_85_template_python_project.py index e4733d9..b8d8a58 100644 --- a/build_support/test/feature_tests/test_85_template_python_project.py +++ b/build_support/test/feature_tests/test_85_template_python_project.py @@ -1,582 +1,23 @@ -from pathlib import Path +"""Feature tests for ticket 85: static type checking. -import pytest -from _pytest.fixtures import SubRequest -from build_support.ci_cd_vars.subproject_structure import ( - SubprojectContext, - get_python_subproject, -) -from test_utils.command_runner import run_command_and_save_logs - - -@pytest.mark.usefixtures( - "mock_lightweight_project", "mock_lightweight_project_on_feature_branch" -) -def test_mypy_passes_no_issues( - mock_project_root: Path, - make_command_prefix: list[str], - real_project_root_dir: Path, - request: SubRequest, -) -> None: - # Default state - should pass as long as style checks are passing for the repo - return_code, _, _ = run_command_and_save_logs( - args=[*make_command_prefix, "type_check_pypi"], - cwd=mock_project_root, - test_name=request.node.name, - real_project_root_dir=real_project_root_dir, - ) - assert return_code == 0 - - -@pytest.mark.usefixtures( - "mock_lightweight_project", "mock_lightweight_project_on_feature_branch" -) -def test_mypy_fails_type_arg( - mock_project_root: Path, - make_command_prefix: list[str], - real_project_root_dir: Path, - request: SubRequest, -) -> None: - # Putting "bad" file in test avoids additional src style enforcement - get_python_subproject( - project_root=mock_project_root, subproject_context=SubprojectContext.PYPI - ).get_test_dir().joinpath("test_mypy_fails_type_arg.py").write_text( - '''def some_function(items: list) -> list: - """func docstring""" - return items -''' - ) - return_code, _, _ = run_command_and_save_logs( - args=[*make_command_prefix, "type_check_pypi"], - cwd=mock_project_root, - test_name=request.node.name, - real_project_root_dir=real_project_root_dir, - expect_failure=True, - ) - assert return_code != 0 - - -@pytest.mark.usefixtures( - "mock_lightweight_project", "mock_lightweight_project_on_feature_branch" -) -def test_mypy_fails_no_untyped_def( - mock_project_root: Path, - make_command_prefix: list[str], - real_project_root_dir: Path, - request: SubRequest, -) -> None: - # Putting "bad" file in test avoids additional src style enforcement - get_python_subproject( - project_root=mock_project_root, subproject_context=SubprojectContext.PYPI - ).get_test_dir().joinpath("test_mypy_fails_no_untyped_def.py").write_text( - '''def some_function(items) -> list: - """func docstring""" - return items -''' - ) - return_code, _, _ = run_command_and_save_logs( - args=[*make_command_prefix, "type_check_pypi"], - cwd=mock_project_root, - test_name=request.node.name, - real_project_root_dir=real_project_root_dir, - expect_failure=True, - ) - assert return_code != 0 - - -@pytest.mark.usefixtures( - "mock_lightweight_project", "mock_lightweight_project_on_feature_branch" -) -def test_mypy_fails_redundant_cast( - mock_project_root: Path, - make_command_prefix: list[str], - real_project_root_dir: Path, - request: SubRequest, -) -> None: - # Putting "bad" file in test avoids additional src style enforcement - get_python_subproject( - project_root=mock_project_root, subproject_context=SubprojectContext.PYPI - ).get_test_dir().joinpath("test_mypy_fails_redundant_cast.py").write_text( - '''def example(x: Count) -> int: - """func docstring""" - return cast(int, x) -''' - ) - return_code, _, _ = run_command_and_save_logs( - args=[*make_command_prefix, "type_check_pypi"], - cwd=mock_project_root, - test_name=request.node.name, - real_project_root_dir=real_project_root_dir, - expect_failure=True, - ) - assert return_code != 0 - - -@pytest.mark.usefixtures( - "mock_lightweight_project", "mock_lightweight_project_on_feature_branch" -) -def test_mypy_fails_redundant_self( - mock_project_root: Path, - make_command_prefix: list[str], - real_project_root_dir: Path, - request: SubRequest, -) -> None: - # Putting "bad" file in test avoids additional src style enforcement - get_python_subproject( - project_root=mock_project_root, subproject_context=SubprojectContext.PYPI - ).get_test_dir().joinpath("test_mypy_fails_redundant_self.py").write_text( - '''def copy(self: Self) -> Self: - """func docstring""" - return type(self)() -''' - ) - return_code, _, _ = run_command_and_save_logs( - args=[*make_command_prefix, "type_check_pypi"], - cwd=mock_project_root, - test_name=request.node.name, - real_project_root_dir=real_project_root_dir, - expect_failure=True, - ) - assert return_code != 0 - - -@pytest.mark.usefixtures( - "mock_lightweight_project", "mock_lightweight_project_on_feature_branch" -) -def test_mypy_fails_comparison_overlap( - mock_project_root: Path, - make_command_prefix: list[str], - real_project_root_dir: Path, - request: SubRequest, -) -> None: - # Putting "bad" file in test avoids additional src style enforcement - get_python_subproject( - project_root=mock_project_root, subproject_context=SubprojectContext.PYPI - ).get_test_dir().joinpath("test_mypy_fails_comparison_overlap.py").write_text( - '''def is_magic(x: bytes) -> bool: - """func docstring""" - return x == 'magic' -''' - ) - return_code, _, _ = run_command_and_save_logs( - args=[*make_command_prefix, "type_check_pypi"], - cwd=mock_project_root, - test_name=request.node.name, - real_project_root_dir=real_project_root_dir, - expect_failure=True, - ) - assert return_code != 0 - - -@pytest.mark.usefixtures( - "mock_lightweight_project", "mock_lightweight_project_on_feature_branch" -) -def test_mypy_fails_no_untyped_call( - mock_project_root: Path, - make_command_prefix: list[str], - real_project_root_dir: Path, - request: SubRequest, -) -> None: - # Putting "bad" file in test avoids additional src style enforcement - get_python_subproject( - project_root=mock_project_root, subproject_context=SubprojectContext.PYPI - ).get_test_dir().joinpath("test_mypy_fails_no_untyped_call.py").write_text( - '''def do_it() -> None: - """func docstring""" - bad() - -def bad(): - """docstring""" - return -''' - ) - return_code, _, _ = run_command_and_save_logs( - args=[*make_command_prefix, "type_check_pypi"], - cwd=mock_project_root, - test_name=request.node.name, - real_project_root_dir=real_project_root_dir, - expect_failure=True, - ) - assert return_code != 0 - - -@pytest.mark.usefixtures( - "mock_lightweight_project", "mock_lightweight_project_on_feature_branch" -) -def test_mypy_fails_no_any_return( - mock_project_root: Path, - make_command_prefix: list[str], - real_project_root_dir: Path, - request: SubRequest, -) -> None: - # Putting "bad" file in test avoids additional src style enforcement - get_python_subproject( - project_root=mock_project_root, subproject_context=SubprojectContext.PYPI - ).get_test_dir().joinpath("test_mypy_fails_no_any_return.py").write_text( - '''def some_function(x: dict[str, Any]) -> str: - """func docstring""" - return x['str'] -''' - ) - return_code, _, _ = run_command_and_save_logs( - args=[*make_command_prefix, "type_check_pypi"], - cwd=mock_project_root, - test_name=request.node.name, - real_project_root_dir=real_project_root_dir, - expect_failure=True, - ) - assert return_code != 0 - - -@pytest.mark.usefixtures( - "mock_lightweight_project", "mock_lightweight_project_on_feature_branch" -) -def test_mypy_fails_no_any_unimported( - mock_project_root: Path, - make_command_prefix: list[str], - real_project_root_dir: Path, - request: SubRequest, -) -> None: - # Putting "bad" file in test avoids additional src style enforcement - get_python_subproject( - project_root=mock_project_root, subproject_context=SubprojectContext.PYPI - ).get_test_dir().joinpath("test_mypy_fails_no_any_unimported.py").write_text( - '''from animals import Cat - - -def feed(cat: Cat) -> None: - """func docstring""" - return -''' - ) - return_code, _, _ = run_command_and_save_logs( - args=[*make_command_prefix, "type_check_pypi"], - cwd=mock_project_root, - test_name=request.node.name, - real_project_root_dir=real_project_root_dir, - expect_failure=True, - ) - assert return_code != 0 - - -@pytest.mark.usefixtures( - "mock_lightweight_project", "mock_lightweight_project_on_feature_branch" -) -def test_mypy_fails_redundant_expr( - mock_project_root: Path, - make_command_prefix: list[str], - real_project_root_dir: Path, - request: SubRequest, -) -> None: - # Putting "bad" file in test avoids additional src style enforcement - get_python_subproject( - project_root=mock_project_root, subproject_context=SubprojectContext.PYPI - ).get_test_dir().joinpath("test_mypy_fails_redundant_expr.py").write_text( - '''def example(x: int) -> int: - """func docstring""" - if isinstance(x, int) and x > 0: - return 1 - else: - return 0 -''' - ) - return_code, _, _ = run_command_and_save_logs( - args=[*make_command_prefix, "type_check_pypi"], - cwd=mock_project_root, - test_name=request.node.name, - real_project_root_dir=real_project_root_dir, - expect_failure=True, - ) - assert return_code != 0 - - -@pytest.mark.usefixtures( - "mock_lightweight_project", "mock_lightweight_project_on_feature_branch" -) -def test_mypy_fails_possibly_undefined( - mock_project_root: Path, - make_command_prefix: list[str], - real_project_root_dir: Path, - request: SubRequest, -) -> None: - # Putting "bad" file in test avoids additional src style enforcement - get_python_subproject( - project_root=mock_project_root, subproject_context=SubprojectContext.PYPI - ).get_test_dir().joinpath("test_mypy_fails_possibly_undefined.py").write_text( - '''def some_function(val: int, flag: bool) -> int: - """func docstring""" - if flag: - a = 12 - return val + a -''' - ) - return_code, _, _ = run_command_and_save_logs( - args=[*make_command_prefix, "type_check_pypi"], - cwd=mock_project_root, - test_name=request.node.name, - real_project_root_dir=real_project_root_dir, - expect_failure=True, - ) - assert return_code != 0 +This ticket originally introduced mypy for static type checking. The project +has since switched from mypy to ty (ticket 98). Type-checking behavior is +now covered by test_98_template_python_project.py, which verifies that ty is +used and exercises the type-checker rules. - -@pytest.mark.usefixtures( - "mock_lightweight_project", "mock_lightweight_project_on_feature_branch" -) -def test_mypy_fails_truthy_bool( - mock_project_root: Path, - make_command_prefix: list[str], - real_project_root_dir: Path, - request: SubRequest, -) -> None: - # Putting "bad" file in test avoids additional src style enforcement - get_python_subproject( - project_root=mock_project_root, subproject_context=SubprojectContext.PYPI - ).get_test_dir().joinpath("test_mypy_fails_truthy_bool.py").write_text( - """class Foo: - pass -foo = Foo() - -if foo: - a = 0 +This file remains as a placeholder so that ticket 85 still has an associated +feature test file for the CI/CD pipeline. """ - ) - return_code, _, _ = run_command_and_save_logs( - args=[*make_command_prefix, "type_check_pypi"], - cwd=mock_project_root, - test_name=request.node.name, - real_project_root_dir=real_project_root_dir, - expect_failure=True, - ) - assert return_code != 0 - - -@pytest.mark.usefixtures( - "mock_lightweight_project", "mock_lightweight_project_on_feature_branch" -) -def test_mypy_fails_truthy_iterable( - mock_project_root: Path, - make_command_prefix: list[str], - real_project_root_dir: Path, - request: SubRequest, -) -> None: - # Putting "bad" file in test avoids additional src style enforcement - get_python_subproject( - project_root=mock_project_root, subproject_context=SubprojectContext.PYPI - ).get_test_dir().joinpath("test_mypy_fails_truthy_iterable.py").write_text( - '''from typing import Iterable - -def transform(items: Iterable[int]) -> list[int]: - """func docstring""" - if not items: - return [42] - return [x + 1 for x in items] -''' - ) - return_code, _, _ = run_command_and_save_logs( - args=[*make_command_prefix, "type_check_pypi"], - cwd=mock_project_root, - test_name=request.node.name, - real_project_root_dir=real_project_root_dir, - expect_failure=True, - ) - assert return_code != 0 - - -@pytest.mark.usefixtures( - "mock_lightweight_project", "mock_lightweight_project_on_feature_branch" -) -def test_mypy_fails_ignore_without_code( - mock_project_root: Path, - make_command_prefix: list[str], - real_project_root_dir: Path, - request: SubRequest, -) -> None: - # Putting "bad" file in test avoids additional src style enforcement - get_python_subproject( - project_root=mock_project_root, subproject_context=SubprojectContext.PYPI - ).get_test_dir().joinpath("test_mypy_fails_ignore_without_code.py").write_text( - '''def some_function(items: list) -> list: # type: ignore - """func docstring""" - return items -''' - ) - return_code, _, _ = run_command_and_save_logs( - args=[*make_command_prefix, "type_check_pypi"], - cwd=mock_project_root, - test_name=request.node.name, - real_project_root_dir=real_project_root_dir, - expect_failure=True, - ) - assert return_code != 0 - -@pytest.mark.usefixtures( - "mock_lightweight_project", "mock_lightweight_project_on_feature_branch" -) -def test_mypy_fails_unused_awaitable( - mock_project_root: Path, - make_command_prefix: list[str], - real_project_root_dir: Path, - request: SubRequest, -) -> None: - # Putting "bad" file in test avoids additional src style enforcement - get_python_subproject( - project_root=mock_project_root, subproject_context=SubprojectContext.PYPI - ).get_test_dir().joinpath("test_mypy_fails_unused_awaitable.py").write_text( - """import asyncio - -async def f() -> int: - return 0 - -async def g() -> None: - asyncio.create_task(f()) -""" - ) - return_code, _, _ = run_command_and_save_logs( - args=[*make_command_prefix, "type_check_pypi"], - cwd=mock_project_root, - test_name=request.node.name, - real_project_root_dir=real_project_root_dir, - expect_failure=True, - ) - assert return_code != 0 - - -@pytest.mark.usefixtures( - "mock_lightweight_project", "mock_lightweight_project_on_feature_branch" -) -def test_mypy_fails_unused_ignore( - mock_project_root: Path, - make_command_prefix: list[str], - real_project_root_dir: Path, - request: SubRequest, -) -> None: - # Putting "bad" file in test avoids additional src style enforcement - get_python_subproject( - project_root=mock_project_root, subproject_context=SubprojectContext.PYPI - ).get_test_dir().joinpath("test_mypy_fails_unused_ignore.py").write_text( - '''def add(a: int, b: int) -> int: - """func docstring""" - return a + b # type: ignore[unused-awaitable] -''' - ) - return_code, _, _ = run_command_and_save_logs( - args=[*make_command_prefix, "type_check_pypi"], - cwd=mock_project_root, - test_name=request.node.name, - real_project_root_dir=real_project_root_dir, - expect_failure=True, - ) - assert return_code != 0 - - -@pytest.mark.usefixtures( - "mock_lightweight_project", "mock_lightweight_project_on_feature_branch" -) -def test_mypy_fails_explicit_override( - mock_project_root: Path, - make_command_prefix: list[str], - real_project_root_dir: Path, - request: SubRequest, -) -> None: - # Putting "bad" file in test avoids additional src style enforcement - get_python_subproject( - project_root=mock_project_root, subproject_context=SubprojectContext.PYPI - ).get_test_dir().joinpath("test_mypy_fails_explicit_override.py").write_text( - '''from typing import override - -class Parent: - def f(self, x: int) -> None: - """func docstring""" - pass - - def g(self, y: int) -> None: - """func docstring""" - pass - - -class Child(Parent): - def f(self, x: int) -> None: # Error: Missing @override decorator - """func docstring""" - pass - - @override - def g(self, y: int) -> None: - """func docstring""" - pass -''' - ) - return_code, _, _ = run_command_and_save_logs( - args=[*make_command_prefix, "type_check_pypi"], - cwd=mock_project_root, - test_name=request.node.name, - real_project_root_dir=real_project_root_dir, - expect_failure=True, - ) - assert return_code != 0 - - -@pytest.mark.usefixtures( - "mock_lightweight_project", "mock_lightweight_project_on_feature_branch" -) -def test_mypy_fails_mutable_override( - mock_project_root: Path, - make_command_prefix: list[str], - real_project_root_dir: Path, - request: SubRequest, -) -> None: - # Putting "bad" file in test avoids additional src style enforcement - get_python_subproject( - project_root=mock_project_root, subproject_context=SubprojectContext.PYPI - ).get_test_dir().joinpath("test_mypy_fails_mutable_override.py").write_text( - """from typing import Any - -class C: - x: float - y: float - z: float - -class D(C): - x: int # Error: Covariant override of a mutable attribute - # (base class "C" defined the type as "float", - # expression has type "int") [mutable-override] - y: float # OK - z: Any # OK -""" - ) - return_code, _, _ = run_command_and_save_logs( - args=[*make_command_prefix, "type_check_pypi"], - cwd=mock_project_root, - test_name=request.node.name, - real_project_root_dir=real_project_root_dir, - expect_failure=True, - ) - assert return_code != 0 +import pytest +from test_utils.command_runner import FeatureTestCommandContext @pytest.mark.usefixtures( "mock_lightweight_project", "mock_lightweight_project_on_feature_branch" ) -def test_mypy_fails_unimported_reveal( - mock_project_root: Path, - make_command_prefix: list[str], - real_project_root_dir: Path, - request: SubRequest, +def test_85_placeholder_type_checking_ticket( + default_command_context: FeatureTestCommandContext, ) -> None: - # Putting "bad" file in test avoids additional src style enforcement - get_python_subproject( - project_root=mock_project_root, subproject_context=SubprojectContext.PYPI - ).get_test_dir().joinpath("test_mypy_fails_unimported_reveal.py").write_text( - """x = 1 -reveal_type(x) -""" - ) - return_code, _, _ = run_command_and_save_logs( - args=[*make_command_prefix, "type_check_pypi"], - cwd=mock_project_root, - test_name=request.node.name, - real_project_root_dir=real_project_root_dir, - expect_failure=True, - ) - assert return_code != 0 + """Placeholder test for ticket 85; type checking is now tested in test_98.""" + assert default_command_context.mock_project_root is not None diff --git a/build_support/test/feature_tests/test_86_template_python_project.py b/build_support/test/feature_tests/test_86_template_python_project.py index e97f40c..af6d20f 100644 --- a/build_support/test/feature_tests/test_86_template_python_project.py +++ b/build_support/test/feature_tests/test_86_template_python_project.py @@ -15,7 +15,7 @@ Because the Docker images are built from the real project but run against the mock project, any source files generated by the test - must satisfy the full build pipeline (ruff lint, mypy type checks, + must satisfy the full build pipeline (ruff lint, ty type checks, style enforcement including Google-style docstrings with typed ``Args`` and ``Returns`` sections, and module-level docstrings). @@ -31,10 +31,8 @@ """ from datetime import timedelta -from pathlib import Path import pytest -from _pytest.fixtures import SubRequest from build_support.ci_cd_tasks.validation_tasks import ( SubprojectFeatureTests, SubprojectUnitTests, @@ -48,15 +46,15 @@ get_python_subproject, ) from build_support.dag_engine import BuildRunReport -from test_utils.command_runner import run_command_and_save_logs +from test_utils.command_runner import ( + FeatureTestCommandContext, + run_command_and_save_logs, +) @pytest.mark.usefixtures("mock_lightweight_project_with_unit_tests_and_feature_tests") def test_do_not_run_tests_for_unmodified_projects( - request: SubRequest, - mock_project_root: Path, - make_command_prefix: list[str], - real_project_root_dir: Path, + default_command_context: FeatureTestCommandContext, ) -> None: """Verify that unmodified subprojects have their tests skipped. @@ -64,13 +62,10 @@ def test_do_not_run_tests_for_unmodified_projects( subproject test tasks should complete near-instantly (under 0.1s), indicating they were effectively skipped. """ - run_command_and_save_logs( - args=[*make_command_prefix, "test"], - cwd=mock_project_root, - test_name=request.node.name, - real_project_root_dir=real_project_root_dir, + run_command_and_save_logs(context=default_command_context, command_args=["test"]) + expected_report_yaml = get_build_runtime_report_path( + project_root=default_command_context.mock_project_root ) - expected_report_yaml = get_build_runtime_report_path(project_root=mock_project_root) runtime_report = BuildRunReport.from_yaml(expected_report_yaml.read_text()) assert all( @@ -83,10 +78,7 @@ def test_do_not_run_tests_for_unmodified_projects( @pytest.mark.usefixtures("mock_lightweight_project_with_unit_tests_and_feature_tests") def test_run_tests_for_single_modified_subproject( - request: SubRequest, - mock_project_root: Path, - make_command_prefix: list[str], - real_project_root_dir: Path, + default_command_context: FeatureTestCommandContext, ) -> None: """Verify that only the modified subproject's tests are executed. @@ -100,7 +92,8 @@ def test_run_tests_for_single_modified_subproject( all other subproject test tasks completed near-instantly (<0.1s). """ subproject = get_python_subproject( - subproject_context=SubprojectContext.PYPI, project_root=mock_project_root + subproject_context=SubprojectContext.PYPI, + project_root=default_command_context.mock_project_root, ) subproject_pkg_dir = subproject.get_python_package_dir() subproject_src_file = subproject_pkg_dir.joinpath("src_file.py") @@ -142,7 +135,9 @@ def subtract_slow(a: int, b: int) -> int: test_suite=PythonSubproject.TestSuite.UNIT_TESTS ) project_unit_test_file = project_unit_test_dir.joinpath("test_src_file.py") - project_name = get_project_name(project_root=mock_project_root) + project_name = get_project_name( + project_root=default_command_context.mock_project_root + ) project_unit_test_file.write_text( f'''from {project_name}.src_file import add_slow, subtract_slow @@ -180,13 +175,10 @@ def test_subtract_slow() -> None: ''' ) - run_command_and_save_logs( - args=[*make_command_prefix, "test"], - cwd=mock_project_root, - test_name=request.node.name, - real_project_root_dir=real_project_root_dir, + run_command_and_save_logs(context=default_command_context, command_args=["test"]) + expected_report_yaml = get_build_runtime_report_path( + project_root=default_command_context.mock_project_root ) - expected_report_yaml = get_build_runtime_report_path(project_root=mock_project_root) runtime_report = BuildRunReport.from_yaml(expected_report_yaml.read_text()) assert all( @@ -211,26 +203,20 @@ def test_subtract_slow() -> None: @pytest.mark.usefixtures("mock_lightweight_project_with_unit_tests_and_feature_tests") def test_run_all_tests_if_dockerfile_modified( - request: SubRequest, - mock_project_root: Path, - make_command_prefix: list[str], - real_project_root_dir: Path, + default_command_context: FeatureTestCommandContext, ) -> None: """Verify all tests run when the Dockerfile is modified. Appending a newline to the Dockerfile should cause the build system to treat every subproject as affected and run all tests. """ - dockerfile = get_dockerfile(project_root=mock_project_root) + dockerfile = get_dockerfile(project_root=default_command_context.mock_project_root) dockerfile_contents = dockerfile.read_text() dockerfile.write_text(dockerfile_contents + "\n") - run_command_and_save_logs( - args=[*make_command_prefix, "test"], - cwd=mock_project_root, - test_name=request.node.name, - real_project_root_dir=real_project_root_dir, + run_command_and_save_logs(context=default_command_context, command_args=["test"]) + expected_report_yaml = get_build_runtime_report_path( + project_root=default_command_context.mock_project_root ) - expected_report_yaml = get_build_runtime_report_path(project_root=mock_project_root) runtime_report = BuildRunReport.from_yaml(expected_report_yaml.read_text()) assert all( @@ -243,26 +229,22 @@ def test_run_all_tests_if_dockerfile_modified( @pytest.mark.usefixtures("mock_lightweight_project_with_unit_tests_and_feature_tests") def test_run_all_tests_if_uv_lock_modified( - request: SubRequest, - mock_project_root: Path, - make_command_prefix: list[str], - real_project_root_dir: Path, + default_command_context: FeatureTestCommandContext, ) -> None: """Verify all tests run when uv.lock is modified. Appending a newline to ``uv.lock`` should cause the build system to treat every subproject as affected and run all tests. """ - uv_lock_file = get_uv_lock_file(project_root=mock_project_root) + uv_lock_file = get_uv_lock_file( + project_root=default_command_context.mock_project_root + ) uv_lock_file_contents = uv_lock_file.read_text() uv_lock_file.write_text(uv_lock_file_contents + "\n") - run_command_and_save_logs( - args=[*make_command_prefix, "test"], - cwd=mock_project_root, - test_name=request.node.name, - real_project_root_dir=real_project_root_dir, + run_command_and_save_logs(context=default_command_context, command_args=["test"]) + expected_report_yaml = get_build_runtime_report_path( + project_root=default_command_context.mock_project_root ) - expected_report_yaml = get_build_runtime_report_path(project_root=mock_project_root) runtime_report = BuildRunReport.from_yaml(expected_report_yaml.read_text()) assert all( diff --git a/build_support/test/feature_tests/test_95_template_python_project.py b/build_support/test/feature_tests/test_95_template_python_project.py index 22b4bc4..7e93a87 100644 --- a/build_support/test/feature_tests/test_95_template_python_project.py +++ b/build_support/test/feature_tests/test_95_template_python_project.py @@ -4,26 +4,24 @@ + task stdout/stderr. Failures always log command, code, stdout, and stderr at ERROR. """ -from pathlib import Path - import pytest from build_support.ci_cd_vars.subproject_structure import ( SubprojectContext, get_python_subproject, ) -from test_utils.command_runner import run_command_and_save_logs +from test_utils.command_runner import ( + FeatureTestCommandContext, + run_command_and_save_logs, +) @pytest.mark.usefixtures("mock_lightweight_project") def test_default_log_level_shows_only_workflow( - mock_project_root: Path, make_command_prefix: list[str], real_project_root_dir: Path + default_command_context: FeatureTestCommandContext, ) -> None: """At default (INFO) level only workflow messages appear; no task output.""" return_code, stdout, stderr = run_command_and_save_logs( - args=[*make_command_prefix, "format"], - cwd=mock_project_root, - test_name="test_default_log_level_shows_only_workflow", - real_project_root_dir=real_project_root_dir, + context=default_command_context, command_args=["format"] ) combined = stdout + stderr assert return_code == 0 @@ -35,14 +33,11 @@ def test_default_log_level_shows_only_workflow( @pytest.mark.usefixtures("mock_lightweight_project") def test_trace_log_level_shows_task_output( - mock_project_root: Path, make_command_prefix: list[str], real_project_root_dir: Path + default_command_context: FeatureTestCommandContext, ) -> None: """At LOG_LEVEL=TRACE, task stdout/stderr (e.g. formatter output) is present.""" return_code, stdout, stderr = run_command_and_save_logs( - args=[*make_command_prefix, "LOG_LEVEL=TRACE", "format"], - cwd=mock_project_root, - test_name="test_trace_log_level_shows_task_output", - real_project_root_dir=real_project_root_dir, + context=default_command_context, command_args=["LOG_LEVEL=TRACE", "format"] ) combined = stdout + stderr assert return_code == 0 @@ -56,14 +51,11 @@ def test_trace_log_level_shows_task_output( @pytest.mark.usefixtures("mock_lightweight_project") def test_debug_log_level_shows_command_lines( - mock_project_root: Path, make_command_prefix: list[str], real_project_root_dir: Path + default_command_context: FeatureTestCommandContext, ) -> None: """At LOG_LEVEL=DEBUG, command lines are logged (inner docker run).""" return_code, stdout, stderr = run_command_and_save_logs( - args=[*make_command_prefix, "LOG_LEVEL=DEBUG", "format"], - cwd=mock_project_root, - test_name="test_debug_log_level_shows_command_lines", - real_project_root_dir=real_project_root_dir, + context=default_command_context, command_args=["LOG_LEVEL=DEBUG", "format"] ) combined = stdout + stderr assert return_code == 0 @@ -73,24 +65,21 @@ def test_debug_log_level_shows_command_lines( @pytest.mark.usefixtures("mock_lightweight_project") def test_failure_visible_at_default_log_level( - mock_project_root: Path, make_command_prefix: list[str], real_project_root_dir: Path + default_command_context: FeatureTestCommandContext, ) -> None: """On task failure, the failure and failing command are visible at default level.""" get_python_subproject( - project_root=mock_project_root, + project_root=default_command_context.mock_project_root, subproject_context=SubprojectContext.BUILD_SUPPORT, ).get_test_dir().joinpath("test_type_error_for_logging_test.py").write_text( - """def some_function(items) -> list: + """def some_function() -> None: \"\"\"func docstring\"\"\" - return items + x: int = "not an int" """ ) + default_command_context.expect_failure = True return_code, stdout, stderr = run_command_and_save_logs( - args=[*make_command_prefix, "type_check_build_support"], - cwd=mock_project_root, - test_name="test_failure_visible_at_default_log_level", - real_project_root_dir=real_project_root_dir, - expect_failure=True, + context=default_command_context, command_args=["type_check_build_support"] ) combined = stdout + stderr assert return_code != 0 diff --git a/build_support/test/feature_tests/test_98_template_python_project.py b/build_support/test/feature_tests/test_98_template_python_project.py new file mode 100644 index 0000000..0180275 --- /dev/null +++ b/build_support/test/feature_tests/test_98_template_python_project.py @@ -0,0 +1,1070 @@ +"""Feature tests for ticket 98: switch static typing from mypy to ty. + +Verifies that type checking uses ty, succeeds on the repository, and that +pyproject.toml is configured for ty with no mypy configuration. One test +writes a file per ty rule (each file violates that rule), runs the type +checker once, and parses the output to ensure every rule is flagged. +""" + +import re +from pathlib import Path +from subprocess import run +from typing import Any, cast + +import pytest +from build_support.ci_cd_vars.docker_vars import DockerTarget, get_docker_image_name +from build_support.ci_cd_vars.project_setting_vars import get_pyproject_toml_data +from build_support.ci_cd_vars.subproject_structure import ( + SubprojectContext, + get_python_subproject, +) +from test_utils.command_runner import ( + FeatureTestCommandContext, + run_command_and_save_logs, +) + + +@pytest.mark.usefixtures( + "mock_lightweight_project", "mock_lightweight_project_on_feature_branch" +) +def test_type_checks_use_ty_and_succeed( + default_command_context: FeatureTestCommandContext, +) -> None: + """Running make type_checks uses ty and succeeds on the repository.""" + return_code, _, _ = run_command_and_save_logs( + context=default_command_context, command_args=["type_checks"] + ) + assert return_code == 0 + + +def test_pyproject_has_ty_config_and_no_mypy(real_project_root_dir: Path) -> None: + """pyproject.toml contains ty configuration with all checks at error and no mypy.""" + pyproject_data = get_pyproject_toml_data(project_root=real_project_root_dir) + tool = cast(dict[str, Any], pyproject_data).get("tool") + assert tool is not None + assert "ty" in tool + ty_config = tool["ty"] + assert "rules" in ty_config + assert ty_config["rules"].get("all") == "error" + assert "mypy" not in tool + + +@pytest.mark.usefixtures("mock_lightweight_project_with_unit_tests_and_feature_tests") +def test_dev_container_has_ty_not_mypy( + mock_project_root: Path, make_command_prefix: list[str], real_project_root_dir: Path +) -> None: + """Verify mypy is not available and ty is available in the dev container. + + Runs ``mypy --version`` and ``ty version`` in the development container. + Mypy must fail with a command-not-found style error; ty must succeed. + + Args: + mock_project_root (Path): Root of the mock project (dev image context). + make_command_prefix (list[str]): Make command prefix for running setup_dev_env. + real_project_root_dir (Path): Root of the real project for image name lookup. + """ + run( + [*make_command_prefix, "setup_dev_env"], + cwd=mock_project_root, + check=True, + capture_output=True, + ) + image = get_docker_image_name( + project_root=real_project_root_dir, target_image=DockerTarget.DEV + ) + mount = f"{mock_project_root.resolve()}:/usr/dev" + docker_run = ["docker", "run", "--rm", "-v", mount, "-w", "/usr/dev", image] + + mypy_result = run( + [*docker_run, "mypy", "--version"], + check=False, + capture_output=True, + text=True, + cwd=mock_project_root, + ) + assert mypy_result.returncode != 0, ( + "mypy should not be available in the dev container; " + f"stderr: {mypy_result.stderr!r}" + ) + assert ( + "not found" in mypy_result.stderr.lower() + or "no such file" in mypy_result.stderr.lower() + ), ( + "Expected command-not-found style error from mypy; " + f"stderr: {mypy_result.stderr!r}" + ) + + ty_result = run( + [*docker_run, "ty", "version"], + check=False, + capture_output=True, + text=True, + cwd=mock_project_root, + ) + assert ty_result.returncode == 0, ( + f"ty must be available in the dev container; stderr: {ty_result.stderr!r}" + ) + + +def _write_pypi_test_file( + mock_project_root: Path, file_name: str, file_contents: str +) -> None: + get_python_subproject( + project_root=mock_project_root, subproject_context=SubprojectContext.PYPI + ).get_test_dir().joinpath(file_name).write_text(file_contents) + + +TY_RULES: list[tuple[str, str, str] | tuple[str, str, str, list[tuple[str, str]]]] = [ + ( + "call-non-callable", + "test_ty98_fails_call_non_callable.py", + """def f() -> None: + x: int = 1 + x() +""", + ), + ( + "duplicate-base", + "test_ty98_fails_duplicate_base.py", + """class A: + pass + +class B(A, A): + pass +""", + ), + ( + "invalid-assignment", + "test_ty98_fails_invalid_assignment.py", + """def f() -> None: + x: int = "not an int" +""", + ), + ( + "subclass-of-final-class", + "test_ty98_fails_subclass_of_final_class.py", + """from typing import final + +@final +class A: + pass + +class B(A): + pass +""", + ), + ( + "missing-argument", + "test_ty98_fails_missing_argument.py", + """def g(a: int, b: int) -> int: + return a + b + +def f() -> None: + g(1) +""", + ), + ( + "unknown-argument", + "test_ty98_fails_unknown_argument.py", + """def g(a: int) -> int: + return a + +def f() -> None: + g(a=1, b=2) +""", + ), + ( + "invalid-argument-type", + "test_ty98_fails_invalid_argument_type.py", + """def func(x: int) -> None: ... + +def f() -> None: + func("foo") +""", + ), + ( + "abstract-method-in-final-class", + "test_ty98_fails_abstract_method_in_final_class.py", + """from abc import ABC, abstractmethod +from typing import final + +class Base(ABC): + @abstractmethod + def method(self) -> int: ... + +@final +class Derived(Base): + pass +""", + ), + ( + "ambiguous-protocol-member", + "test_ty98_fails_ambiguous_protocol_member.py", + """from typing import Protocol + +class BaseProto(Protocol): + a: int + c = "some variable" +""", + ), + ( + "assert-type-unspellable-subtype", + "test_ty98_fails_assert_type_unspellable_subtype.py", + """from typing import assert_type + +def _(x: int) -> None: + if x: + assert_type(x, int) +""", + ), + ( + "byte-string-type-annotation", + "test_ty98_fails_byte_string_type_annotation.py", + """def test() -> b"int": + ... +""", + ), + ( + "call-abstract-method", + "test_ty98_fails_call_abstract_method.py", + """from abc import ABC, abstractmethod + +class Foo(ABC): + @classmethod + @abstractmethod + def method(cls) -> int: ... + +Foo.method() +""", + ), + ( + "call-top-callable", + "test_ty98_fails_call_top_callable.py", + """def f(x: object) -> None: + if callable(x): + x() +""", + ), + ( + "conflicting-declarations", + "test_ty98_fails_conflicting_declarations.py", + """def f(b: bool) -> None: + if b: + a: int + else: + a: str + a = 1 +""", + ), + ( + "conflicting-metaclass", + "test_ty98_fails_conflicting_metaclass.py", + """class M1(type): ... +class M2(type): ... +class A(metaclass=M1): ... +class B(metaclass=M2): ... + +class C(A, B): ... +""", + ), + ( + "cyclic-type-alias-definition", + "test_ty98_fails_cyclic_type_alias_definition.py", + """type Itself = Itself +""", + ), + ( + "deprecated", + "test_ty98_fails_deprecated.py", + """import warnings + +@warnings.deprecated("use new_func") +def old_func() -> None: ... + +def f() -> None: + old_func() +""", + ), + ( + "dataclass-field-order", + "test_ty98_fails_dataclass_field_order.py", + """from dataclasses import dataclass + +@dataclass +class Example: + x: int = 1 + y: str +""", + ), + ( + "division-by-zero", + "test_ty98_fails_division_by_zero.py", + """def f() -> int: + return 5 / 0 +""", + ), + ( + "duplicate-kw-only", + "test_ty98_fails_duplicate_kw_only.py", + """from dataclasses import KW_ONLY, dataclass + +@dataclass +class A: + b: int + _1: KW_ONLY + c: str + _2: KW_ONLY + d: bytes +""", + ), + ( + "empty-body", + "test_ty98_fails_empty_body.py", + """def foo() -> int: ... +""", + ), + ( + "escape-character-in-forward-annotation", + "test_ty98_fails_escape_char_forward_annotation.py", + """def foo() -> "intt\\\\b": + return 0 +""", + ), + ( + "final-without-value", + "test_ty98_fails_final_without_value.py", + """from typing import Final + +MY_CONSTANT: Final[int] +""", + ), + ( + "fstring-type-annotation", + "test_ty98_fails_fstring_type_annotation.py", + """def test() -> f"int": + ... +""", + ), + ( + "ignore-comment-unknown-rule", + "test_ty98_fails_ignore_comment_unknown_rule.py", + """def f() -> int: + return 1 # ty: ignore[division-by-zer] +""", + ), + ( + "implicit-concatenated-string-type-annotation", + "test_ty98_fails_implicit_concatenated_string_type_annotation.py", + """def test() -> "Literal[" "5" "]": + ... +""", + ), + ( + "inconsistent-mro", + "test_ty98_fails_inconsistent_mro.py", + """class A: ... +class B(A): ... + +class C(A, B): ... +""", + ), + ( + "index-out-of-bounds", + "test_ty98_fails_index_out_of_bounds.py", + """def f() -> int: + t = (0, 1, 2) + return t[3] +""", + ), + ( + "ineffective-final", + "test_ty98_fails_ineffective_final.py", + """from typing import final + +MyClass = final(type("MyClass", (), {})) +""", + ), + ( + "instance-layout-conflict", + "test_ty98_fails_instance_layout_conflict.py", + """class A: + __slots__ = ("a", "b") + +class B: + __slots__ = ("a", "b") + +class C(A, B): ... +""", + ), + ( + "invalid-await", + "test_ty98_fails_invalid_await.py", + """async def f() -> None: + await 42 +""", + ), + ( + "invalid-base", + "test_ty98_fails_invalid_base.py", + """class A(42): ... +""", + ), + ( + "invalid-context-manager", + "test_ty98_fails_invalid_context_manager.py", + """def f() -> None: + with 1: + pass +""", + ), + ( + "invalid-dataclass", + "test_ty98_fails_invalid_dataclass.py", + """from dataclasses import dataclass +from typing import NamedTuple + +@dataclass +class Foo(NamedTuple): + x: int +""", + ), + ( + "invalid-dataclass-override", + "test_ty98_fails_invalid_dataclass_override.py", + """from dataclasses import dataclass + +@dataclass(frozen=True) +class A: + def __setattr__(self, name: str, value: object) -> None: ... +""", + ), + ( + "invalid-declaration", + "test_ty98_fails_invalid_declaration.py", + """def f() -> None: + a = 1 + a: str +""", + ), + ( + "invalid-exception-caught", + "test_ty98_fails_invalid_exception_caught.py", + """def f() -> None: + try: + 1 / 0 + except 1: + pass +""", + ), + ( + "invalid-explicit-override", + "test_ty98_fails_invalid_explicit_override.py", + """from typing import override + +class C: + @override + def not_overriding(self) -> int: + return 0 +""", + ), + ( + "invalid-frozen-dataclass-subclass", + "test_ty98_fails_invalid_frozen_dataclass_subclass.py", + """from dataclasses import dataclass + +@dataclass +class Base: + x: int + +@dataclass(frozen=True) +class Child(Base): + y: int +""", + ), + ( + "invalid-generic-class", + "test_ty98_fails_invalid_generic_class.py", + """from typing import Generic, TypeVar + +T = TypeVar("T") +U = TypeVar("U", default=int) + +class D(Generic[U, T]): ... +""", + ), + ( + "invalid-generic-enum", + "test_ty98_fails_invalid_generic_enum.py", + """from enum import Enum + +class E[tuple](Enum): + A = 1 +""", + ), + ( + "invalid-ignore-comment", + "test_ty98_fails_invalid_ignore_comment.py", + """def f() -> int: + return 1 # type: ignoree +""", + ), + ( + "invalid-key", + "test_ty98_fails_invalid_key.py", + """from typing import TypedDict + +class Person(TypedDict): + name: str + age: int + +def f() -> None: + carol = Person(name="Carol", age=25, typo_key=0) +""", + ), + ( + "invalid-legacy-positional-parameter", + "test_ty98_fails_invalid_legacy_positional_parameter.py", + """def f(x: int, __y: int) -> int: + return x + __y +""", + ), + ( + "invalid-legacy-type-variable", + "test_ty98_fails_invalid_legacy_type_variable.py", + """from typing import TypeVar + +Q = TypeVar("S") +""", + ), + ( + "invalid-match-pattern", + "test_ty98_fails_invalid_match_pattern.py", + """NotAClass = 42 + +def f(x: object) -> None: + match x: + case NotAClass(): + pass +""", + ), + ( + "invalid-method-override", + "test_ty98_fails_invalid_method_override.py", + """class Base: + def foo(self) -> int: + return 0 + +class Sub(Base): + def foo(self) -> str: + return "x" +""", + ), + ( + "invalid-named-tuple", + "test_ty98_fails_invalid_named_tuple.py", + """from typing import NamedTuple + +class Foo(NamedTuple, object): + x: int +""", + ), + ( + "invalid-newtype", + "test_ty98_fails_invalid_newtype.py", + """from typing import NewType + +Baz = NewType("Baz", int | str) +""", + ), + ( + "invalid-overload", + "test_ty98_fails_invalid_overload.py", + """from typing import overload + +@overload +def foo(x: int) -> int: ... +@overload +def foo(x: str) -> str: ... +""", + ), + ( + "invalid-parameter-default", + "test_ty98_fails_invalid_parameter_default.py", + """def f(a: int = "") -> int: + return a +""", + ), + ( + "invalid-paramspec", + "test_ty98_fails_invalid_paramspec.py", + """from typing import ParamSpec + +P2 = ParamSpec("S2") +""", + ), + ( + "invalid-protocol", + "test_ty98_fails_invalid_protocol.py", + """from typing import Protocol + +class Foo(int, Protocol): ... +""", + ), + ( + "invalid-raise", + "test_ty98_fails_invalid_raise.py", + """def f() -> None: + raise "oops!" +""", + ), + ( + "invalid-return-type", + "test_ty98_fails_invalid_return_type.py", + """def func() -> int: + return "a" +""", + ), + ( + "invalid-super-argument", + "test_ty98_fails_invalid_super_argument.py", + """class A: ... +class B(A): ... + +def f() -> None: + super(A(), B()) +""", + ), + ( + "invalid-syntax-in-forward-annotation", + "test_ty98_fails_invalid_syntax_in_forward_annotation.py", + """def foo() -> "intstance of C": + return 42 + +class C: ... +""", + ), + ( + "invalid-type-alias-type", + "test_ty98_fails_invalid_type_alias_type.py", + """from typing import TypeAliasType + +def get_name() -> str: + return "A" + +NewAlias = TypeAliasType(get_name(), int) +""", + ), + ( + "invalid-total-ordering", + "test_ty98_fails_invalid_total_ordering.py", + """from functools import total_ordering + +@total_ordering +class MyClass: + def __eq__(self, other: object) -> bool: + return True +""", + ), + ( + "invalid-type-checking-constant", + "test_ty98_fails_invalid_type_checking_constant.py", + """TYPE_CHECKING = "" +""", + ), + ( + "invalid-type-form", + "test_ty98_fails_invalid_type_form.py", + """def f() -> None: + a: type[1] = int +""", + ), + ( + "invalid-type-guard-definition", + "test_ty98_fails_invalid_type_guard_definition.py", + """from typing import TypeIs + +def f() -> TypeIs[int]: ... +""", + ), + ( + "invalid-type-variable-bound", + "test_ty98_fails_invalid_type_variable_bound.py", + """from typing import TypeVar + +T = TypeVar("T", bound=list["T"]) +""", + ), + ( + "invalid-type-variable-default", + "test_ty98_fails_invalid_type_variable_default.py", + """from typing import TypeVar + +T = TypeVar("T", bound=str, default=int) +""", + ), + ( + "invalid-typed-dict-header", + "test_ty98_fails_invalid_typed_dict_header.py", + """from typing import TypedDict + +class Meta(type): ... + +class Foo(TypedDict, metaclass=Meta): + x: int +""", + ), + ( + "invalid-typed-dict-statement", + "test_ty98_fails_invalid_typed_dict_statement.py", + """from typing import TypedDict + +class Foo(TypedDict): + def bar(self) -> None: + pass +""", + ), + ( + "isinstance-against-protocol", + "test_ty98_fails_isinstance_against_protocol.py", + """from typing import Protocol + +class HasX(Protocol): + x: int + +def f(arg: object) -> None: + isinstance(arg, HasX) +""", + ), + ( + "isinstance-against-typed-dict", + "test_ty98_fails_isinstance_against_typed_dict.py", + """from typing import TypedDict + +class Movie(TypedDict): + name: str + director: str + +def f(arg: object) -> None: + isinstance(arg, Movie) +""", + ), + ( + "missing-typed-dict-key", + "test_ty98_fails_missing_typed_dict_key.py", + """from typing import TypedDict + +class Person(TypedDict): + name: str + age: int + +alice: Person = {"name": "Alice"} +""", + ), + ( + "no-matching-overload", + "test_ty98_fails_no_matching_overload.py", + """from typing import overload + +@overload +def func(x: int) -> int: ... +@overload +def func(x: bool) -> int: ... +def func(x: int | bool) -> int: + return 0 + +def f() -> None: + func("string") +""", + ), + ( + "not-iterable", + "test_ty98_fails_not_iterable.py", + """def f() -> None: + for i in 34: + pass +""", + ), + ( + "not-subscriptable", + "test_ty98_fails_not_subscriptable.py", + """def f() -> None: + x = 4[1] +""", + ), + ( + "override-of-final-method", + "test_ty98_fails_override_of_final_method.py", + """from typing import final + +class A: + @final + def foo(self) -> int: + return 0 + +class B(A): + def foo(self) -> int: + return 1 +""", + ), + ( + "override-of-final-variable", + "test_ty98_fails_override_of_final_variable.py", + """from typing import Final + +class A: + X: Final[int] = 1 + +class B(A): + X = 2 +""", + ), + ( + "parameter-already-assigned", + "test_ty98_fails_parameter_already_assigned.py", + """def f(x: int) -> int: + return x + +def g() -> None: + f(1, x=2) +""", + ), + ( + "possibly-unresolved-reference", + "test_ty98_fails_possibly_unresolved_reference.py", + """def f() -> None: + for i in range(0): + x = i + print(x) +""", + ), + ( + "positional-only-parameter-as-kwarg", + "test_ty98_fails_positional_only_parameter_as_kwarg.py", + """def f(x: int, /) -> int: + return x + +def g() -> None: + f(x=1) +""", + ), + ( + "raw-string-type-annotation", + "test_ty98_fails_raw_string_type_annotation.py", + """def test() -> r"int": + ... +""", + ), + ( + "redundant-cast", + "test_ty98_fails_redundant_cast.py", + """from typing import cast + +def example(x: int) -> int: + return cast(int, x) +""", + ), + ( + "redundant-final-classvar", + "test_ty98_fails_redundant_final_classvar.py", + """from typing import ClassVar, Final + +class C: + x: ClassVar[Final[int]] = 1 +""", + ), + ( + "super-call-in-named-tuple-method", + "test_ty98_fails_super_call_in_named_tuple_method.py", + """from typing import NamedTuple + +class F(NamedTuple): + x: int + + def method(self) -> None: + super() +""", + ), + ( + "too-many-positional-arguments", + "test_ty98_fails_too_many_positional_arguments.py", + """def f() -> None: ... + +def g() -> None: + f("foo") +""", + ), + ( + "type-assertion-failure", + "test_ty98_fails_type_assertion_failure.py", + """from typing import assert_type + +def f(x: int) -> None: + assert_type(x, str) +""", + ), + ( + "unavailable-implicit-super-arguments", + "test_ty98_fails_unavailable_implicit_super_arguments.py", + """super() +""", + ), + ( + "undefined-reveal", + "test_ty98_fails_undefined_reveal.py", + """def f(x: int) -> None: + reveal_type(x) +""", + ), + ( + "unresolved-attribute", + "test_ty98_fails_unresolved_attribute.py", + """class A: ... + +def f() -> None: + A().foo +""", + ), + ( + "unresolved-global", + "test_ty98_fails_unresolved_global.py", + """def f() -> None: + global x + x = 42 +""", + ), + ( + "unresolved-import", + "test_ty98_fails_unresolved_import.py", + """from nonexistent_module_ty98_xyz import X + +def use(x: X) -> None: + return +""", + ), + ( + "unresolved-reference", + "test_ty98_fails_unresolved_reference.py", + """def bad() -> int: + return missing_name +""", + ), + ( + "unsupported-dynamic-base", + "test_ty98_fails_unsupported_dynamic_base.py", + """class Base: ... + +def factory(base: type[Base]) -> type: + return type("Dynamic", (base,), {}) + +class D(factory(Base)): ... +""", + ), + ( + "unsupported-base", + "test_ty98_fails_unsupported_base.py", + """import datetime + +class A: ... +class B: ... + +if datetime.date.today().weekday() != 6: + C = A +else: + C = B + +class D(C): ... +""", + ), + ( + "unused-ignore-comment", + "test_ty98_fails_unused_ignore_comment.py", + """def f() -> int: + return 20 / 2 # ty: ignore[division-by-zero] +""", + ), + ( + "unused-type-ignore-comment", + "test_ty98_fails_unused_type_ignore_comment.py", + """def f() -> int: + return 1 # type: ignore[redundant-cast] +""", + ), + ( + "useless-overload-body", + "test_ty98_fails_useless_overload_body.py", + """from typing import overload + +@overload +def foo(x: int) -> int: + return x + 1 + +def foo(x: int | str) -> int | str: + return x +""", + ), + ( + "static-assert-error", + "test_ty98_fails_static_assert_error.py", + """from ty_extensions import static_assert + +static_assert(1 + 1 == 3) +""", + ), + ( + "possibly-missing-import", + "test_ty98_fails_possibly_missing_import.py", + """from ty98_conditional_module import a + + +def use() -> int: + return a +""", + [ + ( + "ty98_conditional_module.py", + """import datetime + + +if datetime.date.today().weekday() != 6: + a = 1 +""", + ) + ], + ), +] + + +@pytest.mark.usefixtures( + "mock_lightweight_project", "mock_lightweight_project_on_feature_branch" +) +def test_all_ty_rules_flagged_in_type_check_output( + default_command_context: FeatureTestCommandContext, +) -> None: + """Type check fails and each ty rule is reported in the output.""" + test_dir = get_python_subproject( + project_root=default_command_context.mock_project_root, + subproject_context=SubprojectContext.PYPI, + ).get_test_dir() + entry_with_extras_len = 4 # (rule, file_name, file_contents, extra_files) + for entry in TY_RULES: + file_name = entry[1] + file_contents = entry[2] + test_dir.joinpath(file_name).write_text(file_contents) + if len(entry) == entry_with_extras_len: + entry_with_extras = cast(tuple[str, str, str, list[tuple[str, str]]], entry) + for extra_name, extra_content in entry_with_extras[3]: + test_dir.joinpath(extra_name).write_text(extra_content) + default_command_context.expect_failure = True + return_code, stdout, stderr = run_command_and_save_logs( + context=default_command_context, command_args=["type_check_pypi"] + ) + assert return_code != 0, "type_check_pypi should fail when rules are violated" + combined_output = stdout + stderr + # Ty prints rule names in brackets, e.g. [invalid-return-type] + found_rules = set(re.findall(r"\[([a-z][a-z0-9-]+)\]", combined_output)) + expected_rules = {entry[0] for entry in TY_RULES} + missing = expected_rules - found_rules + assert not missing, ( + f"Rules not flagged in type checker output: {sorted(missing)}. " + "Check that each rule is triggered by its test file." + ) diff --git a/build_support/test/style_enforcement/test_docstrings.py b/build_support/test/style_enforcement/test_docstrings.py index 0a5046e..20fc0cd 100644 --- a/build_support/test/style_enforcement/test_docstrings.py +++ b/build_support/test/style_enforcement/test_docstrings.py @@ -17,7 +17,7 @@ get_all_python_subprojects_with_src, ) -type ClassOrStaticMethod = classmethod | staticmethod # type: ignore[type-arg] +type ClassOrStaticMethod = classmethod | staticmethod type ImportedElement = ModuleType | FunctionType | ClassOrStaticMethod | type[Any] @@ -67,7 +67,7 @@ def package_to_test(request: SubRequest) -> str: context_with_src = request.param.subproject_context if context_with_src == SubprojectContext.PYPI: return get_project_name(project_root=PROJECT_ROOT) - return context_with_src.value # type: ignore[no-any-return] + return context_with_src.value def import_element( diff --git a/build_support/test/style_enforcement/test_sphinx_docs.py b/build_support/test/style_enforcement/test_sphinx_docs.py index 43e2c40..ba8b77c 100644 --- a/build_support/test/style_enforcement/test_sphinx_docs.py +++ b/build_support/test/style_enforcement/test_sphinx_docs.py @@ -18,6 +18,43 @@ def docs_dir(real_project_root_dir: Path) -> Path: return get_docs_dir(project_root=real_project_root_dir) +def _makefile_phony_targets(makefile_path: Path) -> set[str]: + """Extract all .PHONY target names from the root Makefile.""" + text = makefile_path.read_text() + targets: set[str] = set() + for line in text.splitlines(): + stripped = line.strip() + if stripped.startswith(".PHONY:"): + rest = stripped[7:].strip() # after ".PHONY:" + for name in rest.split(): + targets.add(name) + return targets + + +def _documented_make_commands(developer_tooling_path: Path) -> set[str]: + """Extract make target names from the Command column in developer_tooling.rst.""" + text = developer_tooling_path.read_text() + documented: set[str] = set() + for match in re.findall(r"^\s+\*\s+-\s+make\s+(\S+)", text, re.MULTILINE): + documented.add(match) + return documented + + +def test_makefile_targets_documented_in_developer_tooling( + real_project_root_dir: Path, docs_dir: Path +) -> None: + """Every .PHONY target in the root Makefile must be in developer_tooling.rst.""" + makefile_path = real_project_root_dir.joinpath("Makefile") + developer_tooling_path = docs_dir.joinpath("developer_tooling.rst") + makefile_targets = _makefile_phony_targets(makefile_path) + documented_targets = _documented_make_commands(developer_tooling_path) + missing = makefile_targets - documented_targets + assert not missing, ( + f"Makefile target(s) not documented in docs/developer_tooling.rst: " + f"{sorted(missing)}. Add a row to the Standardized Developer Commands table." + ) + + def test_subprojects_documented(real_project_root_dir: Path, docs_dir: Path) -> None: subprojects_doc_file = docs_dir.joinpath("subprojects.rst") expected_subproject_name_to_doc_link: dict[str, list[str]] = {} diff --git a/build_support/test/test_utils/command_runner.py b/build_support/test/test_utils/command_runner.py index 9a69450..a4129d8 100644 --- a/build_support/test/test_utils/command_runner.py +++ b/build_support/test/test_utils/command_runner.py @@ -1,36 +1,68 @@ -"""Utility for running commands and saving logs during tests.""" +"""Utility for running commands and saving logs during feature tests. + +Feature tests run make (or other commands) via a single entry point: +run_command_and_save_logs(context=..., command_args=...). The default context is +provided by the default_command_context fixture in conftest. Tests that need +different behavior copy the context and override the desired fields; +tests that need a different args prefix (e.g. test_107) override +args_prefix on the copy. Output is always saved to a log file. +""" import logging +from dataclasses import dataclass from pathlib import Path from subprocess import PIPE, Popen -from build_support.ci_cd_vars.project_structure import get_feature_test_log_name +from build_support.ci_cd_vars.project_structure import get_feature_test_log_file logger = logging.getLogger(__name__) +@dataclass +class FeatureTestCommandContext: + """Bundles everything needed to run a command and save a log in feature tests. + + The default context is provided by the default_command_context fixture. Tests that + need different behavior copy the fixture and override individual fields + (e.g. ``expect_failure``, ``log_name``, or ``args_prefix``). + + Note: This dataclass is intentionally not frozen. We allow mutation so + tests can copy the default context and override individual fields without + repeating the rest; overriding is the common case in feature tests. + """ + + args_prefix: list[str] + mock_project_root: Path + real_project_root_dir: Path + test_name: str + log_name: str + expect_failure: bool = False + + def run_command_and_save_logs( - args: list[str], - cwd: Path, - test_name: str, - real_project_root_dir: Path, - expect_failure: bool = False, + context: FeatureTestCommandContext, command_args: list[str] ) -> tuple[int, str, str]: - """Runs a command and saves stdout/stderr to a log file in test_scratch_folder. + """Run a command and save stdout/stderr to a log file. + + Full command is context.args_prefix + command_args, run in + context.mock_project_root. Log file name is context.log_name (defaults + to context.test_name). Use the default_command_context fixture for the default + context; copy and override fields when needed. Args: - args (list[str]): Command arguments to run. - cwd (Path): Working directory for the command. - test_name (str): Name of the test (used for log file naming). - real_project_root_dir (Path): Real project root directory. - expect_failure (bool): If True, a non-zero return code will not trigger - logging of the log content at ERROR level. Default False. + context: Bundled args prefix, cwd, project root, test name, and + options (expect_failure, log_name). + command_args: Arguments appended to context.args_prefix (e.g. + ["type_checks"] or ["echo_image_tags"]). Returns: tuple[int, str, str]: Return code, stdout, and stderr. """ - log_file = get_feature_test_log_name( - project_root=real_project_root_dir, test_name=test_name + args = [*context.args_prefix, *command_args] + cwd = context.mock_project_root + log_name = context.log_name + log_file = get_feature_test_log_file( + project_root=context.real_project_root_dir, log_name=log_name ) cmd = Popen(args=args, cwd=cwd, stdout=PIPE, stderr=PIPE, text=True) @@ -42,7 +74,7 @@ def run_command_and_save_logs( log_content = ( f"{line_break_strong}\n" - f"Test: {test_name}\n" + f"Test: {log_name}\n" f"Command: {' '.join(args)}\n" f"Working Directory: {cwd}\n" f"Return Code: {return_code}\n" @@ -56,7 +88,7 @@ def run_command_and_save_logs( ) log_file.write_text(log_content, encoding="utf-8") - if (return_code != 0) and not expect_failure: + if (return_code != 0) and not context.expect_failure: logger.error("%s", log_content) return return_code, stdout, stderr diff --git a/build_support/test/test_utils/empty_function_check.py b/build_support/test/test_utils/empty_function_check.py index 561c6db..e2b6fb1 100644 --- a/build_support/test/test_utils/empty_function_check.py +++ b/build_support/test/test_utils/empty_function_check.py @@ -1,5 +1,5 @@ from collections.abc import Callable -from typing import Any +from typing import Any, cast def empty_function() -> None: # pragma: no cov, exists only for return bytecode @@ -22,4 +22,4 @@ def empty_function_with_docstring() -> None: # pragma: no cov, same as above def is_an_empty_function(func: Callable[..., Any]) -> bool: - return func.__code__.co_code in ALL_EMPTY_FUNCTION_BYTECODES + return cast(Any, func).__code__.co_code in ALL_EMPTY_FUNCTION_BYTECODES diff --git a/build_support/test/unit_tests/ci_cd_tasks/test_env_setup_tasks.py b/build_support/test/unit_tests/ci_cd_tasks/test_env_setup_tasks.py index 27c7fd5..9371166 100644 --- a/build_support/test/unit_tests/ci_cd_tasks/test_env_setup_tasks.py +++ b/build_support/test/unit_tests/ci_cd_tasks/test_env_setup_tasks.py @@ -123,6 +123,30 @@ def test_clean_requires(basic_task_info: BasicTaskInfo) -> None: assert Clean(basic_task_info=basic_task_info).required_tasks() == [] +def _create_coverage_files(project_root: Path) -> list[Path]: + """Create .coverage and .coverage.* files; return list of created paths.""" + coverage_file = project_root.joinpath(".coverage") + coverage_file.touch() + worker_1 = project_root.joinpath(".coverage.worker-1") + worker_1.touch() + worker_2 = project_root.joinpath(".coverage.worker-2") + worker_2.touch() + return [coverage_file, worker_1, worker_2] + + +def _assert_folder_exists_with_content(folder: Path) -> None: + """Assert folder exists, is a dir, and has subfolders and files.""" + assert folder.exists() + assert folder.is_dir() + sub_folder_count = sum(1 for p in folder.glob("*") if p.is_dir()) + folder_file_count = sum(1 for p in folder.glob("*") if p.is_file()) + assert sub_folder_count > 0 + assert folder_file_count > 0 + for path in folder.glob("*"): + if path.is_dir(): + assert len(list(path.glob("*"))) > 0 + + def test_run_clean(basic_task_info: BasicTaskInfo) -> None: def _add_some_folders_and_files_to_folder( current_folder: Path, required_file_names: list[str] | None = None @@ -139,8 +163,8 @@ def _add_some_folders_and_files_to_folder( new_folder.mkdir() new_folder.joinpath("some_folder_contents.txt").touch() - mypy_cache = basic_task_info.docker_project_root.joinpath(".mypy_cache") - _add_some_folders_and_files_to_folder(current_folder=mypy_cache) + ty_cache = basic_task_info.docker_project_root.joinpath(".ty_cache") + _add_some_folders_and_files_to_folder(current_folder=ty_cache) pytest_cache = basic_task_info.docker_project_root.joinpath(".pytest_cache") _add_some_folders_and_files_to_folder(current_folder=pytest_cache) @@ -156,31 +180,29 @@ def _add_some_folders_and_files_to_folder( ) _add_some_folders_and_files_to_folder(current_folder=test_scratch_folder) + files_that_will_be_removed = _create_coverage_files( + basic_task_info.docker_project_root + ) + folders_that_will_be_completely_removed = [ - mypy_cache, + ty_cache, pytest_cache, ruff_cache, build_dir, test_scratch_folder, ] + for path in files_that_will_be_removed: + assert path.exists() + for folder in folders_that_will_be_completely_removed: - assert folder.exists() - assert folder.is_dir() - sub_folder_count = 0 - folder_file_count = 0 - for path in folder.glob("*"): - if path.is_dir(): - sub_folder_count += 1 - assert len(list(path.glob("*"))) > 0 - else: - folder_file_count += 1 - assert sub_folder_count > 0 - assert folder_file_count > 0 + _assert_folder_exists_with_content(folder) Clean(basic_task_info=basic_task_info).run() for folder in folders_that_will_be_completely_removed: assert not folder.exists() + for path in files_that_will_be_removed: + assert not path.exists() @pytest.fixture diff --git a/build_support/test/unit_tests/ci_cd_tasks/test_task_node.py b/build_support/test/unit_tests/ci_cd_tasks/test_task_node.py index f3d43b4..3eb99c1 100644 --- a/build_support/test/unit_tests/ci_cd_tasks/test_task_node.py +++ b/build_support/test/unit_tests/ci_cd_tasks/test_task_node.py @@ -209,7 +209,7 @@ def build_mock_basic_task( ) -> TaskNode: """Builds a mock task for testing task interactions.""" - return type( # type: ignore[no-any-return] + return type( task_name, (TaskNode,), {"required_tasks": Mock(return_value=required_mock_tasks), "run": Mock()}, @@ -223,7 +223,7 @@ def build_mock_per_subproject_task( ) -> PerSubprojectTask: """Builds a mock task for testing task interactions.""" - return type( # type: ignore[no-any-return] + return type( task_name, (PerSubprojectTask,), {"required_tasks": Mock(return_value=required_mock_tasks), "run": Mock()}, diff --git a/build_support/test/unit_tests/ci_cd_tasks/test_validation_tasks.py b/build_support/test/unit_tests/ci_cd_tasks/test_validation_tasks.py index 8cc54b0..ee53920 100644 --- a/build_support/test/unit_tests/ci_cd_tasks/test_validation_tasks.py +++ b/build_support/test/unit_tests/ci_cd_tasks/test_validation_tasks.py @@ -39,7 +39,7 @@ get_base_docker_command_for_image, get_docker_command_for_image, get_docker_image_name, - get_mypy_path_env, + get_ty_extra_search_path_args, ) from build_support.ci_cd_vars.file_and_dir_path_vars import ( get_all_non_test_folders, @@ -170,17 +170,16 @@ def test_run_validate_static_type_checking( docker_project_root=basic_task_info.docker_project_root, target_image=DockerTarget.DEV, ), - "-e", - get_mypy_path_env( - docker_project_root=basic_task_info.docker_project_root, - target_image=DockerTarget.DEV, - ), get_docker_image_name( project_root=basic_task_info.docker_project_root, target_image=DockerTarget.DEV, ), - "mypy", - "--explicit-package-bases", + "ty", + "check", + get_ty_extra_search_path_args( + docker_project_root=basic_task_info.docker_project_root, + target_image=DockerTarget.DEV, + ), mock_docker_subproject.get_root_dir(), ] ) @@ -964,9 +963,9 @@ def run_feature_test_side_effect(args: list[Any]) -> None: case = TestCase(name=test_file_name) suite_name = f"suite_{test_file_name}" # allow for untyped calls to library - suite = TestSuite(name=suite_name) # type: ignore[no-untyped-call] + suite = TestSuite(name=suite_name) suite.add_testcase(case) - xml = JUnitXml() # type: ignore[no-untyped-call] + xml = JUnitXml() xml.add_testsuite(suite) xml.write(str(path)) break @@ -1716,12 +1715,14 @@ def _assert_coverage_config_matches_expected( """ actual = parse(actual_config_path.read_text()) expected = parse(expected_config_path.read_text()) - actual["tool"]["coverage"]["run"]["omit"] = sorted( # type: ignore[index] - actual["tool"]["coverage"]["run"]["omit"] # type: ignore[index, arg-type] - ) - expected["tool"]["coverage"]["run"]["omit"] = sorted( # type: ignore[index] - expected["tool"]["coverage"]["run"]["omit"] # type: ignore[index, arg-type] - ) + actual_tool = cast(TOMLDocument, actual["tool"]) + actual_coverage = cast(TOMLDocument, actual_tool["coverage"]) + actual_run = cast(TOMLDocument, actual_coverage["run"]) + actual_run["omit"] = sorted(cast(list[str], actual_run["omit"])) + expected_tool = cast(TOMLDocument, expected["tool"]) + expected_coverage = cast(TOMLDocument, expected_tool["coverage"]) + expected_run = cast(TOMLDocument, expected_coverage["run"]) + expected_run["omit"] = sorted(cast(list[str], expected_run["omit"])) assert actual == expected diff --git a/build_support/test/unit_tests/ci_cd_vars/test_docker_vars.py b/build_support/test/unit_tests/ci_cd_vars/test_docker_vars.py index 4b45eb9..cf37fc2 100644 --- a/build_support/test/unit_tests/ci_cd_vars/test_docker_vars.py +++ b/build_support/test/unit_tests/ci_cd_vars/test_docker_vars.py @@ -13,15 +13,15 @@ get_docker_image_name, get_docker_tag_suffix, get_interactive_docker_command_for_image, - get_mypy_path_env, - get_mypy_path_for_target_image, get_python_path_env, get_python_path_for_target_image, + get_ty_extra_search_path_args, + get_ty_extra_search_paths_for_target_image, ) from build_support.ci_cd_vars.file_and_dir_path_vars import ( get_all_python_folders, get_all_src_folders, - get_test_utils_dirs, + get_all_test_folders, ) from build_support.ci_cd_vars.project_structure import get_dockerfile from build_support.ci_cd_vars.subproject_structure import ( @@ -159,63 +159,61 @@ def test_get_python_path_env( ) -def test_get_mypy_path_for_target_image( +def test_get_ty_extra_search_paths_for_target_image( docker_project_root: Path, docker_target: DockerTarget ) -> None: - observed_mypy_path = get_mypy_path_for_target_image( + observed_search_paths = get_ty_extra_search_paths_for_target_image( docker_project_root=docker_project_root, target_image=docker_target ) if docker_target == DockerTarget.BUILD: - assert observed_mypy_path == ":".join( - concatenate_args( - args=[ - get_python_subproject( - subproject_context=SubprojectContext.BUILD_SUPPORT, - project_root=docker_project_root, - ).get_src_dir() - ] - ) - ) + assert observed_search_paths == [ + get_python_subproject( + subproject_context=SubprojectContext.BUILD_SUPPORT, + project_root=docker_project_root, + ).get_src_dir() + ] elif docker_target == DockerTarget.DEV: src_folders = get_all_src_folders(project_root=docker_project_root) - test_utils_dirs = get_test_utils_dirs(project_root=docker_project_root) - expected_folders = src_folders + test_utils_dirs - assert observed_mypy_path == ":".join(concatenate_args(args=[expected_folders])) + test_folders = get_all_test_folders(project_root=docker_project_root) + expected_folders = src_folders + test_folders + assert observed_search_paths == expected_folders elif docker_target == DockerTarget.PROD: - assert observed_mypy_path == ":".join( - concatenate_args( - args=[ - get_python_subproject( - subproject_context=SubprojectContext.PYPI, - project_root=docker_project_root, - ).get_src_dir() - ] - ) - ) + assert observed_search_paths == [ + get_python_subproject( + subproject_context=SubprojectContext.PYPI, + project_root=docker_project_root, + ).get_src_dir() + ] elif docker_target == DockerTarget.INFRA: - assert observed_mypy_path == ":".join( - concatenate_args( - args=[ - get_python_subproject( - subproject_context=SubprojectContext.INFRA, - project_root=docker_project_root, - ).get_src_dir() - ] - ) - ) + assert observed_search_paths == [ + get_python_subproject( + subproject_context=SubprojectContext.INFRA, + project_root=docker_project_root, + ).get_src_dir() + ] else: # pragma: no cov - will only hit if enum not covered msg = f"{docker_target.__name__} is not a supported type." raise ValueError(msg) -def test_get_mypy_path_env( +def test_get_ty_extra_search_path_args( docker_project_root: Path, docker_target: DockerTarget ) -> None: - assert get_mypy_path_env( - docker_project_root=docker_project_root, target_image=docker_target - ) == "MYPYPATH=" + get_mypy_path_for_target_image( - docker_project_root=docker_project_root, target_image=docker_target + expected_args = concatenate_args( + args=[ + arg + for search_path in get_ty_extra_search_paths_for_target_image( + docker_project_root=docker_project_root, target_image=docker_target + ) + for arg in ("--extra-search-path", search_path) + ] + ) + assert ( + get_ty_extra_search_path_args( + docker_project_root=docker_project_root, target_image=docker_target + ) + == expected_args ) diff --git a/build_support/test/unit_tests/ci_cd_vars/test_project_structure.py b/build_support/test/unit_tests/ci_cd_vars/test_project_structure.py index 3956e67..b09f741 100644 --- a/build_support/test/unit_tests/ci_cd_vars/test_project_structure.py +++ b/build_support/test/unit_tests/ci_cd_vars/test_project_structure.py @@ -4,7 +4,7 @@ get_build_dir, get_dockerfile, get_docs_dir, - get_feature_test_log_name, + get_feature_test_log_file, get_feature_test_scratch_folder, get_license_file, get_new_project_settings, @@ -86,14 +86,14 @@ def test_get_readme(mock_project_root: Path) -> None: ) -def test_get_feature_test_log_name(mock_project_root: Path) -> None: - test_name = "test_something[param1::param2]" +def test_get_feature_test_log_file(mock_project_root: Path) -> None: + log_name = "test_something[param1::param2]" expected_log_path = get_feature_test_scratch_folder( project_root=mock_project_root ).joinpath("test_logs", "test_something_param1_param2.log") assert not expected_log_path.parent.exists() - result = get_feature_test_log_name( - project_root=mock_project_root, test_name=test_name + result = get_feature_test_log_file( + project_root=mock_project_root, log_name=log_name ) assert result == expected_log_path assert expected_log_path.parent.exists() diff --git a/build_support/test/unit_tests/conftest.py b/build_support/test/unit_tests/conftest.py index 1c5b779..8ad76e7 100644 --- a/build_support/test/unit_tests/conftest.py +++ b/build_support/test/unit_tests/conftest.py @@ -86,25 +86,24 @@ def pyproject_toml_data(project_version: str, project_name: str) -> TOMLDocument """The TOMLDocument that would be read from the pyproject toml.""" doc = document() doc["project"] = table() - doc["project"]["name"] = project_name # type: ignore[index] - doc["project"]["version"] = project_version # type: ignore[index] + project = cast(TOMLDocument, doc["project"]) + project["name"] = project_name + project["version"] = project_version doc["tool"] = table() - tool = doc["tool"] - tool["coverage"] = table() # type: ignore[index] - tool["coverage"]["run"] = table() # type: ignore[index] - tool["coverage"]["run"]["branch"] = True # type: ignore[index] - tool["coverage"]["run"]["parallel"] = True # type: ignore[index] - tool["coverage"]["run"]["concurrency"] = [ # type: ignore[index] - "multiprocessing", - "thread", - ] - - tool["coverage"]["report"] = table() # type: ignore[index] - tool["coverage"]["report"]["fail_under"] = 100 # type: ignore[index] - tool["coverage"]["report"]["exclude_lines"] = [ # type: ignore[index] - "pragma: no cov" - ] + tool = cast(TOMLDocument, doc["tool"]) + tool["coverage"] = table() + coverage = cast(TOMLDocument, tool["coverage"]) + coverage["run"] = table() + run_table = cast(TOMLDocument, coverage["run"]) + run_table["branch"] = True + run_table["parallel"] = True + run_table["concurrency"] = ["multiprocessing", "thread"] + + coverage["report"] = table() + report_table = cast(TOMLDocument, coverage["report"]) + report_table["fail_under"] = 100 + report_table["exclude_lines"] = ["pragma: no cov"] return doc diff --git a/build_support/test/unit_tests/new_project_setup/test_new_project_data_models.py b/build_support/test/unit_tests/new_project_setup/test_new_project_data_models.py index e3e5dfc..d741ea9 100644 --- a/build_support/test/unit_tests/new_project_setup/test_new_project_data_models.py +++ b/build_support/test/unit_tests/new_project_setup/test_new_project_data_models.py @@ -70,9 +70,7 @@ def test_org_formatted_name_and_email( assert org.formatted_name_and_email() == "Someone Nice " -def test_org_as_pyproject_author( - project_settings_data_dict: dict[str, Any], -) -> None: +def test_org_as_pyproject_author(project_settings_data_dict: dict[str, Any]) -> None: project_setting = ProjectSettings.model_validate(project_settings_data_dict) org = project_setting.organization assert org.as_pyproject_author() == { diff --git a/build_support/test/unit_tests/new_project_setup/test_setup_new_project.py b/build_support/test/unit_tests/new_project_setup/test_setup_new_project.py index 80a3a32..caa5e47 100644 --- a/build_support/test/unit_tests/new_project_setup/test_setup_new_project.py +++ b/build_support/test/unit_tests/new_project_setup/test_setup_new_project.py @@ -2,6 +2,7 @@ import tomllib from collections.abc import Mapping from pathlib import Path +from typing import cast from build_support.ci_cd_tasks.env_setup_tasks import Clean from build_support.ci_cd_tasks.task_node import BasicTaskInfo @@ -34,7 +35,7 @@ def _license_value(pyproject_license: str | Mapping[str, str]) -> str: str: The license identifier (e.g. ``unlicense``, ``mit``). """ if isinstance(pyproject_license, Mapping): - return pyproject_license["text"] + return cast(dict[str, str], pyproject_license)["text"] return pyproject_license diff --git a/build_support/test/unit_tests/new_project_setup/test_update_pyproject_toml.py b/build_support/test/unit_tests/new_project_setup/test_update_pyproject_toml.py index dcd0d1c..0376ac7 100644 --- a/build_support/test/unit_tests/new_project_setup/test_update_pyproject_toml.py +++ b/build_support/test/unit_tests/new_project_setup/test_update_pyproject_toml.py @@ -1,6 +1,7 @@ import shutil from copy import deepcopy from pathlib import Path +from typing import cast from build_support.ci_cd_vars.project_setting_vars import get_pyproject_toml_data from build_support.ci_cd_vars.project_structure import get_pyproject_toml @@ -9,6 +10,7 @@ ProjectSettings, ) from build_support.new_project_setup.update_pyproject_toml import update_pyproject_toml +from tomlkit import TOMLDocument def test_update_pyproject_toml(tmp_path: Path, real_project_root_dir: Path) -> None: @@ -32,17 +34,19 @@ def test_update_pyproject_toml(tmp_path: Path, real_project_root_dir: Path) -> N project_root=tmp_project_path, new_project_settings=new_project_settings ) - expected_project = expected_data["project"] - expected_project["name"] = new_project_settings.name # type: ignore[index] - expected_project["version"] = "0.0.0" # type: ignore[index] - expected_project["license"] = new_project_settings.license # type: ignore[index] - expected_project["authors"] = [ # type: ignore[index] + expected_project = cast(TOMLDocument, expected_data["project"]) + expected_project["name"] = new_project_settings.name + expected_project["version"] = "0.0.0" + expected_project["license"] = new_project_settings.license + expected_project["authors"] = [ new_project_settings.organization.as_pyproject_author() ] - expected_hatch = expected_data["tool"]["hatch"] # type: ignore[index] - expected_hatch["build"]["targets"]["wheel"]["packages"] = [ # type: ignore[index] - f"pypi_package/src/{new_project_settings.name}" - ] + expected_tool = cast(TOMLDocument, expected_data["tool"]) + expected_hatch = cast(TOMLDocument, expected_tool["hatch"]) + expected_hatch_build = cast(TOMLDocument, expected_hatch["build"]) + expected_targets = cast(TOMLDocument, expected_hatch_build["targets"]) + expected_wheel = cast(TOMLDocument, expected_targets["wheel"]) + expected_wheel["packages"] = [f"pypi_package/src/{new_project_settings.name}"] observed_new_pyproject_toml = get_pyproject_toml_data(project_root=tmp_project_path) assert observed_new_pyproject_toml == expected_data diff --git a/build_support/test/unit_tests/test_dag_engine.py b/build_support/test/unit_tests/test_dag_engine.py index 2908088..62cef7a 100644 --- a/build_support/test/unit_tests/test_dag_engine.py +++ b/build_support/test/unit_tests/test_dag_engine.py @@ -15,7 +15,7 @@ def build_mock_basic_task( ) -> TaskNode: """Builds a mock task for testing task interactions.""" - return type( # type: ignore[no-any-return] + return type( task_name, (TaskNode,), {"required_tasks": Mock(return_value=required_mock_tasks), "run": Mock()}, diff --git a/build_support/test/unit_tests/test_file_caching.py b/build_support/test/unit_tests/test_file_caching.py index 4855ce4..7971775 100644 --- a/build_support/test/unit_tests/test_file_caching.py +++ b/build_support/test/unit_tests/test_file_caching.py @@ -1,7 +1,7 @@ from datetime import UTC, datetime from pathlib import Path from time import sleep -from typing import Any +from typing import Any, cast from unittest.mock import patch import pytest @@ -34,12 +34,12 @@ def test_file_info_serialize_deserialize(file_info: TestFileInfo) -> None: def test_file_info_serialize_deserialize_bad_file_path() -> None: with pytest.raises(ValidationError): - TestFileInfo(file_path=4, tests_passed=None) + TestFileInfo(file_path=cast(Any, 4), tests_passed=None) def test_file_info_serialize_deserialize_bad_datetime() -> None: with pytest.raises(ValidationError): - TestFileInfo(file_path=Path("some/file"), tests_passed="5/1/2025") + TestFileInfo(file_path=Path("some/file"), tests_passed=cast(Any, "5/1/2025")) @pytest.fixture diff --git a/docs/developer_tooling.rst b/docs/developer_tooling.rst index 806607b..a9743ac 100755 --- a/docs/developer_tooling.rst +++ b/docs/developer_tooling.rst @@ -80,6 +80,9 @@ a few important ones for people who are new to this repository. * - make echo_v - - Prints a Makefile variable; useful when debugging or adding new targets. + * - make echo_image_tags + - + - Prints Docker image tag variables (TAG_SUFFIX, DOCKER_BUILD_IMAGE, etc.). * - make setup_build_env - - Builds the Docker image for the build environment. @@ -96,6 +99,9 @@ a few important ones for people who are new to this repository. - - Updates :code:`uv.lock` in the dev container after changing :code:`pyproject.toml`. Builds the dev image if needed. + * - make make_new_project + - + - Creates a new project from this template (interactive). * - make format - Yes - Runs code formatting for this repo. @@ -126,10 +132,13 @@ a few important ones for people who are new to this repository. - Runs process-enforcement tests for this repo. * - make type_checks - - - Runs mypy for this repo. + - Runs ty type checks for this repo. * - make type_check_build_support - - - Runs mypy for the :code:`build_support` subproject only. + - Runs ty type checks for the :code:`build_support` subproject only. + * - make type_check_pypi + - + - Runs ty type checks for the :code:`pypi_package` subproject only. * - make security_checks - - Runs Bandit security checks for this repo. diff --git a/docs/software_development_process.rst b/docs/software_development_process.rst index 731985c..20a1c8d 100644 --- a/docs/software_development_process.rst +++ b/docs/software_development_process.rst @@ -510,16 +510,14 @@ so that all stable rules are run, and unstable rules are skipped. When running on test code we also turn off pydocstyle (D) and flake8-boolean-trap (FBT) rules, because they are onerous to enforce and provide very little benefit in test code. -Static Type Checking - MyPy -''''''''''''''''''''''''''' - -We run :code:`mypy` on every package to ensure that typing is enforced. The version -and configuration live in :code:`pyproject.toml` under :code:`[tool.mypy]`. We use the -Pydantic plugin and enable a strict set of checks: disallow untyped defs and untyped -calls, warn on redundant casts and return of :code:`Any`, disallow unimported -:code:`Any`, and enable error codes such as redundant-self, possibly-undefined, -truthy-bool, explicit-override, and others. Any use of :code:`type: ignore` must be -explained to the satisfaction of the pull request reviewer. +Static Type Checking - ty +''''''''''''''''''''''''' + +We run :code:`ty check` on every package to ensure that typing is enforced. The +configuration lives in :code:`pyproject.toml` under :code:`[tool.ty.rules]` with +:code:`all = "error"`, so every enabled ty check is enforced at error severity. Any +use of :code:`type: ignore` must be explained to the satisfaction of the pull request +reviewer. Bandit Security Tests ''''''''''''''''''''' diff --git a/docs/source_code_style_guide.rst b/docs/source_code_style_guide.rst index 9b9f443..4c3fca9 100644 --- a/docs/source_code_style_guide.rst +++ b/docs/source_code_style_guide.rst @@ -6,7 +6,7 @@ in this project. It covers everything from how to divide a package into subpack to how to name a local variable. When in doubt about any structural or stylistic decision, consult this document. -The automated enforcement tools — Ruff, mypy, and Bandit — catch most surface-level +The automated enforcement tools — Ruff, ty, and Bandit — catch most surface-level issues. This guide addresses the design-level decisions that tools cannot check. Architecture @@ -140,6 +140,30 @@ When a class is appropriate: ) return _core_analysis(params=params, client=client) +Prefer Keyword Arguments +~~~~~~~~~~~~~~~~~~~~~~~~ + +When calling functions and constructors, prefer passing arguments by keyword rather than +position. Keyword arguments make call sites self-documenting, reduce mistakes when +parameters are reordered, and make it easier to add or remove parameters over time. + +.. code-block:: python + + # Good: intent is clear at the call site + result = compute_total(items=items, discount_rate=rate, currency=currency) + + # Acceptable for a single argument where the name is obvious + path = Path(project_root) + + # Prefer keyword form for two or more arguments + config = FeatureTestCommandContext( + args_prefix=prefix, + mock_project_root=cwd, + real_project_root_dir=project_root, + test_name=name, + log_name=log_name, + ) + Dependency Injection ~~~~~~~~~~~~~~~~~~~~ @@ -364,7 +388,7 @@ Type Annotations ---------------- All functions and methods must have complete type annotations: every parameter and the -return type. This is enforced by mypy in strict mode. +return type. This is enforced by ty with all checks set to error level. Use the most specific type available. Prefer ``Path`` over ``str`` for file paths; prefer a domain-specific ``Annotated`` type over its underlying primitive; prefer a @@ -565,8 +589,8 @@ a pull request can be merged. Their configuration lives in ``pyproject.toml``. only those that are genuinely inapplicable to this codebase. Pydocstyle is configured to require Google-style docstrings. -**mypy** — runs in strict mode. All functions must be typed; ``Any`` is prohibited -except where unavoidable; generic types must be parameterized. +**ty** — all checks are configured at error level. All functions must be typed; +``Any`` is prohibited except where unavoidable; generic types must be parameterized. **Bandit** — scans source files for security issues. Low-severity findings may be suppressed with a ``# nosec`` comment only if the exact rule ID is given and a diff --git a/docs/testing_style_guide.rst b/docs/testing_style_guide.rst index a5f7058..99de345 100644 --- a/docs/testing_style_guide.rst +++ b/docs/testing_style_guide.rst @@ -404,5 +404,5 @@ Type Annotations ~~~~~~~~~~~~~~~~~ All test functions and fixtures must have complete type annotations, consistent with the -mypy configuration used for source code. This makes fixture dependency chains +ty configuration used for source code. This makes fixture dependency chains self-documenting and catches errors in test setup early. diff --git a/docs/tickets/template_python_project/98-switch-from-mypy-to-ty.rst b/docs/tickets/template_python_project/98-switch-from-mypy-to-ty.rst new file mode 100644 index 0000000..aa44a7f --- /dev/null +++ b/docs/tickets/template_python_project/98-switch-from-mypy-to-ty.rst @@ -0,0 +1,24 @@ +98: Switch Static Typing from mypy to ty +======================================== + +Overview +-------- +This ticket migrates repository static type checks from mypy to ty. +The goal is to keep strict typing enforcement while standardizing on Astral tooling. + +Requirements +------------ +- Replace mypy-based type checking with ty across build tooling and test coverage. +- Configure ty so all checks are enforced at error level. +- Remove mypy-specific configuration and command wiring from the repo. +- Update feature tests that rely on mypy if needed. +- Determine the maximum number of rules that can be readily enforced on this repo. +- We must use all the default ty rules, and we'd prefer to be able to use all of them. + +Acceptance Criteria / Feature Tests +----------------------------------- +- Running :code:`make type_checks` uses ty and succeeds on the repository. +- Running :code:`make type_check_pypi` uses ty and reports failures for + intentionally invalid type-check fixture files. +- :code:`pyproject.toml` contains ty configuration with all checks set to error, and no + mypy configuration block remains. diff --git a/pypi_package/test/feature_tests/conftest.py b/pypi_package/test/feature_tests/conftest.py index b2a91c4..63cb7ef 100644 --- a/pypi_package/test/feature_tests/conftest.py +++ b/pypi_package/test/feature_tests/conftest.py @@ -10,6 +10,7 @@ get_feature_test_scratch_folder, maybe_build_dir, ) +from test_utils.command_runner import FeatureTestCommandContext def _sanitize_test_id(node_name: str) -> str: @@ -73,3 +74,24 @@ def prod_docker_command_prefix(host_tmp_path: Path, prod_workdir: str) -> list[s prod_workdir, image, ] + + +@pytest.fixture +def default_command_context( + prod_docker_command_prefix: list[str], + pypi_feature_test_scratch_path: Path, + request: SubRequest, +) -> FeatureTestCommandContext: + """Default feature test context for pypi prod CLI tests. + + Runs commands via the prod Docker image with the scratch dir as cwd. + Copy and override fields when needed (e.g. expect_failure, log_name). + """ + project_root = Path(os.environ.get("DOCKER_REMOTE_PROJECT_ROOT") or Path.cwd()) + return FeatureTestCommandContext( + args_prefix=prod_docker_command_prefix, + mock_project_root=pypi_feature_test_scratch_path, + real_project_root_dir=project_root, + test_name=request.node.name, + log_name=request.node.name, + ) diff --git a/pypi_package/test/feature_tests/test_100_template_python_project.py b/pypi_package/test/feature_tests/test_100_template_python_project.py index 1854cbf..6d695e2 100644 --- a/pypi_package/test/feature_tests/test_100_template_python_project.py +++ b/pypi_package/test/feature_tests/test_100_template_python_project.py @@ -1,26 +1,17 @@ """Feature tests for ticket 100 calculator CLI behavior.""" -import os -from pathlib import Path - import pytest -from _pytest.fixtures import FixtureRequest from template_python_project.api.data_models import CalculatorInput, CalculatorOutput from template_python_project.calculators.data_models import CalculationType -from test_utils.command_runner import run_command_and_save_logs +from test_utils.command_runner import ( + FeatureTestCommandContext, + run_command_and_save_logs, +) -def _run_cli_in_prod_docker( - prod_docker_command_prefix: list[str], - input_filename: str, - output_filename: str, - test_name: str, - cwd: Path, -) -> tuple[int, str, str]: - """Run the CLI in the prod container with tmp_path mounted at prod_workdir.""" - project_root = Path(os.environ.get("DOCKER_REMOTE_PROJECT_ROOT") or Path.cwd()) - args = [ - *prod_docker_command_prefix, +def _cli_command_args(input_filename: str, output_filename: str) -> list[str]: + """Build command args for the calculator CLI.""" + return [ "python", "-m", "template_python_project.main", @@ -29,9 +20,6 @@ def _run_cli_in_prod_docker( "--output", output_filename, ] - return run_command_and_save_logs( - args=args, cwd=cwd, test_name=test_name, real_project_root_dir=project_root - ) @pytest.mark.parametrize( @@ -58,25 +46,21 @@ def _run_cli_in_prod_docker( ], ) def test_main_cli_writes_expected_output_for_each_calculation_type( - request: FixtureRequest, + default_command_context: FeatureTestCommandContext, calculator_input: CalculatorInput, expected_output: CalculatorOutput, - prod_docker_command_prefix: list[str], - pypi_feature_test_scratch_path: Path, ) -> None: """Check if the CLI produces the expected output for each calculation input.""" + cwd = default_command_context.mock_project_root calculation_type_name = calculator_input.type_of_calc.name.lower() input_filename = f"{calculation_type_name}_input.json" output_filename = f"{calculation_type_name}_result.json" - input_file = pypi_feature_test_scratch_path.joinpath(input_filename) - output_file = pypi_feature_test_scratch_path.joinpath(output_filename) + input_file = cwd.joinpath(input_filename) + output_file = cwd.joinpath(output_filename) input_file.write_text(calculator_input.model_dump_json()) - return_code, _, stderr = _run_cli_in_prod_docker( - prod_docker_command_prefix=prod_docker_command_prefix, - input_filename=input_filename, - output_filename=output_filename, - test_name=request.node.name, - cwd=pypi_feature_test_scratch_path, + return_code, _, stderr = run_command_and_save_logs( + context=default_command_context, + command_args=_cli_command_args(input_filename, output_filename), ) assert return_code == 0, stderr observed_output = CalculatorOutput.model_validate_json(output_file.read_text()) @@ -84,25 +68,21 @@ def test_main_cli_writes_expected_output_for_each_calculation_type( def test_main_cli_fails_for_divide_by_zero( - request: FixtureRequest, - prod_docker_command_prefix: list[str], - pypi_feature_test_scratch_path: Path, + default_command_context: FeatureTestCommandContext, ) -> None: """Division by zero exits non-zero and does not produce an output file.""" + cwd = default_command_context.mock_project_root input_filename = "divide_by_zero_input.json" output_filename = "divide_by_zero_result.json" - input_file = pypi_feature_test_scratch_path.joinpath(input_filename) - output_file = pypi_feature_test_scratch_path.joinpath(output_filename) + input_file = cwd.joinpath(input_filename) + output_file = cwd.joinpath(output_filename) divide_by_zero_input = CalculatorInput( type_of_calc=CalculationType.DIVIDE, value1=5, value2=0 ) input_file.write_text(divide_by_zero_input.model_dump_json()) - return_code, _, stderr = _run_cli_in_prod_docker( - prod_docker_command_prefix=prod_docker_command_prefix, - input_filename=input_filename, - output_filename=output_filename, - test_name=request.node.name, - cwd=pypi_feature_test_scratch_path, + return_code, _, stderr = run_command_and_save_logs( + context=default_command_context, + command_args=_cli_command_args(input_filename, output_filename), ) assert return_code != 0 assert "ZeroDivisionError" in stderr diff --git a/pypi_package/test/unit_tests/api/test_data_models.py b/pypi_package/test/unit_tests/api/test_data_models.py index ff7814f..3e385ea 100644 --- a/pypi_package/test/unit_tests/api/test_data_models.py +++ b/pypi_package/test/unit_tests/api/test_data_models.py @@ -73,7 +73,7 @@ def test_calculator_input_rejects_extra_fields() -> None: type_of_calc=CalculationType.ADD, value1=1.0, value2=2.0, - extra_field="bad", # type: ignore[call-arg] + extra_field="bad", # type: ignore[unknown-argument] ) diff --git a/pypi_package/test/unit_tests/api/versioned_model/test_pydantic_semver_annotation.py b/pypi_package/test/unit_tests/api/versioned_model/test_pydantic_semver_annotation.py index b82cd0a..143ad9e 100644 --- a/pypi_package/test/unit_tests/api/versioned_model/test_pydantic_semver_annotation.py +++ b/pypi_package/test/unit_tests/api/versioned_model/test_pydantic_semver_annotation.py @@ -1,5 +1,7 @@ """Unit tests for versioned_model.pydantic_semver_annotation.""" +from typing import Any, cast + import pytest from pydantic import BaseModel, ValidationError from semver import Version @@ -17,14 +19,14 @@ class _SemVerModel(BaseModel): @pytest.mark.parametrize("input_value", [Version.parse("1.2.3"), "1.2.3"]) def test_pydantic_semver_accepts_version_and_string(input_value: object) -> None: """PydanticSemVer accepts both Version instances and strings.""" - model = _SemVerModel(version=input_value) + model = _SemVerModel(version=cast(Any, input_value)) assert isinstance(model.version, Version) assert model.version == Version.parse("1.2.3") def test_pydantic_semver_serializes_version_as_string() -> None: """PydanticSemVer serializes the version field to a string.""" - model = _SemVerModel(version="2.0.0") + model = _SemVerModel(version=cast(Any, "2.0.0")) dumped = model.model_dump() assert dumped["version"] == "2.0.0" @@ -35,7 +37,7 @@ def test_pydantic_semver_serializes_version_as_string() -> None: def test_pydantic_semver_rejects_invalid_string() -> None: """Invalid semantic version strings raise a Pydantic validation error.""" with pytest.raises(ValidationError) as exc_info: - _SemVerModel(version="not-a-version") + _SemVerModel(version=cast(Any, "not-a-version")) message = str(exc_info.value) assert "not a valid semantic version string" in message diff --git a/pypi_package/test/unit_tests/api/versioned_model/test_versioned_model.py b/pypi_package/test/unit_tests/api/versioned_model/test_versioned_model.py index 1548fe6..330c84f 100644 --- a/pypi_package/test/unit_tests/api/versioned_model/test_versioned_model.py +++ b/pypi_package/test/unit_tests/api/versioned_model/test_versioned_model.py @@ -1,6 +1,6 @@ """Tests for VersionedModel — version validation, serialization, and migration.""" -from typing import Any, ClassVar, override +from typing import Any, ClassVar, cast, override import pytest from pydantic import ValidationError @@ -56,7 +56,7 @@ class ChildModelB(SimpleVersionedModel): @classmethod @override - def _coerce_to_most_recent_version(cls, data: Any) -> Any: # type: ignore[override] + def _coerce_to_most_recent_version(cls, data: Any) -> Any: """Backfill new_value_1 and new_value_2 for payloads from older versions.""" version = cls._get_valid_version_if_any_from_raw_data(data=data) if ( @@ -90,7 +90,9 @@ def test_simple_versioned_model_accepts_versions_within_supported_range( data_model_version: Version | str, ) -> None: """SimpleVersionedModel accepts versions within [lowest_supported, current].""" - model = SimpleVersionedModel(name="ok", data_model_version=data_model_version) + model = SimpleVersionedModel( + name="ok", data_model_version=cast(Any, data_model_version) + ) assert model.data_model_version == Version(major=50, minor=10, patch=0) @@ -106,7 +108,7 @@ def test_simple_versioned_model_rejects_versions_outside_supported_range( ) -> None: """SimpleVersionedModel rejects versions outside the supported range.""" with pytest.raises(ValidationError) as exc_info: - SimpleVersionedModel(name="bad", data_model_version=bad_version) + SimpleVersionedModel(name="bad", data_model_version=cast(Any, bad_version)) msg = str(exc_info.value) assert "Model undefined for versions" in msg @@ -153,14 +155,14 @@ def test_child_model_a_pins_single_supported_version() -> None: model = ChildModelA(name="child-a") assert model.data_model_version == Version(major=2, minor=1, patch=0) - # Explicit same version is accepted - explicit = ChildModelA(name="child-a", data_model_version="2.1.0") + # Explicit same version is accepted (Pydantic coerces str at runtime) + explicit = ChildModelA(name="child-a", data_model_version=cast(Any, "2.1.0")) assert explicit.data_model_version == Version(major=2, minor=1, patch=0) # Lower and higher versions are rejected for bad in ["2.0.9", "3.0.0"]: with pytest.raises(ValidationError): - ChildModelA(name="bad", data_model_version=bad) + ChildModelA(name="bad", data_model_version=cast(Any, bad)) # --------------------------------------------------------------------------- diff --git a/pypi_package/test/unit_tests/calculators/test_data_models.py b/pypi_package/test/unit_tests/calculators/test_data_models.py index 706552f..e3bead6 100644 --- a/pypi_package/test/unit_tests/calculators/test_data_models.py +++ b/pypi_package/test/unit_tests/calculators/test_data_models.py @@ -52,7 +52,7 @@ def test_calculation_request_is_frozen() -> None: """CalculationRequest is immutable after construction.""" request = CalculationRequest(operation=CalculationType.ADD, value1=1.0, value2=2.0) with pytest.raises(AttributeError): - request.value1 = 99.0 # type: ignore[misc] + request.value1 = 99.0 # type: ignore[invalid-assignment] def test_calculation_request_equality() -> None: @@ -81,7 +81,7 @@ def test_calculation_result_is_frozen() -> None: """CalculationResult is immutable after construction.""" result = CalculationResult(result=1.0) with pytest.raises(AttributeError): - result.result = 99.0 # type: ignore[misc] + result.result = 99.0 # type: ignore[invalid-assignment] def test_calculation_result_equality() -> None: diff --git a/pyproject.toml b/pyproject.toml index f1af081..ad1047c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,36 +34,12 @@ line-ending = "auto" docstring-code-format = true docstring-code-line-length = "dynamic" -[tool.ruff.lint.per-file-ignores] -"process_and_style_enforcement/sphinx_conf/conf.py" = ["INP001"] - -[tool.mypy] -plugins = ["pydantic.mypy"] - -disallow_any_generics = true -disallow_untyped_defs = true -warn_redundant_casts = true -strict_equality = true -disallow_untyped_calls = true -warn_return_any = true -disallow_any_unimported = true -warn_unused_ignores = true -enable_error_code = [ - "redundant-self", - "redundant-expr", - "possibly-undefined", - "truthy-bool", - "truthy-iterable", - "ignore-without-code", - "unused-awaitable", - "explicit-override", - "mutable-override", - "unimported-reveal", -] +[tool.ty.rules] +all = "error" [project] name = "template_python_project" -version = "0.2.40" +version = "0.2.41" packages = [{include = "template_python_project", from="pypi_package/src"}] description = "A project that can be used as a template to provide some CI/CD out of the box." readme = "README.md" @@ -89,14 +65,14 @@ build = [ ] dev = [ "bandit[baseline,toml]>=1.9,<2", - "mypy>=1.17,<2", "pytest>=9.0,<10", "pytest-cov>=7.0,<8", "pytest-xdist>=3.8,<4", "requests>=2.32,<3", - "ruff==0.15.2", + "ruff==0.15.2", # pinned because it can have changes across many files "sphinx>=9.1,<10", "sphinx-rtd-theme>=3.1,<4", + "ty==0.0.18", # pinned because it can have changes across many files "types-requests>=2.32,<3", ] pulumi = [ diff --git a/uv.lock b/uv.lock index 9dcfe08..b1addd0 100644 --- a/uv.lock +++ b/uv.lock @@ -346,53 +346,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/44/87/b444f934f62ee2a1be45bb52563cf17a66b0d790eba43af4df9929e7107f/junitparser-4.0.2-py3-none-any.whl", hash = "sha256:94c3570e41fcaedc64cc3c634ca99457fe41a84dd1aa8ff74e9e12e66223a155", size = 14592, upload-time = "2025-06-24T04:37:31.322Z" }, ] -[[package]] -name = "librt" -version = "0.8.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" }, - { url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" }, - { url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z" }, - { url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" }, - { url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z" }, - { url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z" }, - { url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z" }, - { url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z" }, - { url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" }, - { url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969, upload-time = "2026-02-17T16:12:09.633Z" }, - { url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000, upload-time = "2026-02-17T16:12:10.632Z" }, - { url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495, upload-time = "2026-02-17T16:12:11.633Z" }, - { url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081, upload-time = "2026-02-17T16:12:12.766Z" }, - { url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309, upload-time = "2026-02-17T16:12:13.756Z" }, - { url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804, upload-time = "2026-02-17T16:12:14.818Z" }, - { url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907, upload-time = "2026-02-17T16:12:16.513Z" }, - { url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217, upload-time = "2026-02-17T16:12:17.906Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622, upload-time = "2026-02-17T16:12:19.108Z" }, - { url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987, upload-time = "2026-02-17T16:12:20.331Z" }, - { url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132, upload-time = "2026-02-17T16:12:21.54Z" }, - { url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195, upload-time = "2026-02-17T16:12:23.073Z" }, - { url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946, upload-time = "2026-02-17T16:12:24.275Z" }, - { url = "https://files.pythonhosted.org/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023", size = 50689, upload-time = "2026-02-17T16:12:25.766Z" }, - { url = "https://files.pythonhosted.org/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730", size = 57875, upload-time = "2026-02-17T16:12:27.465Z" }, - { url = "https://files.pythonhosted.org/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3", size = 48058, upload-time = "2026-02-17T16:12:28.556Z" }, - { url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313, upload-time = "2026-02-17T16:12:29.659Z" }, - { url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994, upload-time = "2026-02-17T16:12:31.516Z" }, - { url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770, upload-time = "2026-02-17T16:12:33.294Z" }, - { url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409, upload-time = "2026-02-17T16:12:35.443Z" }, - { url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473, upload-time = "2026-02-17T16:12:36.656Z" }, - { url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866, upload-time = "2026-02-17T16:12:37.849Z" }, - { url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248, upload-time = "2026-02-17T16:12:39.445Z" }, - { url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629, upload-time = "2026-02-17T16:12:40.889Z" }, - { url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615, upload-time = "2026-02-17T16:12:42.446Z" }, - { url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001, upload-time = "2026-02-17T16:12:43.627Z" }, - { url = "https://files.pythonhosted.org/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a", size = 51328, upload-time = "2026-02-17T16:12:45.148Z" }, - { url = "https://files.pythonhosted.org/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4", size = 58722, upload-time = "2026-02-17T16:12:46.85Z" }, - { url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" }, -] - [[package]] name = "markdown-it-py" version = "4.0.0" @@ -466,42 +419,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] -[[package]] -name = "mypy" -version = "1.19.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, - { name = "mypy-extensions" }, - { name = "pathspec" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, - { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, - { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, - { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, - { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, - { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, - { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, - { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, - { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, - { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, - { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, - { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, -] - -[[package]] -name = "mypy-extensions" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, -] - [[package]] name = "packaging" version = "26.0" @@ -524,15 +441,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0f/4c/f98024021bef4d44dce3613feebd702c7ad8883f777ff8488384c59e9774/parver-0.5-py3-none-any.whl", hash = "sha256:2281b187276c8e8e3c15634f62287b2fb6fe0efe3010f739a6bd1e45fa2bf2b2", size = 15172, upload-time = "2023-10-03T21:06:52.796Z" }, ] -[[package]] -name = "pathspec" -version = "1.0.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, -] - [[package]] name = "pip" version = "26.0.1" @@ -961,7 +869,7 @@ wheels = [ [[package]] name = "template-python-project" -version = "0.2.36" +version = "0.2.40" source = { editable = "." } dependencies = [ { name = "pydantic" }, @@ -978,7 +886,6 @@ build = [ ] dev = [ { name = "bandit", extra = ["baseline"] }, - { name = "mypy" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "pytest-xdist" }, @@ -986,6 +893,7 @@ dev = [ { name = "ruff" }, { name = "sphinx" }, { name = "sphinx-rtd-theme" }, + { name = "ty" }, { name = "types-requests" }, ] pulumi = [ @@ -998,7 +906,6 @@ requires-dist = [ { name = "bandit", extras = ["baseline", "toml"], marker = "extra == 'dev'", specifier = ">=1.9,<2" }, { name = "gitpython", marker = "extra == 'build'", specifier = ">=3.1,<4" }, { name = "junitparser", marker = "extra == 'build'", specifier = ">=4.0,<5" }, - { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.17,<2" }, { name = "pulumi", marker = "extra == 'pulumi'", specifier = "==3.223.0" }, { name = "pulumi-aws", marker = "extra == 'pulumi'", specifier = ">=7.20,<8" }, { name = "pydantic", specifier = ">=2.12,<3" }, @@ -1012,6 +919,7 @@ requires-dist = [ { name = "sphinx", marker = "extra == 'dev'", specifier = ">=9.1,<10" }, { name = "sphinx-rtd-theme", marker = "extra == 'dev'", specifier = ">=3.1,<4" }, { name = "tomlkit", marker = "extra == 'build'", specifier = ">=0.14,<0.15" }, + { name = "ty", marker = "extra == 'dev'", specifier = "==0.0.18" }, { name = "types-pyyaml", marker = "extra == 'build'", specifier = ">=6.0,<7" }, { name = "types-requests", marker = "extra == 'dev'", specifier = ">=2.32,<3" }, ] @@ -1026,6 +934,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310, upload-time = "2026-01-13T01:14:51.965Z" }, ] +[[package]] +name = "ty" +version = "0.0.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/74/15/9682700d8d60fdca7afa4febc83a2354b29cdcd56e66e19c92b521db3b39/ty-0.0.18.tar.gz", hash = "sha256:04ab7c3db5dcbcdac6ce62e48940d3a0124f377c05499d3f3e004e264ae94b83", size = 5214774, upload-time = "2026-02-20T21:51:31.173Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/d8/920460d4c22ea68fcdeb0b2fb53ea2aeb9c6d7875bde9278d84f2ac767b6/ty-0.0.18-py3-none-linux_armv6l.whl", hash = "sha256:4e5e91b0a79857316ef893c5068afc4b9872f9d257627d9bc8ac4d2715750d88", size = 10280825, upload-time = "2026-02-20T21:51:25.03Z" }, + { url = "https://files.pythonhosted.org/packages/83/56/62587de582d3d20d78fcdddd0594a73822ac5a399a12ef512085eb7a4de6/ty-0.0.18-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee0e578b3f8416e2d5416da9553b78fd33857868aa1384cb7fefeceee5ff102d", size = 10118324, upload-time = "2026-02-20T21:51:22.27Z" }, + { url = "https://files.pythonhosted.org/packages/2f/2d/dbdace8d432a0755a7417f659bfd5b8a4261938ecbdfd7b42f4c454f5aa9/ty-0.0.18-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3f7a0487d36b939546a91d141f7fc3dbea32fab4982f618d5b04dc9d5b6da21e", size = 9605861, upload-time = "2026-02-20T21:51:16.066Z" }, + { url = "https://files.pythonhosted.org/packages/6b/d9/de11c0280f778d5fc571393aada7fe9b8bc1dd6a738f2e2c45702b8b3150/ty-0.0.18-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5e2fa8d45f57ca487a470e4bf66319c09b561150e98ae2a6b1a97ef04c1a4eb", size = 10092701, upload-time = "2026-02-20T21:51:26.862Z" }, + { url = "https://files.pythonhosted.org/packages/0f/94/068d4d591d791041732171e7b63c37a54494b2e7d28e88d2167eaa9ad875/ty-0.0.18-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d75652e9e937f7044b1aca16091193e7ef11dac1c7ec952b7fb8292b7ba1f5f2", size = 10109203, upload-time = "2026-02-20T21:51:11.59Z" }, + { url = "https://files.pythonhosted.org/packages/34/e4/526a4aa56dc0ca2569aaa16880a1ab105c3b416dd70e87e25a05688999f3/ty-0.0.18-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:563c868edceb8f6ddd5e91113c17d3676b028f0ed380bdb3829b06d9beb90e58", size = 10614200, upload-time = "2026-02-20T21:51:20.298Z" }, + { url = "https://files.pythonhosted.org/packages/fd/3d/b68ab20a34122a395880922587fbfc3adf090d22e0fb546d4d20fe8c2621/ty-0.0.18-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:502e2a1f948bec563a0454fc25b074bf5cf041744adba8794d024277e151d3b0", size = 11153232, upload-time = "2026-02-20T21:51:14.121Z" }, + { url = "https://files.pythonhosted.org/packages/68/ea/678243c042343fcda7e6af36036c18676c355878dcdcd517639586d2cf9e/ty-0.0.18-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cc881dea97021a3aa29134a476937fd8054775c4177d01b94db27fcfb7aab65b", size = 10832934, upload-time = "2026-02-20T21:51:32.92Z" }, + { url = "https://files.pythonhosted.org/packages/d8/bd/7f8d647cef8b7b346c0163230a37e903c7461c7248574840b977045c77df/ty-0.0.18-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:421fcc3bc64cab56f48edb863c7c1c43649ec4d78ff71a1acb5366ad723b6021", size = 10700888, upload-time = "2026-02-20T21:51:09.673Z" }, + { url = "https://files.pythonhosted.org/packages/6e/06/cb3620dc48c5d335ba7876edfef636b2f4498eff4a262ff90033b9e88408/ty-0.0.18-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0fe5038a7136a0e638a2fb1ad06e3d3c4045314c6ba165c9c303b9aeb4623d6c", size = 10078965, upload-time = "2026-02-20T21:51:07.678Z" }, + { url = "https://files.pythonhosted.org/packages/60/27/c77a5a84533fa3b685d592de7b4b108eb1f38851c40fac4e79cc56ec7350/ty-0.0.18-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d123600a52372677613a719bbb780adeb9b68f47fb5f25acb09171de390e0035", size = 10134659, upload-time = "2026-02-20T21:51:18.311Z" }, + { url = "https://files.pythonhosted.org/packages/43/6e/60af6b88c73469e628ba5253a296da6984e0aa746206f3034c31f1a04ed1/ty-0.0.18-py3-none-musllinux_1_2_i686.whl", hash = "sha256:bb4bc11d32a1bf96a829bf6b9696545a30a196ac77bbc07cc8d3dfee35e03723", size = 10297494, upload-time = "2026-02-20T21:51:39.631Z" }, + { url = "https://files.pythonhosted.org/packages/33/90/612dc0b68224c723faed6adac2bd3f930a750685db76dfe17e6b9e534a83/ty-0.0.18-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:dda2efbf374ba4cd704053d04e32f2f784e85c2ddc2400006b0f96f5f7e4b667", size = 10791944, upload-time = "2026-02-20T21:51:37.13Z" }, + { url = "https://files.pythonhosted.org/packages/0d/da/f4ada0fd08a9e4138fe3fd2bcd3797753593f423f19b1634a814b9b2a401/ty-0.0.18-py3-none-win32.whl", hash = "sha256:c5768607c94977dacddc2f459ace6a11a408a0f57888dd59abb62d28d4fee4f7", size = 9677964, upload-time = "2026-02-20T21:51:42.039Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fa/090ed9746e5c59fc26d8f5f96dc8441825171f1f47752f1778dad690b08b/ty-0.0.18-py3-none-win_amd64.whl", hash = "sha256:b78d0fa1103d36fc2fce92f2092adace52a74654ab7884d54cdaec8eb5016a4d", size = 10636576, upload-time = "2026-02-20T21:51:29.159Z" }, + { url = "https://files.pythonhosted.org/packages/92/4f/5dd60904c8105cda4d0be34d3a446c180933c76b84ae0742e58f02133713/ty-0.0.18-py3-none-win_arm64.whl", hash = "sha256:01770c3c82137c6b216aa3251478f0b197e181054ee92243772de553d3586398", size = 10095449, upload-time = "2026-02-20T21:51:34.914Z" }, +] + [[package]] name = "types-pyyaml" version = "6.0.12.20250915"