diff --git a/src/_pytask/capture.py b/src/_pytask/capture.py index 33f00143..7ea3546b 100644 --- a/src/_pytask/capture.py +++ b/src/_pytask/capture.py @@ -219,7 +219,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 # ty: ignore[invalid-method-override] + def write(self, data: str) -> int: # noqa: ARG002 msg = "Cannot write to stdin." raise UnsupportedOperation(msg) diff --git a/src/_pytask/collect_command.py b/src/_pytask/collect_command.py index 4ad86229..348ee1c5 100644 --- a/src/_pytask/collect_command.py +++ b/src/_pytask/collect_command.py @@ -4,6 +4,7 @@ import sys from collections import defaultdict +from pathlib import Path from typing import TYPE_CHECKING from typing import Any @@ -39,9 +40,19 @@ from _pytask.tree_util import tree_leaves if TYPE_CHECKING: - from pathlib import Path from typing import NoReturn + from _pytask.typing import NodePath + + +def _normalize_local_node_path(path: NodePath) -> Path: + """Normalize a local node path and preserve that guarantee in its type.""" + normalized = normalize_local_upath(path) + if not isinstance(normalized, Path): + msg = f"Expected a local path, got {path!r}." + raise TypeError(msg) + return normalized + @hookimpl(tryfirst=True) def pytask_extend_command_line_interface(cli: click.Group) -> None: @@ -127,12 +138,12 @@ def _find_common_ancestor_of_all_nodes( all_paths.append(task.path) if show_nodes: all_paths.extend( - normalize_local_upath(x.path) + _normalize_local_node_path(x.path) for x in tree_leaves(task.depends_on) if isinstance(x, PPathNode) and not is_non_local_path(x.path) ) all_paths.extend( - normalize_local_upath(x.path) + _normalize_local_node_path(x.path) for x in tree_leaves(task.produces) if isinstance(x, PPathNode) and not is_non_local_path(x.path) ) diff --git a/src/_pytask/config_utils.py b/src/_pytask/config_utils.py index 0cf3c8d4..1fff9cce 100644 --- a/src/_pytask/config_utils.py +++ b/src/_pytask/config_utils.py @@ -42,7 +42,9 @@ def set_defaults_from_config( # to this command. They might expect the defaults coming from their related # command-line options during parsing. Here, we add their defaults to the # configuration. - command_option_names = [option.name for option in context.command.params] + command_option_names = { + option.name for option in context.command.params if option.name is not None + } assert context.parent is not None assert isinstance(context.parent.command, click.Group) commands = context.parent.command.commands @@ -50,7 +52,9 @@ def set_defaults_from_config( option.name: option.default for name, command in commands.items() for option in command.params - if name != context.info_name and option.name not in command_option_names + if name != context.info_name + and option.name is not None + and option.name not in command_option_names } context.params.update(all_defaults_from_cli) diff --git a/src/_pytask/data_catalog.py b/src/_pytask/data_catalog.py index 1629da88..15bc3a83 100644 --- a/src/_pytask/data_catalog.py +++ b/src/_pytask/data_catalog.py @@ -14,6 +14,8 @@ from dataclasses import field from pathlib import Path from typing import Any +from typing import Protocol +from typing import cast from _pytask.config_utils import find_project_root_and_config from _pytask.data_catalog_utils import DATA_CATALOG_NAME_FIELD @@ -25,6 +27,25 @@ from _pytask.pluginmanager import storage from _pytask.session import Session + +class _NodeFactory(Protocol): + """Construct a node which does not require a path.""" + + @property + def __mro__(self) -> tuple[type[Any], ...]: ... + + def __call__(self, *, name: str) -> PNode: ... + + +class _PathNodeFactory(Protocol): + """Construct a node which requires a path.""" + + @property + def __mro__(self) -> tuple[type[Any], ...]: ... + + def __call__(self, *, name: str, path: Path) -> PNode: ... + + __all__ = ["DataCatalog"] @@ -37,7 +58,9 @@ def _get_parent_path_of_data_catalog_module(stacklevel: int = 2) -> Path: return Path.cwd() -def _is_path_node_type(node_type: type[Any]) -> bool: +def _is_path_node_type( + node_type: _NodeFactory | _PathNodeFactory, +) -> bool: """Return True if the class looks like a path-based node.""" for cls in node_type.__mro__: if "path" in getattr(cls, "__annotations__", {}): @@ -65,7 +88,7 @@ class DataCatalog: """ - default_node: type[PNode] = PickleNode + default_node: _NodeFactory | _PathNodeFactory = PickleNode name: str = "default" path: Path | None = None _entries: dict[str, PNode | PProvisionalNode] = field(default_factory=dict) @@ -120,11 +143,17 @@ def add(self, name: str, node: PNode | PProvisionalNode | Any = None) -> None: filename = hashlib.sha256(name.encode()).hexdigest() if _is_path_node_type(self.default_node): assert self.path is not None - self._entries[name] = self.default_node( + node_factory = cast("_PathNodeFactory", self.default_node) + created_node = node_factory( name=name, path=self.path / f"{filename}.pkl" ) else: - self._entries[name] = self.default_node(name=name) + node_factory = cast("_NodeFactory", self.default_node) + created_node = node_factory(name=name) + if not isinstance(created_node, PNode): + msg = "The default node factory must return an object matching PNode." + raise TypeError(msg) + self._entries[name] = created_node assert self.path is not None self.path.joinpath(f"{filename}-node.pkl").write_bytes( pickle.dumps(self._entries[name]) diff --git a/src/_pytask/models.py b/src/_pytask/models.py index 95a6bfec..39f31298 100644 --- a/src/_pytask/models.py +++ b/src/_pytask/models.py @@ -7,17 +7,21 @@ from typing import TYPE_CHECKING from typing import Any from typing import NamedTuple +from typing import TypeAlias from uuid import UUID from uuid import uuid4 if TYPE_CHECKING: - from collections.abc import Callable from pathlib import Path from _pytask.mark import Mark from _pytask.tree_util import PyTree +ParsedAfter: TypeAlias = str | list[UUID] +"""A parsed ``after`` expression or temporary IDs of preceding tasks.""" + + @dataclass class CollectionMetadata: """A class for carrying metadata from functions to tasks. @@ -25,8 +29,8 @@ class CollectionMetadata: Attributes ---------- after - An expression or a task function or a list of task functions that need to be - executed before this task can. + An expression or a list of temporary task IDs that identify tasks which need + to be executed before this task can. id_ An id for the task if it is part of a parametrization. Otherwise, an automatic id will be generated. See @@ -51,7 +55,7 @@ class CollectionMetadata: information. """ - after: str | list[Callable[..., Any]] = field(default_factory=list) + after: ParsedAfter = field(default_factory=list) attributes: dict[str, Any] = field(default_factory=dict) annotation_locals: dict[str, Any] | None = None is_generator: bool = False diff --git a/src/_pytask/task_utils.py b/src/_pytask/task_utils.py index 34b2d1e1..e1cd3aac 100644 --- a/src/_pytask/task_utils.py +++ b/src/_pytask/task_utils.py @@ -7,11 +7,15 @@ import inspect import sys from collections import defaultdict +from collections.abc import Callable from dataclasses import asdict from dataclasses import is_dataclass from types import BuiltinFunctionType from typing import TYPE_CHECKING from typing import Any +from typing import ParamSpec +from typing import Protocol +from typing import TypeAlias from typing import TypeGuard from typing import TypeVar from typing import cast @@ -22,6 +26,7 @@ from _pytask.console import get_file from _pytask.mark import Mark from _pytask.models import CollectionMetadata +from _pytask.models import ParsedAfter from _pytask.shared import find_duplicates from _pytask.shared import unwrap_task_function from _pytask.typing import TaskFunction @@ -29,15 +34,21 @@ from _pytask.typing import is_task_decorator_target as is_task_decorator_target_runtime if TYPE_CHECKING: - from collections.abc import Callable from pathlib import Path - from typing import TypeAlias + from uuid import UUID - from ty_extensions import Intersection +P = ParamSpec("P") +R_co = TypeVar("R_co", covariant=True) - TaskDecorated: TypeAlias = "Intersection[T, TaskFunction]" +AfterInput: TypeAlias = str | Callable[..., Any] | list[Callable[..., Any]] | None -T = TypeVar("T", bound="Callable[..., Any]") + +class TaskDecorated(Protocol[P, R_co]): + """A callable task with collection metadata attached.""" + + pytask_meta: CollectionMetadata + + def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R_co: ... def _is_task_decorator_target(obj: object) -> TypeGuard[Callable[..., Any]]: @@ -65,32 +76,32 @@ def _is_task_decorator_target(obj: object) -> TypeGuard[Callable[..., Any]]: @overload def task( - name: T, + name: Callable[P, R_co], /, -) -> TaskDecorated[T]: ... +) -> TaskDecorated[P, R_co]: ... @overload def task( name: str | None = None, *, - after: str | Callable[..., Any] | list[Callable[..., Any]] | None = None, + after: AfterInput = None, is_generator: bool = False, id: str | None = None, kwargs: dict[Any, Any] | None = None, produces: Any | None = None, -) -> Callable[[T], TaskDecorated[T]]: ... +) -> Callable[[Callable[P, R_co]], TaskDecorated[P, R_co]]: ... def task( # noqa: PLR0913 - name: str | T | None = None, + name: str | Callable[P, R_co] | None = None, *, - after: str | Callable[..., Any] | list[Callable[..., Any]] | None = None, + after: AfterInput = None, is_generator: bool = False, id: str | None = None, # noqa: A002 kwargs: dict[Any, Any] | None = None, produces: Any | None = None, -) -> TaskDecorated[T] | Callable[[T], TaskDecorated[T]]: +) -> TaskDecorated[P, R_co] | Callable[[Callable[P, R_co]], TaskDecorated[P, R_co]]: """Decorate a task function. This decorator declares every callable as a pytask task. @@ -157,7 +168,7 @@ def create_text_file() -> Annotated[str, Path("file.txt")]: ) caller_locals = None if has_future_annotations else caller_frame.f_locals.copy() - def wrapper(func: T) -> TaskDecorated[T]: + def wrapper(func: Callable[P, R_co]) -> TaskDecorated[P, R_co]: # Omits frame when a builtin function is wrapped. _rich_traceback_omit = True @@ -229,11 +240,13 @@ def wrapper(func: T) -> TaskDecorated[T]: # collection when the function definition is overwritten in a loop. COLLECTED_TASKS[path].append(unwrapped) - return unwrapped + # Runtime validation and metadata attachment establish the protocol, but the + # relationship to the input signature cannot be narrowed automatically. + return cast("TaskDecorated[P, R_co]", unwrapped) # When decorator is used without parentheses, call wrapper directly. if _is_task_decorator_target(name) and kwargs is None: - func = cast("T", name) + func = cast("Callable[P, R_co]", name) return wrapper(func) return wrapper @@ -254,8 +267,8 @@ def _parse_name(func: Callable[..., Any], name: str | None) -> str: def _parse_after( - after: str | Callable[..., Any] | list[Callable[..., Any]] | None, -) -> str | list[Callable[..., Any]]: + after: AfterInput, +) -> ParsedAfter: if not after: return [] if isinstance(after, str): @@ -263,7 +276,7 @@ def _parse_after( if callable(after): after = [after] if isinstance(after, list): - new_after = [] + new_after: list[UUID] = [] for func in after: if not isinstance(func, TaskFunction): func = task()(func) # noqa: PLW2901 diff --git a/tests/test_data_catalog.py b/tests/test_data_catalog.py index 7de49cf8..5f7a59f0 100644 --- a/tests/test_data_catalog.py +++ b/tests/test_data_catalog.py @@ -45,6 +45,17 @@ def test_change_default_node(): assert isinstance(default_node, PythonNode) +def test_default_node_factory_must_return_node(): + class InvalidNode: + def __init__(self, *, name): + self.name = name + + data_catalog = DataCatalog(default_node=InvalidNode) + + with pytest.raises(TypeError, match="factory must return"): + data_catalog["invalid"] + + def test_use_data_catalog_in_workflow(runner, tmp_path): source = """ from pathlib import Path diff --git a/tests/test_task_utils.py b/tests/test_task_utils.py index b4986272..aeb5f2b5 100644 --- a/tests/test_task_utils.py +++ b/tests/test_task_utils.py @@ -5,6 +5,7 @@ from functools import partial from pathlib import Path from typing import NamedTuple +from uuid import UUID import pytest @@ -79,6 +80,20 @@ def task_example(): ... COLLECTED_TASKS.pop(Path(__file__)) +def test_after_functions_are_stored_as_temporary_ids(): + @task + def task_before(): ... + + @task(after=task_before) + def task_after(): ... + + assert task_after.pytask_meta.after == [task_before.pytask_meta._id] + assert all(isinstance(value, UUID) for value in task_after.pytask_meta.after) + + # Remove collected tasks. + COLLECTED_TASKS.pop(Path(__file__)) + + def task_func(x): # pragma: no cover pass diff --git a/tests/test_typing.py b/tests/test_typing.py index 55fe52b8..bf17b106 100644 --- a/tests/test_typing.py +++ b/tests/test_typing.py @@ -1,9 +1,23 @@ from __future__ import annotations import functools +from typing import TYPE_CHECKING from pytask import is_task_function +if TYPE_CHECKING: + from typing_extensions import assert_type + + from pytask import CollectionMetadata + from pytask import task + + @task + def _typed_task(value: int) -> str: + return str(value) + + assert_type(_typed_task(1), str) + assert_type(_typed_task.pytask_meta, CollectionMetadata) + def test_is_task_function(): def func(): ... diff --git a/uv.lock b/uv.lock index 3b6806a3..9016adc4 100644 --- a/uv.lock +++ b/uv.lock @@ -4079,27 +4079,27 @@ wheels = [ [[package]] name = "ty" -version = "0.0.35" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4e/53/440e7b1212c4b0abbd4adb7aed93f4971aa1f8dca386ac5515930afa9172/ty-0.0.35.tar.gz", hash = "sha256:8375c240ab38138a19db07996c9808fb7a92047c1492e1ce587c2ef5112ad3a9", size = 5629237, upload-time = "2026-05-10T18:25:17.105Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/84/19662ee881675815b7fafff940a365be1985730465afd9b75cb2edd5f8b3/ty-0.0.35-py3-none-linux_armv6l.whl", hash = "sha256:85ae1e59b9fb0b40e9d84fe61b29653c5f2f5e78b487ece371a7a38c20c781cf", size = 11198741, upload-time = "2026-05-10T18:24:49.378Z" }, - { url = "https://files.pythonhosted.org/packages/62/df/7e5b6f83d85b4d2e5b72b5dceb388f440acc10679417bd46f829b9200fab/ty-0.0.35-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:709dbb7af4fcadb1196863c00b8791bbbbcc9dacbe15a0ff17f0af82b35d415b", size = 10948304, upload-time = "2026-05-10T18:24:58.246Z" }, - { url = "https://files.pythonhosted.org/packages/59/94/72d7263aca055cde427f0ebcf08d6a74e5a5fee1d1e7fdd553696089cecb/ty-0.0.35-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2cb0877419ab0c8708b6925cb0c2800b263842bd3c425113f200538772f3a0cc", size = 10407413, upload-time = "2026-05-10T18:24:37.422Z" }, - { url = "https://files.pythonhosted.org/packages/b6/23/fda6fae8a81ce0cb5f24cdfe63260e110c7af8844e31fa07d1e6e8ef0232/ty-0.0.35-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e7afbcfc61904b7e82e7fe1a1db832a40d8f01e69dee1775f6594e552980536c", size = 10932614, upload-time = "2026-05-10T18:24:47.401Z" }, - { url = "https://files.pythonhosted.org/packages/72/3d/b98d8d4aa1a5ed6daaf15864e838f605ca7b1e8b93b7e17b96ed4bc4dfed/ty-0.0.35-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b61498cc3e4178031c079951257fbdb209a891b4feb10ad6c40f615a51846f41", size = 10962982, upload-time = "2026-05-10T18:24:44.88Z" }, - { url = "https://files.pythonhosted.org/packages/18/c4/2881aad71bf6fb2f8df17fc8e4bc89e904e54490a3ee747b5ef73f98ac85/ty-0.0.35-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:573b1eacda349fc8dba0d767b41631c3a6f66412363127c5bf2b1b40a1d898d2", size = 11476274, upload-time = "2026-05-10T18:24:42.4Z" }, - { url = "https://files.pythonhosted.org/packages/34/0f/7717650adaeaddd23eea70470e2c26d3f0b9b18fdc7f26ec9552d6001f17/ty-0.0.35-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a7209746158d6393c1040aa64b3ca29622e212ea7d8bae22ba50dbcbb4f96f0a", size = 12012027, upload-time = "2026-05-10T18:25:00.752Z" }, - { url = "https://files.pythonhosted.org/packages/22/c9/1a16cb4aab6f4707d8f550772e91abc26d1c8870f19b5e2453ad10bb8209/ty-0.0.35-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4466a1470aa4418d49a9aa45d9da7de42033addd0a2837c5b2b0eb71d3c2bcd3", size = 11648894, upload-time = "2026-05-10T18:25:12.44Z" }, - { url = "https://files.pythonhosted.org/packages/18/a1/a977c0e07e9f88db9c67f90c6342a4dc4422c8091fa07bf26521870687c5/ty-0.0.35-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb44bb742d52c309dcaa6598bcf4d82eb4bf1241b9e4940461e522e30093fe8b", size = 11560482, upload-time = "2026-05-10T18:25:05.172Z" }, - { url = "https://files.pythonhosted.org/packages/d6/c1/a5fb11227d5cc4ac3f29a115d8c8bc817578e8ef6907d1e4c914ddbf45ee/ty-0.0.35-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:34b219250736c989b2670a03782c61315f523f3a2be37f1f90b1207e2212c188", size = 11718495, upload-time = "2026-05-10T18:24:54.12Z" }, - { url = "https://files.pythonhosted.org/packages/3c/cb/e92e4317388b6d1fd821a46941b448a8a1ff0bf13e22147c5167d8fa1b00/ty-0.0.35-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:88e2ac497decc0940ef1a07571dee8a746112a93a09cdc7f8bca0099752e2e05", size = 10900815, upload-time = "2026-05-10T18:25:02.941Z" }, - { url = "https://files.pythonhosted.org/packages/e9/4f/03bd87388a92567f262f35ac64e10d2be047d258f2dfcf1405f500fa2b90/ty-0.0.35-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:02cae51b53e6ec17d5d827ff1a3a76fd119705b56a92156e04399eda6e911596", size = 10998051, upload-time = "2026-05-10T18:25:14.68Z" }, - { url = "https://files.pythonhosted.org/packages/b4/60/6edbc375ee6073973200096168f644e1081e5e55a7d42596826465b275de/ty-0.0.35-py3-none-musllinux_1_2_i686.whl", hash = "sha256:11871d730c9400d899ac0b9f3d660ed2e7e433377c8725549f8250a36a7f2620", size = 11148910, upload-time = "2026-05-10T18:24:51.842Z" }, - { url = "https://files.pythonhosted.org/packages/4d/b1/a845d2066ed521c477450f436d4bd353d107e7c02dd6536a485944aaf892/ty-0.0.35-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1ad0a2f0530d0933dcc99ad36ac556c63e384ea72ab9a18d23ad2e2c9fd61c73", size = 11671005, upload-time = "2026-05-10T18:24:56.223Z" }, - { url = "https://files.pythonhosted.org/packages/73/81/1d5912a54fb66b2f95ac828ae61d422ef5afeae1263e4d231e40796c229f/ty-0.0.35-py3-none-win32.whl", hash = "sha256:0e25d63ec4ab116e7f6757e44d16ca9216bca679d19ecc36d119cf80faada61a", size = 10481096, upload-time = "2026-05-10T18:24:39.976Z" }, - { url = "https://files.pythonhosted.org/packages/3b/36/1c7f8632bfec1c321f01581d4c940a3617b24bd3e8b37c8a7363d33fbfc4/ty-0.0.35-py3-none-win_amd64.whl", hash = "sha256:6a0a6d259f6f2f8f2f954c6f013d4e0b5eba68af6b353bf19a47d59ec254a3d5", size = 11555691, upload-time = "2026-05-10T18:25:07.792Z" }, - { url = "https://files.pythonhosted.org/packages/7a/fb/59325221bce52f6e833d6865ce8360ef7d5e1e21151b38df6dc77c4327a7/ty-0.0.35-py3-none-win_arm64.whl", hash = "sha256:619c52c0fb2aa21961a848a1995135ad3b6d0a9aa54da0194e60f679cc200e13", size = 10925457, upload-time = "2026-05-10T18:25:10.352Z" }, +version = "0.0.65" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cf561927e8e9ab5c1892a833b664aa9cd6f051a75f6280c66d8047246bda/ty-0.0.65.tar.gz", hash = "sha256:b7134bffcc00b715fa8291e84d845782ced810a998dc1f7f11d71c85c4046325", size = 6460098, upload-time = "2026-07-29T18:31:03.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/4e/71e2d325d2b53a1afad81624ad076b2ede413213fc4a18cb05b78c568571/ty-0.0.65-py3-none-linux_armv6l.whl", hash = "sha256:dc556c9f05408bef4c4ef02b2cc382e4e5f797b4b20d64410289848f0d76705f", size = 12298466, upload-time = "2026-07-29T18:30:12.744Z" }, + { url = "https://files.pythonhosted.org/packages/57/77/fec8f29647c55794efa430a7f365e44f5ce7ffb6459d9445a87fac569bec/ty-0.0.65-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:29d2e0d34cc0a28a17ef0cf81135c5ebabc3562131f9079138ba5e7bae0f56bd", size = 11942421, upload-time = "2026-07-29T18:30:16.076Z" }, + { url = "https://files.pythonhosted.org/packages/13/09/7f3766aef9dc627e2698cf4e3e59cf53389dcae3812040d33c1aa931230f/ty-0.0.65-py3-none-macosx_11_0_arm64.whl", hash = "sha256:685f49a9312bbf69d5b65bbb66384fed1f927403ea030c217b9289092d7e46c4", size = 11451922, upload-time = "2026-07-29T18:30:19.155Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/1a77cd50e0befb50f55b8bf9bd3ed3eddf184bf28c61b56727039e0774fc/ty-0.0.65-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f564b5ebe78e2f3a8e7b8eacb1292eb88b7c0f3c8630671cfca31abc0709cd9", size = 11994999, upload-time = "2026-07-29T18:30:22.315Z" }, + { url = "https://files.pythonhosted.org/packages/63/7b/feda16f3a4a0a99be27431e0c9598eeeec0db1eb2fec9a15976698209418/ty-0.0.65-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c983e156fe9e113fb56389e13d327b6b8549fe866de9b269684723a88e9b732d", size = 12090662, upload-time = "2026-07-29T18:30:24.93Z" }, + { url = "https://files.pythonhosted.org/packages/ed/3e/3f69bf9c9307dbdc0771719f65ce5b556e7bdeeaccbdd599d4f57866d801/ty-0.0.65-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e3663b7396e8b1a9954e20e732de7ccb0192bf4118473069b4945920d6923921", size = 12822094, upload-time = "2026-07-29T18:30:28.012Z" }, + { url = "https://files.pythonhosted.org/packages/90/38/8fa791b3bb503ee2b46ad81690cd1bdd54519582df6d805cee57fe143e85/ty-0.0.65-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:306ed01f29d6e108e98feb233dbbf5878a027603b71bd3743b343977933a9f16", size = 13357833, upload-time = "2026-07-29T18:30:31.122Z" }, + { url = "https://files.pythonhosted.org/packages/c1/73/4dda396a201e1dd0ed3594a9b48e559cb41c4bc048c6cd4c4d1b39eb4313/ty-0.0.65-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28bcfc8898c94f079a9100e684bcf312b6a64ad3a7d4ebb35a4591546030a2cd", size = 12977303, upload-time = "2026-07-29T18:30:33.944Z" }, + { url = "https://files.pythonhosted.org/packages/a5/26/c250c2c569adc53a8591716641388397bcb2a442e4a30b952ae81b50c0e0/ty-0.0.65-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a75bd0c245c38802a8f488378e74f92feb7dd33db7d63fbdd6fdf82791ba730", size = 12579338, upload-time = "2026-07-29T18:30:37.199Z" }, + { url = "https://files.pythonhosted.org/packages/d3/94/4a5647d44753ca218fc930d7e4d9bf468d0ed4a0ad4b3d57588bc1bbacf7/ty-0.0.65-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9e5e1bdea9662d2b5312b4e99f319f4e6e2ea427511b5fbc546141b79ec53f76", size = 12957731, upload-time = "2026-07-29T18:30:39.937Z" }, + { url = "https://files.pythonhosted.org/packages/36/b6/1e22fa11a1e0dfb20b1c7f3cbfd8170273aada2a82f9ecd3055275370c44/ty-0.0.65-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:03a88493d4842889f65280ae241e06b399d57eb3c63571054cad21a4c33b3b69", size = 11938625, upload-time = "2026-07-29T18:30:42.603Z" }, + { url = "https://files.pythonhosted.org/packages/5c/0a/fe5f22ef62b193201bc5566762e22049762cd485bfafb5095a7050760054/ty-0.0.65-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:600b8bf6f4940cf7ffb2f43d3716faaf38dcb97cd8617c55771451bc0276408f", size = 12105592, upload-time = "2026-07-29T18:30:45.419Z" }, + { url = "https://files.pythonhosted.org/packages/76/fd/922b3a6e9d697452cdbb4b7e3f636868add5ec652154518a736e4364f3b7/ty-0.0.65-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0c28007bc79d648c1ddaf1e65885d07baec48eb87240da442f608e4107c1b7d8", size = 12387335, upload-time = "2026-07-29T18:30:48.405Z" }, + { url = "https://files.pythonhosted.org/packages/77/22/a1a08ebc84c083db2fb55e3b5cd186db0c067692f4921146f601360231e2/ty-0.0.65-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c852da96091ad22361e6586b7c7ba98e1334dcd4d8ffb67e47f4fb673de33f77", size = 12682710, upload-time = "2026-07-29T18:30:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/81/14/eaaa410a25bbdea19722109b5422380a0e211b3afcf3071d15953ddbd5db/ty-0.0.65-py3-none-win32.whl", hash = "sha256:cf529d538f1403b14b0511e6ec3cdb95d3d974adabf24cc76cedc533368c3edc", size = 11692341, upload-time = "2026-07-29T18:30:54.35Z" }, + { url = "https://files.pythonhosted.org/packages/bc/0f/6d48f206dce9d7e53fe3b5ea0f0ab5800dd9d2365b2b48f736783436c43f/ty-0.0.65-py3-none-win_amd64.whl", hash = "sha256:234a321e33c7cbbfbd67bfa0b01b685dd9c21f1841781a21e5ca1fa0b25f1d5d", size = 12729355, upload-time = "2026-07-29T18:30:57.275Z" }, + { url = "https://files.pythonhosted.org/packages/96/aa/7446f7725e303cf78e058c893af1f0552b9451895454908706f4c6c3494b/ty-0.0.65-py3-none-win_arm64.whl", hash = "sha256:b9424be1ec56d93ff18609fb1c0a0a2283fe1282cd6d1c7604f97d73b94d61f2", size = 12051375, upload-time = "2026-07-29T18:31:00.579Z" }, ] [[package]]