diff --git a/pyproject.toml b/pyproject.toml index f353ea9c..2075eb65 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,7 @@ docs = [ docs-live = ["sphinx-autobuild>=2024.10.3"] plugin-list = ["httpx>=0.27.0", "tabulate[widechars]>=0.9.0"] test = [ + { include-group = "typing" }, "cloudpickle>=3.0.0", "deepdiff>=7.0.0", # nbmake requires pywin32 on Windows, which has no wheels for Python 3.14 yet @@ -76,8 +77,11 @@ test = [ "coiled>=1.42.0; python_version < '3.14'", "networkx>=2.4.0", "pygraphviz>=1.12;platform_system=='Linux'", + "pytest-typing>=26.3.0", +] +typing = [ + "ty>=0.0.84", ] -typing = ["ty>=0.0.8"] [project.urls] Changelog = "https://pytask-dev.readthedocs.io/en/stable/changes.html" @@ -159,6 +163,7 @@ force-single-line = true convention = "numpy" [tool.pytest.ini_options] +typing_checkers = ["ty"] testpaths = ["src", "tests"] filterwarnings = [ "ignore:'@pytask.mark.*. is deprecated:FutureWarning", diff --git a/src/_pytask/execute.py b/src/_pytask/execute.py index 266e153b..0d2120d8 100644 --- a/src/_pytask/execute.py +++ b/src/_pytask/execute.py @@ -285,7 +285,10 @@ def pytask_execute_task(session: Session, task: PTask) -> bool: ) raise ValueError(msg) - nodes = tree_leaves(task.produces["return"]) + # Recursive tree inference can retain containers in the inferred leaf type. + nodes = cast( + "list[PNode | PProvisionalNode]", tree_leaves(task.produces["return"]) + ) values = structure_return.flatten_up_to(out) for node, value in zip(nodes, values, strict=False): if not isinstance(node, PProvisionalNode): @@ -301,9 +304,9 @@ def pytask_execute_task_teardown(session: Session, task: PTask) -> None: return collect_provisional_products(session, task) - missing_nodes: list[Any] = [ - node for node in tree_leaves(task.produces) if not node.state() - ] + # Provisional products have been replaced with concrete nodes at this point. + nodes = cast("list[PNode]", tree_leaves(task.produces)) + missing_nodes: list[Any] = [node for node in nodes if not node.state()] if missing_nodes: paths = session.config["paths"] files = [format_node_name(i, paths).plain for i in missing_nodes] diff --git a/src/_pytask/tree_util.py b/src/_pytask/tree_util.py index d5ee0d7e..df0713d0 100644 --- a/src/_pytask/tree_util.py +++ b/src/_pytask/tree_util.py @@ -28,8 +28,11 @@ _T = TypeVar("_T") _U = TypeVar("_U") _K = TypeVar("_K") +_NodeT = TypeVar("_NodeT", bound="TaskNode") if TYPE_CHECKING: + from _pytask.node_protocols import TaskNode + # Use our own recursive type alias for static type checking. # optree's PyTree uses __class_getitem__ to generate Union types at runtime, # but type checkers like ty cannot evaluate these dynamic types properly. @@ -46,6 +49,48 @@ _pytree = optree.pytree.reexport(namespace="pytask") +# Custom leaf predicates can make containers into leaves, so the node overloads +# apply only when the default traversal is used. +@overload +def tree_leaves( + tree: _NodeT, + /, + is_leaf: None = None, +) -> list[_NodeT]: ... + + +@overload +def tree_leaves( + tree: list[_NodeT], + /, + is_leaf: None = None, +) -> list[_NodeT]: ... + + +@overload +def tree_leaves( + tree: tuple[_NodeT, ...], + /, + is_leaf: None = None, +) -> list[_NodeT]: ... + + +@overload +def tree_leaves( + tree: dict[_K, _NodeT], + /, + is_leaf: None = None, +) -> list[_NodeT]: ... + + +@overload +def tree_leaves( + tree: PyTree[_T], + /, + is_leaf: Callable[[Any], bool] | None = None, +) -> list[_T]: ... + + def tree_leaves( tree: PyTree[_T], /, diff --git a/tests/test_typing_tree_util.md b/tests/test_typing_tree_util.md new file mode 100644 index 00000000..b039c1ac --- /dev/null +++ b/tests/test_typing_tree_util.md @@ -0,0 +1,487 @@ +# tree_leaves + +Flattening a tree should preserve the type of its node leaves. + +Checker-specific error comments record current diagnostics for valid node use cases. +Intended types are documented beside the assertions of each checker's current output. +These snapshots use ty 0.0.84, Pyright 1.1.414, Pyrefly 1.3.2, and mypy 2.4.0. +Changes to the revealed output fail the tests and should be reviewed against the +intended types. Mypy's qualified node names are preserved exactly. + +## Concrete nodes in different containers + +Related issues: + +- [ty #2870](https://github.com/astral-sh/ty/issues/2870) + +Intended types: + +- `reveal_type(tree_leaves(path_node))`: `list[PathNode]`. +- `reveal_type(tree_leaves([path_node]))`: `list[PathNode]`. +- `reveal_type(tree_leaves((path_node,)))`: `list[PathNode]`. +- `reveal_type(tree_leaves({"input": path_node}))`: `list[PathNode]`. +- `reveal_type(tree_leaves(python_node))`: `list[PythonNode]`. +- `reveal_type(tree_leaves([python_node]))`: `list[PythonNode]`. +- `reveal_type(tree_leaves((python_node,)))`: `list[PythonNode]`. +- `reveal_type(tree_leaves({"input": python_node}))`: `list[PythonNode]`. + +Current reveals (ty, pyright, pyrefly): + +```python only=ty,pyright,pyrefly +from pathlib import Path +from typing_extensions import reveal_type + +from pytask import PathNode, PythonNode +from pytask.tree_util import tree_leaves + +path_node = PathNode(path=Path("input.txt")) +python_node = PythonNode(value="input") + +# revealed: list[PathNode] +reveal_type(tree_leaves(path_node)) +# revealed: list[PathNode] +reveal_type(tree_leaves([path_node])) +# revealed: list[PathNode] +reveal_type(tree_leaves((path_node,))) +# revealed: list[PathNode] +reveal_type(tree_leaves({"input": path_node})) + +# revealed: list[PythonNode] +reveal_type(tree_leaves(python_node)) +# revealed: list[PythonNode] +reveal_type(tree_leaves([python_node])) +# revealed: list[PythonNode] +reveal_type(tree_leaves((python_node,))) +# revealed: list[PythonNode] +reveal_type(tree_leaves({"input": python_node})) +``` + +Current reveals (mypy): + +```python only=mypy +from pathlib import Path +from typing_extensions import reveal_type + +from pytask import PathNode, PythonNode +from pytask.tree_util import tree_leaves + +path_node = PathNode(path=Path("input.txt")) +python_node = PythonNode(value="input") + +# revealed: list[_pytask.nodes.PathNode] +reveal_type(tree_leaves(path_node)) +# revealed: list[_pytask.nodes.PathNode] +reveal_type(tree_leaves([path_node])) +# revealed: list[_pytask.nodes.PathNode] +reveal_type(tree_leaves((path_node,))) +# revealed: list[_pytask.nodes.PathNode] +reveal_type(tree_leaves({"input": path_node})) + +# revealed: list[_pytask.nodes.PythonNode] +reveal_type(tree_leaves(python_node)) +# revealed: list[_pytask.nodes.PythonNode] +reveal_type(tree_leaves([python_node])) +# revealed: list[_pytask.nodes.PythonNode] +reveal_type(tree_leaves((python_node,))) +# revealed: list[_pytask.nodes.PythonNode] +reveal_type(tree_leaves({"input": python_node})) +``` + + +## Flat containers of concrete and provisional node protocols + +Intended types: + +- `reveal_type(tree_leaves(node))`: `list[PProvisionalNode]`. +- `reveal_type(tree_leaves(nodes))`: `list[PNode | PProvisionalNode]`. +- `reveal_type(tree_leaves(tuple_nodes))`: `list[PNode | PProvisionalNode]`. +- `reveal_type(tree_leaves(named_nodes))`: `list[PNode | PProvisionalNode]`. + +Current reveals (ty, pyright, pyrefly): + +```python only=ty,pyright,pyrefly +from typing_extensions import reveal_type + +from pytask import PNode, PProvisionalNode +from pytask.tree_util import tree_leaves + + +def check_flat_nodes( + node: PProvisionalNode, + nodes: list[PNode | PProvisionalNode], + tuple_nodes: tuple[PNode | PProvisionalNode, ...], + named_nodes: dict[str, PNode | PProvisionalNode], +) -> None: + # revealed: list[PProvisionalNode] + reveal_type(tree_leaves(node)) + # revealed: list[PNode | PProvisionalNode] + reveal_type(tree_leaves(nodes)) + # revealed: list[PNode | PProvisionalNode] + reveal_type(tree_leaves(tuple_nodes)) + # revealed: list[PNode | PProvisionalNode] + reveal_type(tree_leaves(named_nodes)) +``` + +Current reveals (mypy): + +```python only=mypy +from typing_extensions import reveal_type + +from pytask import PNode, PProvisionalNode +from pytask.tree_util import tree_leaves + + +def check_flat_nodes( + node: PProvisionalNode, + nodes: list[PNode | PProvisionalNode], + tuple_nodes: tuple[PNode | PProvisionalNode, ...], + named_nodes: dict[str, PNode | PProvisionalNode], +) -> None: + # revealed: list[_pytask.node_protocols.PProvisionalNode] + reveal_type(tree_leaves(node)) + # revealed: list[_pytask.node_protocols.PNode | _pytask.node_protocols.PProvisionalNode] + reveal_type(tree_leaves(nodes)) + # revealed: list[_pytask.node_protocols.PNode | _pytask.node_protocols.PProvisionalNode] + reveal_type(tree_leaves(tuple_nodes)) + # revealed: list[_pytask.node_protocols.PNode | _pytask.node_protocols.PProvisionalNode] + reveal_type(tree_leaves(named_nodes)) +``` + + +## Custom leaf predicates retain the general traversal contract + +A predicate can treat the entire list as one leaf. Node-container overloads must +not promise individual nodes in that case. + +Intended types: + +- `reveal_type(tree_leaves(nodes, is_leaf=None))`: `list[PythonNode]`. +- `reveal_type(tree_leaves(nodes, is_leaf=lambda value: isinstance(value, list)))`: `list[list[PythonNode]]`. + +Current reveals (ty, pyright, pyrefly): + +```python only=ty,pyright,pyrefly +from typing_extensions import reveal_type + +from pytask import PythonNode +from pytask.tree_util import tree_leaves + +nodes = [PythonNode(value="input")] +# revealed: list[PythonNode] +reveal_type(tree_leaves(nodes, is_leaf=None)) +# revealed: list[list[PythonNode]] +reveal_type(tree_leaves(nodes, is_leaf=lambda value: isinstance(value, list))) +``` + +Current reveals (mypy): + +```python only=mypy +from typing_extensions import reveal_type + +from pytask import PythonNode +from pytask.tree_util import tree_leaves + +nodes = [PythonNode(value="input")] +# revealed: list[_pytask.nodes.PythonNode] +reveal_type(tree_leaves(nodes, is_leaf=None)) +# revealed: list[list[_pytask.nodes.PythonNode]] +reveal_type(tree_leaves(nodes, is_leaf=lambda value: isinstance(value, list))) +``` + + +## Nested containers of concrete nodes + +Related issues: + +- [ty #2870](https://github.com/astral-sh/ty/issues/2870) +- [Pyright #9798](https://github.com/microsoft/pyright/issues/9798) + +Intended types: + +- `reveal_type(tree_leaves(paths))`: `list[PathNode]`. +- `reveal_type(tree_leaves(objects))`: `list[PythonNode]`. + +Current reveals (ty, pyright, pyrefly): + +```python only=ty,pyright,pyrefly +from pathlib import Path +from typing_extensions import reveal_type + +from pytask import PathNode, PythonNode +from pytask.tree_util import tree_leaves + +paths: dict[str, list[tuple[PathNode, ...]]] = { + "inputs": [(PathNode(path=Path("input.txt")),)], +} +objects: list[dict[str, tuple[PythonNode, ...]]] = [ + {"inputs": (PythonNode(value="input"),)}, +] + +# revealed: list[dict[str, list[tuple[PathNode, ...]]]] +reveal_type(tree_leaves(paths)) +# revealed: list[list[dict[str, tuple[PythonNode, ...]]]] +reveal_type(tree_leaves(objects)) +``` + +Current reveals (mypy): + +```python only=mypy +from pathlib import Path +from typing_extensions import reveal_type + +from pytask import PathNode, PythonNode +from pytask.tree_util import tree_leaves + +paths: dict[str, list[tuple[PathNode, ...]]] = { + "inputs": [(PathNode(path=Path("input.txt")),)], +} +objects: list[dict[str, tuple[PythonNode, ...]]] = [ + {"inputs": (PythonNode(value="input"),)}, +] + +# revealed: list[dict[str, list[tuple[_pytask.nodes.PathNode, ...]]]] +reveal_type(tree_leaves(paths)) +# revealed: list[list[dict[str, tuple[_pytask.nodes.PythonNode, ...]]]] +reveal_type(tree_leaves(objects)) +``` + + +## Mixed concrete node types + +Related issues: + +- [ty #2870](https://github.com/astral-sh/ty/issues/2870) +- [mypy #6751](https://github.com/python/mypy/issues/6751) + +Intended types: + +- `reveal_type(tree_leaves(nodes))`: `list[PathNode | PythonNode]`. + +Current reveals (ty, pyright, pyrefly): + +```python only=ty,pyright,pyrefly +from pathlib import Path +from typing_extensions import reveal_type + +from pytask import PathNode, PythonNode +from pytask.tree_util import tree_leaves + +nodes: list[PathNode | PythonNode] = [ + PathNode(path=Path("input.txt")), + PythonNode(value="input"), +] + +# revealed: list[PathNode | PythonNode] +reveal_type(tree_leaves(nodes)) +``` + +Current reveals (mypy): + +```python only=mypy +from pathlib import Path +from typing_extensions import reveal_type + +from pytask import PathNode, PythonNode +from pytask.tree_util import tree_leaves + +nodes: list[PathNode | PythonNode] = [ + PathNode(path=Path("input.txt")), + PythonNode(value="input"), +] + +# revealed: list[_pytask.nodes.PathNode | _pytask.nodes.PythonNode] +reveal_type(tree_leaves(nodes)) +``` + + +## Trees annotated with the node protocol + +Related issues: + +- [ty #2870](https://github.com/astral-sh/ty/issues/2870) + +Intended types: + +- `reveal_type(leaves)`: `list[PNode]`. +- `reveal_type(node.signature)`: `str`. +- `reveal_type(node.state())`: `str | None`. + +Current reveals (ty): + +```python only=ty +from typing_extensions import reveal_type + +from pytask import PNode +from pytask.tree_util import PyTree, tree_leaves + + +def check_node_tree(tree: PyTree[PNode]) -> None: + leaves = tree_leaves(tree) + # revealed: list[PNode | tuple[PyTree[PNode], ...] | list[PyTree[PNode]] | dict[Any, PyTree[PNode]]] + reveal_type(leaves) + for node in leaves: + # ty-error: [unresolved-attribute] + # revealed: str + reveal_type(node.signature) + # ty-error: [unresolved-attribute] + # revealed: str | None + reveal_type(node.state()) + # ty-error: [unresolved-attribute] + node.load() +``` + +Current reveals (pyright, pyrefly): + +```python only=pyright,pyrefly +from typing_extensions import reveal_type + +from pytask import PNode +from pytask.tree_util import PyTree, tree_leaves + + +def check_node_tree(tree: PyTree[PNode]) -> None: + leaves = tree_leaves(tree) + # revealed: list[PNode] + reveal_type(leaves) + for node in leaves: + # ty-error: [unresolved-attribute] + # revealed: str + reveal_type(node.signature) + # ty-error: [unresolved-attribute] + # revealed: str | None + reveal_type(node.state()) + # ty-error: [unresolved-attribute] + node.load() +``` + +Current reveals (mypy): + +```python only=mypy +from typing_extensions import reveal_type + +from pytask import PNode +from pytask.tree_util import PyTree, tree_leaves + + +def check_node_tree(tree: PyTree[PNode]) -> None: + leaves = tree_leaves(tree) + # revealed: list[_pytask.node_protocols.PNode] + reveal_type(leaves) + for node in leaves: + # ty-error: [unresolved-attribute] + # revealed: str + reveal_type(node.signature) + # ty-error: [unresolved-attribute] + # revealed: str | None + reveal_type(node.state()) + # ty-error: [unresolved-attribute] + node.load() +``` + + +## Task dependencies and products include provisional nodes + +Plugins receive `PTask`, whose argument dictionaries contain concrete or provisional +nodes. This is a broader contract than `PyTree[PNode]`. + +Related issues: + +- [ty #2870](https://github.com/astral-sh/ty/issues/2870) +- [mypy #6751](https://github.com/python/mypy/issues/6751) +- [mypy #22063](https://github.com/python/mypy/issues/22063) + +Intended types: + +- `reveal_type(tree_leaves(task.depends_on))`: `list[PNode | PProvisionalNode]`. +- `reveal_type(tree_leaves(task.produces))`: `list[PNode | PProvisionalNode]`. + +Current reveals (ty): + +```python only=ty +from typing_extensions import reveal_type + +from pytask import PTask +from pytask.tree_util import tree_leaves + + +def check_task_nodes(task: PTask) -> None: + # mypy-error: [type-var] + # revealed: list[dict[str, PNode | PProvisionalNode | tuple[PyTree[PNode | PProvisionalNode], ...] | list[PyTree[PNode | PProvisionalNode]] | dict[Any, PyTree[PNode | PProvisionalNode]]] | PNode | PProvisionalNode | tuple[PyTree[PNode | PProvisionalNode], ...] | list[PyTree[PNode | PProvisionalNode]] | dict[Any, PyTree[PNode | PProvisionalNode]]] + reveal_type(tree_leaves(task.depends_on)) + # mypy-error: [type-var] + # revealed: list[dict[str, PNode | PProvisionalNode | tuple[PyTree[PNode | PProvisionalNode], ...] | list[PyTree[PNode | PProvisionalNode]] | dict[Any, PyTree[PNode | PProvisionalNode]]] | PNode | PProvisionalNode | tuple[PyTree[PNode | PProvisionalNode], ...] | list[PyTree[PNode | PProvisionalNode]] | dict[Any, PyTree[PNode | PProvisionalNode]]] + reveal_type(tree_leaves(task.produces)) +``` + +Current reveals (pyright, pyrefly): + +```python only=pyright,pyrefly +from typing_extensions import reveal_type + +from pytask import PTask +from pytask.tree_util import tree_leaves + + +def check_task_nodes(task: PTask) -> None: + # mypy-error: [type-var] + # revealed: list[PNode | PProvisionalNode] + reveal_type(tree_leaves(task.depends_on)) + # mypy-error: [type-var] + # revealed: list[PNode | PProvisionalNode] + reveal_type(tree_leaves(task.produces)) +``` + +Current reveals (mypy): + +```python only=mypy +from typing_extensions import reveal_type + +from pytask import PTask +from pytask.tree_util import tree_leaves + + +def check_task_nodes(task: PTask) -> None: + # mypy-error: [type-var] + # revealed: list[_pytask.node_protocols.PNode | _pytask.node_protocols.PProvisionalNode | tuple[_pytask.node_protocols.PNode | _pytask.node_protocols.PProvisionalNode | tuple[..., ...] | list[...] | dict[Any, ...], ...] | list[_pytask.node_protocols.PNode | _pytask.node_protocols.PProvisionalNode | tuple[..., ...] | list[...] | dict[Any, ...]] | dict[Any, _pytask.node_protocols.PNode | _pytask.node_protocols.PProvisionalNode | tuple[..., ...] | list[...] | dict[Any, ...]]] + reveal_type(tree_leaves(task.depends_on)) + # mypy-error: [type-var] + # revealed: list[_pytask.node_protocols.PNode | _pytask.node_protocols.PProvisionalNode | tuple[_pytask.node_protocols.PNode | _pytask.node_protocols.PProvisionalNode | tuple[..., ...] | list[...] | dict[Any, ...], ...] | list[_pytask.node_protocols.PNode | _pytask.node_protocols.PProvisionalNode | tuple[..., ...] | list[...] | dict[Any, ...]] | dict[Any, _pytask.node_protocols.PNode | _pytask.node_protocols.PProvisionalNode | tuple[..., ...] | list[...] | dict[Any, ...]]] + reveal_type(tree_leaves(task.produces)) +``` + + +## Discover paths in task nodes + +Based on `pytask-latex`'s dependency and product discovery. Narrowing a flattened leaf +to `PPathNode` must allow access to its path without casts or ignores. + +Related issues: + +- [mypy #6751](https://github.com/python/mypy/issues/6751) +- [mypy #22063](https://github.com/python/mypy/issues/22063) + +Intended types: + +- `reveal_type(node.path.as_posix())`: `str`. + +```python +from typing_extensions import reveal_type + +from pytask import PPathNode, PTask +from pytask.tree_util import tree_leaves + + +def check_task_paths(task: PTask) -> set[str]: + paths: set[str] = set() + # mypy-error: [type-var] + for node in tree_leaves(task.depends_on): + if isinstance(node, PPathNode): + # revealed: str + reveal_type(node.path.as_posix()) + paths.add(node.path.as_posix()) + # mypy-error: [type-var] + for node in tree_leaves(task.produces): + if isinstance(node, PPathNode): + paths.add(node.path.as_posix()) + return paths +``` diff --git a/tests/test_typing_tree_util_plugins.md b/tests/test_typing_tree_util_plugins.md new file mode 100644 index 00000000..10b9743c --- /dev/null +++ b/tests/test_typing_tree_util_plugins.md @@ -0,0 +1,298 @@ +# Tree mapping used by plugins + +These snippets reproduce plugin input annotations, callback signatures, and uses of +the results without depending on the plugins. Assignments and return annotations check +recursive result types; reveals check that the outer task argument keys remain strings. +The snippets are static typing tests, not plugin integration tests. + +## Load task argument trees with paths + +Based on `pytask-parallel`'s `create_kwargs_for_task`. Dependency and product leaves +can be concrete or provisional nodes, and loading them returns arbitrary task values. + +Related issues: + +- [ty #3415](https://github.com/astral-sh/ty/issues/3415) +- [mypy #22063](https://github.com/python/mypy/issues/22063) + +```python +from typing import Any + +from pytask import PNode, PProvisionalNode, PTask +from pytask.tree_util import PyTree, tree_map_with_path + + +def load_node(path: tuple[Any, ...], node: PNode | PProvisionalNode) -> Any: + return node.load(is_product=path[0] == "produces") + + +def create_kwargs(task: PTask) -> dict[str, PyTree[Any]]: + kwargs: dict[str, PyTree[Any]] = {} + for name, tree in task.depends_on.items(): + kwargs[name] = tree_map_with_path( + lambda path, node: load_node((name, *path), node), tree + ) + for name, tree in task.produces.items(): + kwargs[name] = tree_map_with_path( + lambda path, node: load_node((name, *path), node), tree + ) + return kwargs +``` + +## Carry products from a worker + +Based on `pytask-parallel`'s product handling. `CarryOverPath` reproduces the plugin's +named tuple declaration. Product mapping changes leaf types and can return `None`. + +Related issues: + +- [ty #3415](https://github.com/astral-sh/ty/issues/3415) +- [mypy #22063](https://github.com/python/mypy/issues/22063) + +Intended types: + +- `reveal_type(list(mapped))`: `list[str]`. + +```python +from typing import Any, NamedTuple +from typing_extensions import reveal_type + +from pytask import PNode, PPathNode, PTask, PythonNode +from pytask.tree_util import PyTree, tree_map_with_path + + +class CarryOverPath(NamedTuple): + content: bytes + + +def carry_product( + path: tuple[Any, ...], node: PNode +) -> CarryOverPath | PythonNode | None: + if isinstance(node, PythonNode): + return node + if isinstance(node, PPathNode): + return CarryOverPath(content=node.path.read_bytes()) + return None + + +def carry_products(task: PTask) -> dict[str, PyTree[CarryOverPath | PythonNode | None]]: + # mypy-error: [misc] Cannot infer value of type parameter + mapped = tree_map_with_path(carry_product, task.produces) + # revealed: list[str] + reveal_type(list(mapped)) + return mapped +``` + + +## Update products from a corresponding carry-over tree + +Based on `pytask-parallel`'s `_update_carry_over_products`. The two trees have +different leaf types. The mapped result must retain its task argument dictionary +and be assignable to `task.produces`. + +Related issues: + +- [ty #3415](https://github.com/astral-sh/ty/issues/3415) +- [mypy #22063](https://github.com/python/mypy/issues/22063) + +Intended types: + +- `reveal_type(list(mapped))`: `list[str]`. + +```python +from typing import NamedTuple +from typing_extensions import reveal_type + +from pytask import PNode, PTask, PythonNode +from pytask.tree_util import PyTree, tree_map + + +class CarryOverPath(NamedTuple): + content: bytes + + +def update_node(node: PNode, carried: CarryOverPath | PythonNode | None) -> PNode: + if isinstance(carried, PythonNode): + node.save(carried.load()) + return node + + +def update_products( + task: PTask, carried: PyTree[CarryOverPath | PythonNode | None] +) -> None: + # mypy-error: [misc] Cannot infer value of type parameter + mapped = tree_map(update_node, task.produces, carried) + # revealed: list[str] + reveal_type(list(mapped)) + task.produces = mapped +``` + + +## Resolve remote nodes in task keyword arguments + +Based on `pytask-parallel`'s `_write_local_files_to_remote`. The minimal custom node +declares the plugin's `load` return type; other node operations come from `PNode`. +Task values must remain supported even when they are no longer nodes. + +Related issues: + +- [Pyrefly #4910](https://github.com/facebook/pyrefly/issues/4910) +- [mypy #15750](https://github.com/python/mypy/issues/15750) + +Intended types: + +- `reveal_type(list(mapped))`: `list[str]`. + +Current reveals (ty, pyright): + +```python only=ty,pyright +from pathlib import Path +from typing import Any +from typing_extensions import reveal_type + +from pytask import PNode +from pytask.tree_util import PyTree, tree_map + + +class RemotePathNode(PNode): + def load(self, is_product: bool = False) -> Path: + raise NotImplementedError + + +def resolve_remote_values(kwargs: dict[str, PyTree[Any]]) -> dict[str, PyTree[Any]]: + mapped = tree_map( + lambda value: value.load() if isinstance(value, RemotePathNode) else value, + kwargs, + ) + # revealed: list[str] + reveal_type(list(mapped)) + return mapped +``` + +Current reveals (pyrefly): + +```python only=pyrefly +from pathlib import Path +from typing import Any +from typing_extensions import reveal_type + +from pytask import PNode +from pytask.tree_util import PyTree, tree_map + + +class RemotePathNode(PNode): + def load(self, is_product: bool = False) -> Path: + raise NotImplementedError + + +def resolve_remote_values(kwargs: dict[str, PyTree[Any]]) -> dict[str, PyTree[Any]]: + mapped = tree_map( + lambda value: value.load() if isinstance(value, RemotePathNode) else value, + kwargs, + ) + # revealed: list[Unknown] + reveal_type(list(mapped)) + return mapped +``` + +Current reveals (mypy): + +```python only=mypy +from pathlib import Path +from typing import Any +from typing_extensions import reveal_type + +from pytask import PNode +from pytask.tree_util import PyTree, tree_map + + +class RemotePathNode(PNode): + def load(self, is_product: bool = False) -> Path: + raise NotImplementedError + + +def resolve_remote_values(kwargs: dict[str, PyTree[Any]]) -> dict[str, PyTree[Any]]: + mapped = tree_map( + lambda value: value.load() if isinstance(value, RemotePathNode) else value, + kwargs, + ) + # revealed: list[Any] + reveal_type(list(mapped)) + return mapped +``` + + +## Unpack mapped dependencies and products as keyword arguments + +Based on the Julia, R, and Stata plugins' keyword argument collection. A path node +becomes a string; other leaves expose their Python value. Both mapped results must +support dictionary unpacking. + +Related issues: + +- [ty #3415](https://github.com/astral-sh/ty/issues/3415) +- [mypy #15750](https://github.com/python/mypy/issues/15750) + +Intended types: + +- `reveal_type(list(kwargs))`: `list[str]`. + +Current reveals (ty): + +```python only=ty +from typing import Any +from typing_extensions import reveal_type + +from pytask import PPathNode, PTask +from pytask.tree_util import tree_map + + +def collect_kwargs(task: PTask) -> dict[str, Any]: + kwargs = { + **tree_map( + lambda node: ( + node.path.as_posix() if isinstance(node, PPathNode) else node.value + ), + task.depends_on, + ), + **tree_map( + lambda node: ( + node.path.as_posix() if isinstance(node, PPathNode) else node.value + ), + task.produces, + ), + } + # revealed: list[Divergent | str | Unknown] + reveal_type(list(kwargs)) + return kwargs +``` + +Current reveals (pyright, pyrefly, mypy): + +```python only=pyright,pyrefly,mypy +from typing import Any +from typing_extensions import reveal_type + +from pytask import PPathNode, PTask +from pytask.tree_util import tree_map + + +def collect_kwargs(task: PTask) -> dict[str, Any]: + kwargs = { + **tree_map( + lambda node: ( + node.path.as_posix() if isinstance(node, PPathNode) else node.value + ), + task.depends_on, + ), + **tree_map( + lambda node: ( + node.path.as_posix() if isinstance(node, PPathNode) else node.value + ), + task.produces, + ), + } + # revealed: list[str] + reveal_type(list(kwargs)) + return kwargs +``` diff --git a/tests/test_typing_tree_util_structure.md b/tests/test_typing_tree_util_structure.md new file mode 100644 index 00000000..3f504f70 --- /dev/null +++ b/tests/test_typing_tree_util_structure.md @@ -0,0 +1,367 @@ +# tree_structure + +Structure queries must accept node trees and return a usable `PyTreeSpec`, without +requiring casts of task dependencies or products. + +## Concrete nodes in flat and nested containers + +```python +from optree import PyTreeSpec +from pytask import PathNode, PythonNode +from pytask.tree_util import tree_structure + + +def check_structures(path_node: PathNode, python_node: PythonNode) -> None: + single: PyTreeSpec = tree_structure(path_node) + listed: PyTreeSpec = tree_structure([python_node]) + paired: PyTreeSpec = tree_structure((path_node, python_node)) + named: PyTreeSpec = tree_structure({"input": path_node}) + nested: PyTreeSpec = tree_structure({"inputs": [(path_node, python_node)]}) +``` + +## Compare task return and worker product structures + +Based on pytask's return validation and pytask-parallel's return validation and +carry-over product updates. The comparison must work without casting either tree. + +Intended types: + +- `reveal_type(declared.is_prefix(output, strict=False))`: `bool`. +- `reveal_type(products.is_prefix(worker_products, strict=False))`: `bool`. + +```python +from typing import Any +from typing_extensions import reveal_type + +from optree import PyTreeSpec +from pytask import PTask +from pytask.tree_util import PyTree, tree_structure + + +def check_task_structures(task: PTask, out: Any, carried: PyTree[Any]) -> None: + output: PyTreeSpec = tree_structure(out) + declared: PyTreeSpec = tree_structure(task.produces["return"]) + # revealed: bool + reveal_type(declared.is_prefix(output, strict=False)) + products: PyTreeSpec = tree_structure(task.produces) + worker_products: PyTreeSpec = tree_structure(carried) + # revealed: bool + reveal_type(products.is_prefix(worker_products, strict=False)) + dependencies: PyTreeSpec = tree_structure(task.depends_on) +``` + + +# tree_flatten_with_path + +Flattening must preserve node leaf types and expose paths and a usable structure. + +## Concrete nodes in different containers + +Related issues: + +- [ty #2870](https://github.com/astral-sh/ty/issues/2870) + +Intended types: + +- `reveal_type(paths)`: `list[tuple[Any, ...]]`. +- `reveal_type(leaves)`: `list[PathNode]`. +- `reveal_type(tree_flatten_with_path([path_node])[1])`: `list[PathNode]`. +- `reveal_type(tree_flatten_with_path((path_node,))[1])`: `list[PathNode]`. +- `reveal_type(tree_flatten_with_path({"input": path_node})[1])`: `list[PathNode]`. +- `reveal_type(tree_flatten_with_path(python_node)[1])`: `list[PythonNode]`. +- `reveal_type(tree_flatten_with_path([python_node])[1])`: `list[PythonNode]`. +- `reveal_type(tree_flatten_with_path((python_node,))[1])`: `list[PythonNode]`. +- `reveal_type(tree_flatten_with_path({"input": python_node})[1])`: `list[PythonNode]`. + +Current reveals (ty): + +```python only=ty +from typing_extensions import reveal_type + +from optree import PyTreeSpec +from pytask import PathNode, PythonNode +from pytask.tree_util import tree_flatten_with_path + + +def check_flattened_nodes(path_node: PathNode, python_node: PythonNode) -> None: + paths, leaves, spec = tree_flatten_with_path(path_node) + # revealed: list[tuple[Any, ...]] + reveal_type(paths) + # revealed: list[PathNode] + reveal_type(leaves) + structure: PyTreeSpec = spec + # revealed: list[list[PathNode]] + reveal_type(tree_flatten_with_path([path_node])[1]) + # revealed: list[tuple[PathNode] | PathNode] + reveal_type(tree_flatten_with_path((path_node,))[1]) + # revealed: list[dict[str, PathNode]] + reveal_type(tree_flatten_with_path({"input": path_node})[1]) + # revealed: list[PythonNode] + reveal_type(tree_flatten_with_path(python_node)[1]) + # revealed: list[list[PythonNode]] + reveal_type(tree_flatten_with_path([python_node])[1]) + # revealed: list[tuple[PythonNode] | PythonNode] + reveal_type(tree_flatten_with_path((python_node,))[1]) + # revealed: list[dict[str, PythonNode]] + reveal_type(tree_flatten_with_path({"input": python_node})[1]) +``` + +Current reveals (pyright, pyrefly): + +```python only=pyright,pyrefly +from typing_extensions import reveal_type + +from optree import PyTreeSpec +from pytask import PathNode, PythonNode +from pytask.tree_util import tree_flatten_with_path + + +def check_flattened_nodes(path_node: PathNode, python_node: PythonNode) -> None: + paths, leaves, spec = tree_flatten_with_path(path_node) + # revealed: list[tuple[Any, ...]] + reveal_type(paths) + # revealed: list[PathNode] + reveal_type(leaves) + structure: PyTreeSpec = spec + # revealed: list[PathNode] + reveal_type(tree_flatten_with_path([path_node])[1]) + # revealed: list[PathNode] + reveal_type(tree_flatten_with_path((path_node,))[1]) + # revealed: list[PathNode] + reveal_type(tree_flatten_with_path({"input": path_node})[1]) + # revealed: list[PythonNode] + reveal_type(tree_flatten_with_path(python_node)[1]) + # revealed: list[PythonNode] + reveal_type(tree_flatten_with_path([python_node])[1]) + # revealed: list[PythonNode] + reveal_type(tree_flatten_with_path((python_node,))[1]) + # revealed: list[PythonNode] + reveal_type(tree_flatten_with_path({"input": python_node})[1]) +``` + +Current reveals (mypy): + +```python only=mypy +from typing_extensions import reveal_type + +from optree import PyTreeSpec +from pytask import PathNode, PythonNode +from pytask.tree_util import tree_flatten_with_path + + +def check_flattened_nodes(path_node: PathNode, python_node: PythonNode) -> None: + paths, leaves, spec = tree_flatten_with_path(path_node) + # revealed: list[tuple[Any, ...]] + reveal_type(paths) + # revealed: list[_pytask.nodes.PathNode] + reveal_type(leaves) + structure: PyTreeSpec = spec + # revealed: list[list[_pytask.nodes.PathNode]] + reveal_type(tree_flatten_with_path([path_node])[1]) + # revealed: list[_pytask.nodes.PathNode] + reveal_type(tree_flatten_with_path((path_node,))[1]) + # revealed: list[dict[Any, _pytask.nodes.PathNode]] + reveal_type(tree_flatten_with_path({"input": path_node})[1]) + # revealed: list[_pytask.nodes.PythonNode] + reveal_type(tree_flatten_with_path(python_node)[1]) + # revealed: list[list[_pytask.nodes.PythonNode]] + reveal_type(tree_flatten_with_path([python_node])[1]) + # revealed: list[_pytask.nodes.PythonNode] + reveal_type(tree_flatten_with_path((python_node,))[1]) + # revealed: list[dict[Any, _pytask.nodes.PythonNode]] + reveal_type(tree_flatten_with_path({"input": python_node})[1]) +``` + + +## Nested containers of concrete nodes + +Related issues: + +- [ty #2870](https://github.com/astral-sh/ty/issues/2870) +- [Pyright #9798](https://github.com/microsoft/pyright/issues/9798) + +Intended types: + +- `reveal_type(tree_flatten_with_path(paths)[1])`: `list[PathNode]`. +- `reveal_type(tree_flatten_with_path(objects)[1])`: `list[PythonNode]`. + +Current reveals (ty, pyright, pyrefly): + +```python only=ty,pyright,pyrefly +from typing_extensions import reveal_type + +from pytask import PathNode, PythonNode +from pytask.tree_util import tree_flatten_with_path + + +def check_nested_nodes( + paths: dict[str, list[tuple[PathNode, ...]]], + objects: list[dict[str, tuple[PythonNode, ...]]], +) -> None: + # revealed: list[dict[str, list[tuple[PathNode, ...]]]] + reveal_type(tree_flatten_with_path(paths)[1]) + # revealed: list[list[dict[str, tuple[PythonNode, ...]]]] + reveal_type(tree_flatten_with_path(objects)[1]) +``` + +Current reveals (mypy): + +```python only=mypy +from typing_extensions import reveal_type + +from pytask import PathNode, PythonNode +from pytask.tree_util import tree_flatten_with_path + + +def check_nested_nodes( + paths: dict[str, list[tuple[PathNode, ...]]], + objects: list[dict[str, tuple[PythonNode, ...]]], +) -> None: + # revealed: list[dict[str, list[tuple[_pytask.nodes.PathNode, ...]]]] + reveal_type(tree_flatten_with_path(paths)[1]) + # revealed: list[list[dict[str, tuple[_pytask.nodes.PythonNode, ...]]]] + reveal_type(tree_flatten_with_path(objects)[1]) +``` + + +## Protocol trees and task dependencies and products + +Related issues: + +- [ty #2870](https://github.com/astral-sh/ty/issues/2870) +- [mypy #6751](https://github.com/python/mypy/issues/6751) + +Intended types: + +- `reveal_type(paths)`: `list[tuple[Any, ...]]`. +- `reveal_type(leaves)`: `list[PNode]`. +- `reveal_type(tree_flatten_with_path(task.depends_on)[1])`: `list[PNode | PProvisionalNode]`. +- `reveal_type(tree_flatten_with_path(task.produces)[1])`: `list[PNode | PProvisionalNode]`. + +Current reveals (ty): + +```python only=ty +from typing_extensions import reveal_type + +from pytask import PNode, PTask +from pytask.tree_util import PyTree, tree_flatten_with_path + + +def check_protocol_trees(tree: PyTree[PNode], task: PTask) -> None: + paths, leaves, spec = tree_flatten_with_path(tree) + # revealed: list[tuple[Any, ...]] + reveal_type(paths) + # revealed: list[PNode | tuple[PyTree[PNode], ...] | list[PyTree[PNode]] | dict[Any, PyTree[PNode]]] + reveal_type(leaves) + # mypy-error: [misc] Cannot infer value of type parameter + # revealed: list[dict[str, PNode | PProvisionalNode | tuple[PyTree[PNode | PProvisionalNode], ...] | list[PyTree[PNode | PProvisionalNode]] | dict[Any, PyTree[PNode | PProvisionalNode]]] | PNode | PProvisionalNode | tuple[PyTree[PNode | PProvisionalNode], ...] | list[PyTree[PNode | PProvisionalNode]] | dict[Any, PyTree[PNode | PProvisionalNode]]] + reveal_type(tree_flatten_with_path(task.depends_on)[1]) + # mypy-error: [misc] Cannot infer value of type parameter + # revealed: list[dict[str, PNode | PProvisionalNode | tuple[PyTree[PNode | PProvisionalNode], ...] | list[PyTree[PNode | PProvisionalNode]] | dict[Any, PyTree[PNode | PProvisionalNode]]] | PNode | PProvisionalNode | tuple[PyTree[PNode | PProvisionalNode], ...] | list[PyTree[PNode | PProvisionalNode]] | dict[Any, PyTree[PNode | PProvisionalNode]]] + reveal_type(tree_flatten_with_path(task.produces)[1]) +``` + +Current reveals (pyright, pyrefly): + +```python only=pyright,pyrefly +from typing_extensions import reveal_type + +from pytask import PNode, PTask +from pytask.tree_util import PyTree, tree_flatten_with_path + + +def check_protocol_trees(tree: PyTree[PNode], task: PTask) -> None: + paths, leaves, spec = tree_flatten_with_path(tree) + # revealed: list[tuple[Any, ...]] + reveal_type(paths) + # revealed: list[PNode] + reveal_type(leaves) + # mypy-error: [misc] Cannot infer value of type parameter + # revealed: list[PNode | PProvisionalNode] + reveal_type(tree_flatten_with_path(task.depends_on)[1]) + # mypy-error: [misc] Cannot infer value of type parameter + # revealed: list[PNode | PProvisionalNode] + reveal_type(tree_flatten_with_path(task.produces)[1]) +``` + +Current reveals (mypy): + +```python only=mypy +from typing_extensions import reveal_type + +from pytask import PNode, PTask +from pytask.tree_util import PyTree, tree_flatten_with_path + + +def check_protocol_trees(tree: PyTree[PNode], task: PTask) -> None: + paths, leaves, spec = tree_flatten_with_path(tree) + # revealed: list[tuple[Any, ...]] + reveal_type(paths) + # revealed: list[_pytask.node_protocols.PNode] + reveal_type(leaves) + # mypy-error: [misc] Cannot infer value of type parameter + # revealed: list[Any] + reveal_type(tree_flatten_with_path(task.depends_on)[1]) + # mypy-error: [misc] Cannot infer value of type parameter + # revealed: list[Any] + reveal_type(tree_flatten_with_path(task.produces)[1]) +``` + + +## A custom predicate can make a container a leaf + +Intended types: + +- `reveal_type(paths)`: `list[tuple[Any, ...]]`. +- `reveal_type(leaves)`: `list[list[PythonNode]]`. +- `reveal_type(structure.is_prefix(spec, strict=False))`: `bool`. + +Current reveals (ty, pyright, pyrefly): + +```python only=ty,pyright,pyrefly +from typing_extensions import reveal_type + +from optree import PyTreeSpec +from pytask import PythonNode +from pytask.tree_util import tree_flatten_with_path, tree_structure + + +def check_container_leaf(nodes: list[PythonNode]) -> None: + paths, leaves, spec = tree_flatten_with_path( + nodes, is_leaf=lambda value: isinstance(value, list) + ) + # revealed: list[tuple[Any, ...]] + reveal_type(paths) + # revealed: list[list[PythonNode]] + reveal_type(leaves) + structure: PyTreeSpec = tree_structure( + nodes, is_leaf=lambda value: isinstance(value, list) + ) + # revealed: bool + reveal_type(structure.is_prefix(spec, strict=False)) +``` + +Current reveals (mypy): + +```python only=mypy +from typing_extensions import reveal_type + +from optree import PyTreeSpec +from pytask import PythonNode +from pytask.tree_util import tree_flatten_with_path, tree_structure + + +def check_container_leaf(nodes: list[PythonNode]) -> None: + paths, leaves, spec = tree_flatten_with_path( + nodes, is_leaf=lambda value: isinstance(value, list) + ) + # revealed: list[tuple[Any, ...]] + reveal_type(paths) + # revealed: list[list[_pytask.nodes.PythonNode]] + reveal_type(leaves) + structure: PyTreeSpec = tree_structure( + nodes, is_leaf=lambda value: isinstance(value, list) + ) + # revealed: bool + reveal_type(structure.is_prefix(spec, strict=False)) +``` diff --git a/uv.lock b/uv.lock index d49a20af..ffc4529a 100644 --- a/uv.lock +++ b/uv.lock @@ -3201,8 +3201,10 @@ test = [ { name = "pygraphviz", marker = "sys_platform == 'linux'" }, { name = "pytest" }, { name = "pytest-cov" }, + { name = "pytest-typing" }, { name = "pytest-xdist" }, { name = "syrupy" }, + { name = "ty" }, ] typing = [ { name = "ty" }, @@ -3253,10 +3255,12 @@ test = [ { name = "pygraphviz", marker = "sys_platform == 'linux'", specifier = ">=1.12" }, { name = "pytest", specifier = ">=8.4.0" }, { name = "pytest-cov", specifier = ">=5.0.0" }, + { name = "pytest-typing", specifier = ">=26.3.0" }, { name = "pytest-xdist", specifier = ">=3.6.1" }, { name = "syrupy", specifier = ">=4.5.0" }, + { name = "ty", specifier = ">=0.0.84" }, ] -typing = [{ name = "ty", specifier = ">=0.0.8" }] +typing = [{ name = "ty", specifier = ">=0.0.84" }] [[package]] name = "pytest" @@ -3290,6 +3294,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] +[[package]] +name = "pytest-typing" +version = "26.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/3f/982c57ce061a6fd667e85b1256eb3ad5d67dc56ba53d430b3bd9aca26716/pytest_typing-26.3.0.tar.gz", hash = "sha256:c0e155612088bd30351bd6a38fea2bf039a280b5303423e8c3eebe568c180b20", size = 65028, upload-time = "2026-03-24T21:31:32.923Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/c1/8c4b27724dc0cc0c566e7b1a27a1afd4497d6c5b9cc93a3f9330c742ac12/pytest_typing-26.3.0-py3-none-any.whl", hash = "sha256:b44a990622e8f77684b0f35c13e1d8711fd20a43c53a53b378619d69bea47007", size = 17594, upload-time = "2026-03-24T21:31:31.48Z" }, +] + [[package]] name = "pytest-xdist" version = "3.8.0" @@ -4110,27 +4126,27 @@ wheels = [ [[package]] name = "ty" -version = "0.0.80" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/54/b0/6d1b10e0d422736a3c439e487c950ae785f401d71ff879d5be68bccb6d90/ty-0.0.80.tar.gz", hash = "sha256:fe86bc91327e45ff5e3593b7e306e7f57a44bc0608f38d647b8d97bd99c96013", size = 7183998, upload-time = "2026-09-09T21:18:47.547Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/29/c8/3c93195eca282936ebb574d0ba98843e4b147ee324006f88464777012a92/ty-0.0.80-py3-none-linux_armv6l.whl", hash = "sha256:738d1cfca466c577aea24547c348629c00c63548cf7da2c46b497b3840f85dee", size = 13606509, upload-time = "2026-09-09T21:18:10.476Z" }, - { url = "https://files.pythonhosted.org/packages/e7/48/bbb47f7001c97262a5109bcd92a45bcc8a2fbb21e994c214a9897630bfb7/ty-0.0.80-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:56060164bb8ee43770fa367524fbdba2611fc90f8923a02cc91f80eb37d5a1b0", size = 13203812, upload-time = "2026-09-09T21:18:12.796Z" }, - { url = "https://files.pythonhosted.org/packages/db/8d/fd141160567047b742e466be8ed09a4c4ed80fa045d37f946eb4f4532fee/ty-0.0.80-py3-none-macosx_11_0_arm64.whl", hash = "sha256:da4062e0fbf3923d9b71688157c5753394f243348f00732e9edbb27498397169", size = 13021896, upload-time = "2026-09-09T21:18:15.186Z" }, - { url = "https://files.pythonhosted.org/packages/1e/10/b4177faf9e71bc37bc4d08f512042269660a1ce0ec457c48f96e51fc91d7/ty-0.0.80-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9eb94b2659f506a3a07a9ea1415d5c18aaa1e554adc1612614d617a0ed320e1", size = 13083881, upload-time = "2026-09-09T21:18:17.616Z" }, - { url = "https://files.pythonhosted.org/packages/7f/c2/192d48b9a9acbbe030fc9a4bd73af75671a5566e731949f77e912a68ce68/ty-0.0.80-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2f5e39a87da48c1af1a3b3135f02be7a83a93da30b0d6a5f5d27806e7c747ce", size = 13354874, upload-time = "2026-09-09T21:18:19.744Z" }, - { url = "https://files.pythonhosted.org/packages/0a/5b/736dfd31efd98bed9dbe018ff91676e5ea83485b1e24afb635fd0247728f/ty-0.0.80-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e00149e7779c6b98f3ba12e10a631a861bd7ca1a42bbf064e2677ada7d2c0c2", size = 14204805, upload-time = "2026-09-09T21:18:21.999Z" }, - { url = "https://files.pythonhosted.org/packages/fd/6f/557c726cb53401998e3589bb632c963a6887a70240e0a4386024f3731c81/ty-0.0.80-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a377268f359fb6e7a2cc9026a09223c764f58d020dec8e6baeaba9caba6ff829", size = 14630014, upload-time = "2026-09-09T21:18:24Z" }, - { url = "https://files.pythonhosted.org/packages/e9/19/7a4b18fe27f6b6bd4ffa56b67c8b09ecb76bb4312d1c64b73f65417b4dbd/ty-0.0.80-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4511dbf1b266b9ce5b73e9b28d94468096c2b6da00e2fe205168c32d3b352ca7", size = 14326946, upload-time = "2026-09-09T21:18:26.679Z" }, - { url = "https://files.pythonhosted.org/packages/f8/95/16dd90805fc7e53ec54ae9fdb1a8b74753fb159a55a170f60f49b9417824/ty-0.0.80-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe95feffa7156800c6f804195acb9fb5846a39671c7192c30fff23351aaf7c31", size = 13705988, upload-time = "2026-09-09T21:18:28.681Z" }, - { url = "https://files.pythonhosted.org/packages/b1/09/4e87992c23ab8a742c7d4914ac5fe66fb9301c8464a69fd2ecbc0be3fbcd/ty-0.0.80-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ba26b39f06bc8c3c2c5acd3a147239482b36620cdbca1b9d02e915294854f2cc", size = 14233729, upload-time = "2026-09-09T21:18:30.961Z" }, - { url = "https://files.pythonhosted.org/packages/36/e9/b8c11fda8e66a1d1cc1a7160ed719a33f4dc0107b1cef6a14e2673965763/ty-0.0.80-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2e6167320888c115a6fbe69893b63fd1c848f9121a454e75e5f612674528e3f3", size = 13173523, upload-time = "2026-09-09T21:18:33.266Z" }, - { url = "https://files.pythonhosted.org/packages/73/a7/512083fa540c5be1ac8642658f03bfb5bfa6fd168ea4f55e3c0aabe04166/ty-0.0.80-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7aecb62b4de70b479eab07d1779ea66da26dd8b068f50a9330867157312f103c", size = 13373014, upload-time = "2026-09-09T21:18:35.339Z" }, - { url = "https://files.pythonhosted.org/packages/5e/63/cd2f0ca81fd9b8cafef93bbcf022bf636bc4de8ee7465c6aafeec1d4d52b/ty-0.0.80-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4fed06adacd16b7e2d722f37449021b1419598d0440769701c0f4827faddde63", size = 13667827, upload-time = "2026-09-09T21:18:37.241Z" }, - { url = "https://files.pythonhosted.org/packages/0d/5d/ebdfdcb2dc099ae74dc4b75511eef0dd398b2fa27006c543543974b4567f/ty-0.0.80-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:09329af6303ce611ec2cfffc995dc1ddf76c9f47194fc1a4d64a984759c25f5b", size = 13963866, upload-time = "2026-09-09T21:18:39.386Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f6/c286d5e1b560cfd16d6e91fdab683718aacf09fc88938ca06dcc9d1c8502/ty-0.0.80-py3-none-win32.whl", hash = "sha256:a81b3b512f7b4c68e42fbd1835633de658809666e9aafd5defa52bbc5197698d", size = 12881806, upload-time = "2026-09-09T21:18:41.507Z" }, - { url = "https://files.pythonhosted.org/packages/fd/c8/3b3a8ac16d47a23ff34bf57c0d99a491860748aea96e0e886b4bfed54f9b/ty-0.0.80-py3-none-win_amd64.whl", hash = "sha256:8043f99878a2ae434781cb881c8a3840dff70c4a5b60bdc5ae84251f35ee063f", size = 13528883, upload-time = "2026-09-09T21:18:43.687Z" }, - { url = "https://files.pythonhosted.org/packages/2e/71/a6c697930fca76596d7d17f8853120f41a298fd9ea632c901cfca8ca869b/ty-0.0.80-py3-none-win_arm64.whl", hash = "sha256:e277ef034331da5319efc839c064968d24f35715b71c70369cf97d36fe4dcbdb", size = 13370135, upload-time = "2026-09-09T21:18:45.758Z" }, +version = "0.0.84" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/f6/34f8869c45ea87fe6a931ffa824b1fe4428167173543d92988d631add355/ty-0.0.84.tar.gz", hash = "sha256:0cefdb0cd5d399418dbe83c349ba605047b7dff815ae6f460c80e8522b40be67", size = 7409537, upload-time = "2026-09-24T13:16:24.367Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/0c/600648778e1c79133ce6b6a5c4c2531ae16b90c9b30f5041b38b574f61b8/ty-0.0.84-py3-none-linux_armv6l.whl", hash = "sha256:868438e2b9abfc835dd5b5b07ed693db95d8348ac40e3228252511d63ae87695", size = 13935333, upload-time = "2026-09-24T13:15:31.167Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3b/27f06f49945c36170765fad82076898039ae3873338e9865f876dd62f12c/ty-0.0.84-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b00f39b521f0b559cde0f6abf8dd47213b513b04f33d58efeb9b98486a16bb17", size = 13511328, upload-time = "2026-09-24T13:15:34.446Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/b12dd130e7f30141f0645614f454c925991cd389b1cc6c8284fd6bf32326/ty-0.0.84-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8f78c2915567f2ee62ce0c24d86091bc76f81577677755e242d09951db588b9a", size = 13445445, upload-time = "2026-09-24T13:15:37.476Z" }, + { url = "https://files.pythonhosted.org/packages/44/96/f012de4b3c1d9a4773326621380d768b4d48c47e620dc012775c5e01c7d7/ty-0.0.84-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:61155cad0921e890d86d1d911a40b3a0e924ef4bdc3b4f3865a6c89c7e3012dc", size = 13471235, upload-time = "2026-09-24T13:15:40.64Z" }, + { url = "https://files.pythonhosted.org/packages/05/32/dca630f5f5353fa7bf67eebccf1549df99b16656d21071d0df6677c826a5/ty-0.0.84-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:417c3bf14947ed16b0b92976ab333c27915590354e96d4f581709068d3811355", size = 13680704, upload-time = "2026-09-24T13:15:43.817Z" }, + { url = "https://files.pythonhosted.org/packages/44/8d/33702c8f62f05ad45b33644fb914d483de6a0a7bc8e51884e9b8773e6d69/ty-0.0.84-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e06ef117759f654f5379efe9ae79bda8a186016c016f1023d05c34f8e15c1546", size = 14518923, upload-time = "2026-09-24T13:15:46.802Z" }, + { url = "https://files.pythonhosted.org/packages/c8/a1/da3246e6ef2ae0e842bca4058b25c54bee55ab058ffff4ee54e72517ec01/ty-0.0.84-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a05d1d53db8b64410ab38c6e32c5898510584869d1d5dd9c0605db81c9aef7df", size = 15027731, upload-time = "2026-09-24T13:15:49.755Z" }, + { url = "https://files.pythonhosted.org/packages/20/17/b0099098a560aeb4f9fde6c912f9a81622c3e44e6555e1fbe322b368ec31/ty-0.0.84-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b92400494ab855cfb1204ac1101e3f53b2d09a8e21d171758b2cf9acdab1809e", size = 14748260, upload-time = "2026-09-24T13:15:52.713Z" }, + { url = "https://files.pythonhosted.org/packages/60/d5/32cd67aedb271f006e716ad093806c96c5d7e2af86be8255469dbd3d76c8/ty-0.0.84-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af17eb391ae3027fed9be1780bea555c2b8a25ba68e184c627b4981b2c9eaab7", size = 14205858, upload-time = "2026-09-24T13:15:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/43/c4/638ac62ca2e8eca1b92e3e16273085744fada728b993500819b5c258ca15/ty-0.0.84-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:44cf06af0e7ec78ea6d8ff33a3450fc19627756a4fba681f7410d4e3ef63047a", size = 14573196, upload-time = "2026-09-24T13:15:59.165Z" }, + { url = "https://files.pythonhosted.org/packages/a6/f6/9ee81c5d8921e5fd5f10889ae563c95cd3272b31df2ada528919ca1cef6b/ty-0.0.84-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c6e56b443ff9ba462e34bfbc1e582004c2719ed35f8f93a433744b0ba0afb2d9", size = 13480760, upload-time = "2026-09-24T13:16:02.313Z" }, + { url = "https://files.pythonhosted.org/packages/f6/83/fbe5d1177667ae16d84c3242846b143809c9cc09568b333e5a9b4d5908f6/ty-0.0.84-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b6562fcc48820c1030e2a4509ef2c14ef93043ca4860280074314ac3a825b95", size = 13696346, upload-time = "2026-09-24T13:16:05.391Z" }, + { url = "https://files.pythonhosted.org/packages/86/9a/1b4ac9ddc34cc0ab4624bc219825d66af52f1ad87f4778ad08ad03fa06d5/ty-0.0.84-py3-none-musllinux_1_2_i686.whl", hash = "sha256:dee7421451efcbd70f03e95a8f2c8d9e5ec3d35edcd27b4095ee00ee062abfc4", size = 13959410, upload-time = "2026-09-24T13:16:08.661Z" }, + { url = "https://files.pythonhosted.org/packages/c9/04/70e289437a67dfd686056ab4fd91880261a8233033a6a95d29393741fb97/ty-0.0.84-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:31e96307cf621babe969307dd657783d672e2d8bdbd06493710da635bf63fa07", size = 14350294, upload-time = "2026-09-24T13:16:11.865Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f4/ae7dbd118425c850981d23efb850f0d9d7f6b8d2b852bde2767434f668b4/ty-0.0.84-py3-none-win32.whl", hash = "sha256:4bdc85f91cbaae35825f2b17bb4ac5de0563367875cb0f42b2bf07a1bf7ff2ba", size = 13176439, upload-time = "2026-09-24T13:16:15.049Z" }, + { url = "https://files.pythonhosted.org/packages/29/ec/994d87252f065dda5a500bc8afc726d18f6582d81d6f4ff1feb8a267c800/ty-0.0.84-py3-none-win_amd64.whl", hash = "sha256:71cc9aa7a7c89d4e12c6a814e9c0c3310ad5bee44efedbd6f5a42d91e9ac0f0d", size = 13962879, upload-time = "2026-09-24T13:16:18.191Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1c/e6e91852b72566b1ab9aca504712857dfb89324c12092a5bd61d42d34427/ty-0.0.84-py3-none-win_arm64.whl", hash = "sha256:6c9d3718c4a0d318bc9c92af9b2aa85c3294e7dff54cf1d69ad79568f56516da", size = 13656453, upload-time = "2026-09-24T13:16:21.51Z" }, ] [[package]]