diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 3fe44856..1a9d25af 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -28,8 +28,11 @@ jobs: enable-cache: true - name: Install just uses: extractions/setup-just@v3 + - name: Install graphviz + run: | + sudo apt-get update + sudo apt-get install graphviz graphviz-dev - run: just typing - - run: just typing-nb run-tests: diff --git a/CHANGELOG.md b/CHANGELOG.md index 33c35d60..c57999c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,10 @@ releases are available on [PyPI](https://pypi.org/project/pytask) and - {pull}`725` fixes the pickle node hash test by accounting for Python 3.14's default pickle protocol. -- {pull}`???` adapts the interactive debugger integration to Python 3.14's +- {pull}`726` adapts the interactive debugger integration to Python 3.14's updated `pdb` behaviour and keeps pytest-style capturing intact. -- {pull}`???` updates the comparison to other tools documentation and adds a section on +- {pull}`734` migrates from mypy to ty for type checking. +- {pull}`736` updates the comparison to other tools documentation and adds a section on the Common Workflow Language (CWL) and WorkflowHub. ## 0.5.7 - 2025-11-22 diff --git a/justfile b/justfile index f8e27aca..9789893d 100644 --- a/justfile +++ b/justfile @@ -10,17 +10,9 @@ test *FLAGS: test-cov *FLAGS: uv run --group test pytest --nbmake --cov=src --cov=tests --cov-report=xml -n auto {{FLAGS}} -# Run tests with notebook validation -test-nb: - uv run --group test pytest --nbmake -n auto - # Run type checking typing: - uv run --group typing --no-dev --isolated mypy - -# Run type checking on notebooks -typing-nb: - uv run --group typing --no-dev --isolated nbqa mypy --ignore-missing-imports . + uv run --group typing --group test ty check src/ tests/ # Run linting lint: diff --git a/pyproject.toml b/pyproject.toml index 7f19dd16..26d8eaea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,6 @@ name = "Tobias Raabe" email = "raabe@posteo.de" [dependency-groups] -dev = ["pygraphviz>=1.12;platform_system=='Linux'"] docs = [ "furo>=2024.8.6", "ipython>=8.13.2", @@ -64,6 +63,7 @@ docs = [ ] plugin-list = ["httpx>=0.27.0", "tabulate[widechars]>=0.9.0", "tqdm>=4.66.3"] test = [ + "cloudpickle>=3.0.0", "deepdiff>=7.0.0", "nbmake>=1.5.5", "pygments>=2.18.0", @@ -72,11 +72,11 @@ test = [ "pytest-cov>=5.0.0", "pytest-xdist>=3.6.1", "syrupy>=4.5.0", - "aiohttp>=3.11.0", # For HTTPPath tests. + "aiohttp>=3.11.0", # For HTTPPath tests. "coiled>=1.42.0", - "cloudpickle>=3.0.0", + "pygraphviz>=1.12;platform_system=='Linux'", ] -typing = ["mypy>=1.11.0", "nbqa>=1.8.5"] +typing = ["ty>=0.0.7"] [project.urls] Changelog = "https://pytask-dev.readthedocs.io/en/stable/changes.html" @@ -167,33 +167,14 @@ filterwarnings = [ "ignore:The --rsyncdir command line argument:DeprecationWarning", ] -[tool.mypy] -files = ["src", "tests"] -check_untyped_defs = true -disallow_any_generics = true -disallow_incomplete_defs = true -disallow_untyped_defs = true -no_implicit_optional = true -warn_redundant_casts = true -warn_unused_ignores = true -disable_error_code = ["import-untyped"] - -[[tool.mypy.overrides]] -module = "tests.*" -disallow_untyped_defs = false -ignore_errors = true - -[[tool.mypy.overrides]] -module = ["click_default_group", "networkx"] -ignore_missing_imports = true - -[[tool.mypy.overrides]] -module = ["_pytask.coiled_utils"] -disable_error_code = ["import-not-found"] - -[[tool.mypy.overrides]] -module = ["_pytask.hookspecs"] -disable_error_code = ["empty-body"] +[tool.ty.rules] +unused-ignore-comment = "error" + +[tool.ty.src] +exclude = ["src/_pytask/_hashlib.py"] + +[tool.ty.terminal] +error-on-warning = true [tool.coverage.report] exclude_also = [ diff --git a/src/_pytask/build.py b/src/_pytask/build.py index ea242b59..83b4c3bd 100644 --- a/src/_pytask/build.py +++ b/src/_pytask/build.py @@ -9,6 +9,7 @@ from typing import TYPE_CHECKING from typing import Any from typing import Literal +from typing import cast import click @@ -65,7 +66,7 @@ def pytask_unconfigure(session: Session) -> None: path.write_text(json.dumps(HashPathCache._cache)) -def build( # noqa: C901, PLR0912, PLR0913 +def build( # noqa: C901, PLR0912, PLR0913, PLR0915 *, capture: Literal["fd", "no", "sys", "tee-sys"] | CaptureMethod = CaptureMethod.FD, check_casing_of_paths: bool = True, @@ -230,10 +231,22 @@ def build( # noqa: C901, PLR0912, PLR0913 raw_config = {**DEFAULTS_FROM_CLI, **raw_config} - raw_config["paths"] = parse_paths(raw_config["paths"]) + paths_value = raw_config["paths"] + # Convert tuple to list since parse_paths expects Path | list[Path] + if isinstance(paths_value, tuple): + paths_value = list(paths_value) + if not isinstance(paths_value, (Path, list)): + msg = f"paths must be Path or list, got {type(paths_value)}" + raise TypeError(msg) # noqa: TRY301 + # Cast is justified - we validated at runtime + raw_config["paths"] = parse_paths(cast("Path | list[Path]", paths_value)) if raw_config["config"] is not None: - raw_config["config"] = Path(raw_config["config"]).resolve() + config_value = raw_config["config"] + if not isinstance(config_value, (str, Path)): + msg = f"config must be str or Path, got {type(config_value)}" + raise TypeError(msg) # noqa: TRY301 + raw_config["config"] = Path(config_value).resolve() raw_config["root"] = raw_config["config"].parent else: ( diff --git a/src/_pytask/cache.py b/src/_pytask/cache.py index 5c122833..517c89b8 100644 --- a/src/_pytask/cache.py +++ b/src/_pytask/cache.py @@ -8,6 +8,9 @@ from inspect import FullArgSpec from typing import TYPE_CHECKING from typing import Any +from typing import ParamSpec +from typing import Protocol +from typing import TypeVar from attrs import define from attrs import field @@ -16,6 +19,20 @@ if TYPE_CHECKING: from collections.abc import Callable + from typing import TypeAlias + + from ty_extensions import Intersection + + Memoized: TypeAlias = "Intersection[Callable[P, R], HasCache]" + +P = ParamSpec("P") +R = TypeVar("R") + + +class HasCache(Protocol): + """Protocol for objects that have a cache attribute.""" + + cache: Cache @define @@ -30,12 +47,14 @@ class Cache: _sentinel: Any = field(factory=object) cache_info: CacheInfo = field(factory=CacheInfo) - def memoize(self, func: Callable[..., Any]) -> Callable[..., Any]: - prefix = f"{func.__module__}.{func.__name__}:" + def memoize(self, func: Callable[P, R]) -> Memoized[P, R]: + func_module = getattr(func, "__module__", "") + func_name = getattr(func, "__name__", "") + prefix = f"{func_module}.{func_name}:" argspec = inspect.getfullargspec(func) @functools.wraps(func) - def wrapped(*args: Any, **kwargs: Any) -> Callable[..., Any]: + def wrapped(*args: P.args, **kwargs: P.kwargs) -> R: key = _make_memoize_key( args, kwargs, typed=False, argspec=argspec, prefix=prefix ) @@ -50,7 +69,7 @@ def wrapped(*args: Any, **kwargs: Any) -> Callable[..., Any]: return value - wrapped.cache = self # type: ignore[attr-defined] + wrapped.cache = self # ty: ignore[unresolved-attribute] return wrapped diff --git a/src/_pytask/capture.py b/src/_pytask/capture.py index 0e2a47fd..c4201b1f 100644 --- a/src/_pytask/capture.py +++ b/src/_pytask/capture.py @@ -129,7 +129,8 @@ def mode(self) -> str: # TextIOWrapper doesn't expose a mode, but at least some of our # tests check it. assert hasattr(self.buffer, "mode") - return cast("str", self.buffer.mode.replace("b", "")) + mode_value = cast("str", self.buffer.mode) + return mode_value.replace("b", "") class CaptureIO(io.TextIOWrapper): @@ -146,7 +147,7 @@ def __init__(self, other: TextIO) -> None: self._other = other super().__init__() - def write(self, s: str) -> int: + def write(self, s: str) -> int: # ty: ignore[invalid-method-override] super().write(s) return self._other.write(s) @@ -209,7 +210,7 @@ def truncate(self, size: int | None = None) -> int: # noqa: ARG002 msg = "Cannot truncate stdin." raise UnsupportedOperation(msg) - def write(self, data: str) -> int: # noqa: ARG002 + def write(self, data: str) -> int: # noqa: ARG002 # ty: ignore[invalid-method-override] msg = "Cannot write to stdin." raise UnsupportedOperation(msg) diff --git a/src/_pytask/click.py b/src/_pytask/click.py index 215a8c05..36f2fa04 100644 --- a/src/_pytask/click.py +++ b/src/_pytask/click.py @@ -37,12 +37,10 @@ if importlib.metadata.version("click") < "8.2": from click.parser import split_opt else: - from click.parser import ( # type: ignore[attr-defined, no-redef, unused-ignore] - _split_opt as split_opt, - ) + from click.parser import _split_opt as split_opt # ty: ignore[unresolved-import] -class EnumChoice(Choice): # type: ignore[type-arg, unused-ignore] +class EnumChoice(Choice): """An enum-based choice type. The implementation is copied from https://github.com/pallets/click/pull/2210 and @@ -75,7 +73,7 @@ def convert(self, value: Any, param: Parameter | None, ctx: Context | None) -> A class _OptionHighlighter(RegexHighlighter): """A highlighter for help texts.""" - highlights: ClassVar = [ # type: ignore[misc] + highlights: ClassVar = [ r"(?P\-\w)\b", r"(?P