Skip to content

Commit 6341071

Browse files
authored
Move to ty. (#734)
1 parent a2ffbf6 commit 6341071

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

59 files changed

+468
-407
lines changed

.github/workflows/main.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,11 @@ jobs:
2828
enable-cache: true
2929
- name: Install just
3030
uses: extractions/setup-just@v3
31+
- name: Install graphviz
32+
run: |
33+
sudo apt-get update
34+
sudo apt-get install graphviz graphviz-dev
3135
- run: just typing
32-
- run: just typing-nb
3336

3437
run-tests:
3538

CHANGELOG.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@ releases are available on [PyPI](https://pypi.org/project/pytask) and
99

1010
- {pull}`725` fixes the pickle node hash test by accounting for Python 3.14's
1111
default pickle protocol.
12-
- {pull}`???` adapts the interactive debugger integration to Python 3.14's
12+
- {pull}`726` adapts the interactive debugger integration to Python 3.14's
1313
updated `pdb` behaviour and keeps pytest-style capturing intact.
14-
- {pull}`???` updates the comparison to other tools documentation and adds a section on
14+
- {pull}`734` migrates from mypy to ty for type checking.
15+
- {pull}`736` updates the comparison to other tools documentation and adds a section on
1516
the Common Workflow Language (CWL) and WorkflowHub.
1617

1718
## 0.5.7 - 2025-11-22

justfile

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,9 @@ test *FLAGS:
1010
test-cov *FLAGS:
1111
uv run --group test pytest --nbmake --cov=src --cov=tests --cov-report=xml -n auto {{FLAGS}}
1212

13-
# Run tests with notebook validation
14-
test-nb:
15-
uv run --group test pytest --nbmake -n auto
16-
1713
# Run type checking
1814
typing:
19-
uv run --group typing --no-dev --isolated mypy
20-
21-
# Run type checking on notebooks
22-
typing-nb:
23-
uv run --group typing --no-dev --isolated nbqa mypy --ignore-missing-imports .
15+
uv run --group typing --group test ty check src/ tests/
2416

2517
# Run linting
2618
lint:

pyproject.toml

Lines changed: 12 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,6 @@ name = "Tobias Raabe"
4646
4747

4848
[dependency-groups]
49-
dev = ["pygraphviz>=1.12;platform_system=='Linux'"]
5049
docs = [
5150
"furo>=2024.8.6",
5251
"ipython>=8.13.2",
@@ -64,6 +63,7 @@ docs = [
6463
]
6564
plugin-list = ["httpx>=0.27.0", "tabulate[widechars]>=0.9.0", "tqdm>=4.66.3"]
6665
test = [
66+
"cloudpickle>=3.0.0",
6767
"deepdiff>=7.0.0",
6868
"nbmake>=1.5.5",
6969
"pygments>=2.18.0",
@@ -72,11 +72,11 @@ test = [
7272
"pytest-cov>=5.0.0",
7373
"pytest-xdist>=3.6.1",
7474
"syrupy>=4.5.0",
75-
"aiohttp>=3.11.0", # For HTTPPath tests.
75+
"aiohttp>=3.11.0", # For HTTPPath tests.
7676
"coiled>=1.42.0",
77-
"cloudpickle>=3.0.0",
77+
"pygraphviz>=1.12;platform_system=='Linux'",
7878
]
79-
typing = ["mypy>=1.11.0", "nbqa>=1.8.5"]
79+
typing = ["ty>=0.0.7"]
8080

8181
[project.urls]
8282
Changelog = "https://pytask-dev.readthedocs.io/en/stable/changes.html"
@@ -167,33 +167,14 @@ filterwarnings = [
167167
"ignore:The --rsyncdir command line argument:DeprecationWarning",
168168
]
169169

170-
[tool.mypy]
171-
files = ["src", "tests"]
172-
check_untyped_defs = true
173-
disallow_any_generics = true
174-
disallow_incomplete_defs = true
175-
disallow_untyped_defs = true
176-
no_implicit_optional = true
177-
warn_redundant_casts = true
178-
warn_unused_ignores = true
179-
disable_error_code = ["import-untyped"]
180-
181-
[[tool.mypy.overrides]]
182-
module = "tests.*"
183-
disallow_untyped_defs = false
184-
ignore_errors = true
185-
186-
[[tool.mypy.overrides]]
187-
module = ["click_default_group", "networkx"]
188-
ignore_missing_imports = true
189-
190-
[[tool.mypy.overrides]]
191-
module = ["_pytask.coiled_utils"]
192-
disable_error_code = ["import-not-found"]
193-
194-
[[tool.mypy.overrides]]
195-
module = ["_pytask.hookspecs"]
196-
disable_error_code = ["empty-body"]
170+
[tool.ty.rules]
171+
unused-ignore-comment = "error"
172+
173+
[tool.ty.src]
174+
exclude = ["src/_pytask/_hashlib.py"]
175+
176+
[tool.ty.terminal]
177+
error-on-warning = true
197178

198179
[tool.coverage.report]
199180
exclude_also = [

src/_pytask/build.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from typing import TYPE_CHECKING
1010
from typing import Any
1111
from typing import Literal
12+
from typing import cast
1213

1314
import click
1415

@@ -65,7 +66,7 @@ def pytask_unconfigure(session: Session) -> None:
6566
path.write_text(json.dumps(HashPathCache._cache))
6667

6768

68-
def build( # noqa: C901, PLR0912, PLR0913
69+
def build( # noqa: C901, PLR0912, PLR0913, PLR0915
6970
*,
7071
capture: Literal["fd", "no", "sys", "tee-sys"] | CaptureMethod = CaptureMethod.FD,
7172
check_casing_of_paths: bool = True,
@@ -230,10 +231,22 @@ def build( # noqa: C901, PLR0912, PLR0913
230231

231232
raw_config = {**DEFAULTS_FROM_CLI, **raw_config}
232233

233-
raw_config["paths"] = parse_paths(raw_config["paths"])
234+
paths_value = raw_config["paths"]
235+
# Convert tuple to list since parse_paths expects Path | list[Path]
236+
if isinstance(paths_value, tuple):
237+
paths_value = list(paths_value)
238+
if not isinstance(paths_value, (Path, list)):
239+
msg = f"paths must be Path or list, got {type(paths_value)}"
240+
raise TypeError(msg) # noqa: TRY301
241+
# Cast is justified - we validated at runtime
242+
raw_config["paths"] = parse_paths(cast("Path | list[Path]", paths_value))
234243

235244
if raw_config["config"] is not None:
236-
raw_config["config"] = Path(raw_config["config"]).resolve()
245+
config_value = raw_config["config"]
246+
if not isinstance(config_value, (str, Path)):
247+
msg = f"config must be str or Path, got {type(config_value)}"
248+
raise TypeError(msg) # noqa: TRY301
249+
raw_config["config"] = Path(config_value).resolve()
237250
raw_config["root"] = raw_config["config"].parent
238251
else:
239252
(

src/_pytask/cache.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
from inspect import FullArgSpec
99
from typing import TYPE_CHECKING
1010
from typing import Any
11+
from typing import ParamSpec
12+
from typing import Protocol
13+
from typing import TypeVar
1114

1215
from attrs import define
1316
from attrs import field
@@ -16,6 +19,20 @@
1619

1720
if TYPE_CHECKING:
1821
from collections.abc import Callable
22+
from typing import TypeAlias
23+
24+
from ty_extensions import Intersection
25+
26+
Memoized: TypeAlias = "Intersection[Callable[P, R], HasCache]"
27+
28+
P = ParamSpec("P")
29+
R = TypeVar("R")
30+
31+
32+
class HasCache(Protocol):
33+
"""Protocol for objects that have a cache attribute."""
34+
35+
cache: Cache
1936

2037

2138
@define
@@ -30,12 +47,14 @@ class Cache:
3047
_sentinel: Any = field(factory=object)
3148
cache_info: CacheInfo = field(factory=CacheInfo)
3249

33-
def memoize(self, func: Callable[..., Any]) -> Callable[..., Any]:
34-
prefix = f"{func.__module__}.{func.__name__}:"
50+
def memoize(self, func: Callable[P, R]) -> Memoized[P, R]:
51+
func_module = getattr(func, "__module__", "")
52+
func_name = getattr(func, "__name__", "")
53+
prefix = f"{func_module}.{func_name}:"
3554
argspec = inspect.getfullargspec(func)
3655

3756
@functools.wraps(func)
38-
def wrapped(*args: Any, **kwargs: Any) -> Callable[..., Any]:
57+
def wrapped(*args: P.args, **kwargs: P.kwargs) -> R:
3958
key = _make_memoize_key(
4059
args, kwargs, typed=False, argspec=argspec, prefix=prefix
4160
)
@@ -50,7 +69,7 @@ def wrapped(*args: Any, **kwargs: Any) -> Callable[..., Any]:
5069

5170
return value
5271

53-
wrapped.cache = self # type: ignore[attr-defined]
72+
wrapped.cache = self # ty: ignore[unresolved-attribute]
5473

5574
return wrapped
5675

src/_pytask/capture.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,8 @@ def mode(self) -> str:
129129
# TextIOWrapper doesn't expose a mode, but at least some of our
130130
# tests check it.
131131
assert hasattr(self.buffer, "mode")
132-
return cast("str", self.buffer.mode.replace("b", ""))
132+
mode_value = cast("str", self.buffer.mode)
133+
return mode_value.replace("b", "")
133134

134135

135136
class CaptureIO(io.TextIOWrapper):
@@ -146,7 +147,7 @@ def __init__(self, other: TextIO) -> None:
146147
self._other = other
147148
super().__init__()
148149

149-
def write(self, s: str) -> int:
150+
def write(self, s: str) -> int: # ty: ignore[invalid-method-override]
150151
super().write(s)
151152
return self._other.write(s)
152153

@@ -209,7 +210,7 @@ def truncate(self, size: int | None = None) -> int: # noqa: ARG002
209210
msg = "Cannot truncate stdin."
210211
raise UnsupportedOperation(msg)
211212

212-
def write(self, data: str) -> int: # noqa: ARG002
213+
def write(self, data: str) -> int: # noqa: ARG002 # ty: ignore[invalid-method-override]
213214
msg = "Cannot write to stdin."
214215
raise UnsupportedOperation(msg)
215216

src/_pytask/click.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,10 @@
3737
if importlib.metadata.version("click") < "8.2":
3838
from click.parser import split_opt
3939
else:
40-
from click.parser import ( # type: ignore[attr-defined, no-redef, unused-ignore]
41-
_split_opt as split_opt,
42-
)
40+
from click.parser import _split_opt as split_opt # ty: ignore[unresolved-import]
4341

4442

45-
class EnumChoice(Choice): # type: ignore[type-arg, unused-ignore]
43+
class EnumChoice(Choice):
4644
"""An enum-based choice type.
4745
4846
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
7573
class _OptionHighlighter(RegexHighlighter):
7674
"""A highlighter for help texts."""
7775

78-
highlights: ClassVar = [ # type: ignore[misc]
76+
highlights: ClassVar = [
7977
r"(?P<switch>\-\w)\b",
8078
r"(?P<option>\-\-[\w\-]+)",
8179
r"\-\-[\w\-]+(?P<metavar>[ |=][\w\.:]+)",
@@ -114,7 +112,7 @@ def format_help(
114112
else:
115113
formatted_name = Text(command_name, style="command")
116114

117-
commands_table.add_row(formatted_name, highlighter(command.help))
115+
commands_table.add_row(formatted_name, highlighter(command.help or ""))
118116

119117
console.print(
120118
Panel(
@@ -177,12 +175,13 @@ def parse_args(self, ctx: Context, args: list[str]) -> list[str]:
177175
_value, args = param.handle_parse_result(ctx, opts, args)
178176

179177
if args and not ctx.allow_extra_args and not ctx.resilient_parsing:
178+
args_list = list(args) if not isinstance(args, list) else args
180179
ctx.fail(
181180
ngettext(
182181
"Got unexpected extra argument ({args})",
183182
"Got unexpected extra arguments ({args})",
184183
len(args),
185-
).format(args=" ".join(map(str, args)))
184+
).format(args=" ".join(str(arg) for arg in args_list))
186185
)
187186

188187
ctx.args = args
@@ -328,7 +327,7 @@ def _format_help_text( # noqa: C901, PLR0912, PLR0915
328327
elif param.is_bool_flag and param.secondary_opts: # type: ignore[attr-defined]
329328
# For boolean flags that have distinct True/False opts,
330329
# use the opt without prefix instead of the value.
331-
default_string = split_opt( # type: ignore[operator, unused-ignore]
330+
default_string = split_opt(
332331
(param.opts if param.default else param.secondary_opts)[0]
333332
)[1]
334333
elif (

src/_pytask/coiled_utils.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
except ImportError:
1414

1515
@define
16-
class Function: # type: ignore[no-redef]
16+
class Function:
1717
cluster_kwargs: dict[str, Any]
1818
environ: dict[str, Any]
1919
function: Callable[..., Any] | None
@@ -26,9 +26,9 @@ class Function: # type: ignore[no-redef]
2626
def extract_coiled_function_kwargs(func: Function) -> dict[str, Any]:
2727
"""Extract the kwargs for a coiled function."""
2828
return {
29-
"cluster_kwargs": func._cluster_kwargs,
29+
"cluster_kwargs": func._cluster_kwargs, # ty: ignore[possibly-missing-attribute]
3030
"keepalive": func.keepalive,
31-
"environ": func._environ,
32-
"local": func._local,
33-
"name": func._name,
31+
"environ": func._environ, # ty: ignore[possibly-missing-attribute]
32+
"local": func._local, # ty: ignore[possibly-missing-attribute]
33+
"name": func._name, # ty: ignore[possibly-missing-attribute]
3434
}

src/_pytask/collect.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
from _pytask.task_utils import COLLECTED_TASKS
5353
from _pytask.task_utils import parse_collected_tasks_with_task_marker
5454
from _pytask.task_utils import task as task_decorator
55+
from _pytask.typing import TaskFunction
5556
from _pytask.typing import is_task_function
5657

5758
if TYPE_CHECKING:
@@ -115,7 +116,7 @@ def _collect_from_tasks(session: Session) -> None:
115116

116117
for raw_task in to_list(session.config.get("tasks", ())):
117118
if is_task_function(raw_task):
118-
if not hasattr(raw_task, "pytask_meta"):
119+
if not isinstance(raw_task, TaskFunction):
119120
raw_task = task_decorator()(raw_task) # noqa: PLW2901
120121

121122
path = get_file(raw_task)
@@ -339,7 +340,7 @@ def pytask_collect_task(
339340

340341
markers = get_all_marks(obj)
341342

342-
if hasattr(obj, "pytask_meta"):
343+
if isinstance(obj, TaskFunction):
343344
attributes = {
344345
**obj.pytask_meta.attributes,
345346
"collection_id": obj.pytask_meta._id,

0 commit comments

Comments
 (0)