Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/_pytask/capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
17 changes: 14 additions & 3 deletions src/_pytask/collect_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import sys
from collections import defaultdict
from pathlib import Path
from typing import TYPE_CHECKING
from typing import Any

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
)
Expand Down
8 changes: 6 additions & 2 deletions src/_pytask/config_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,15 +42,19 @@ 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
all_defaults_from_cli = {
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)

Expand Down
37 changes: 33 additions & 4 deletions src/_pytask/data_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"]


Expand All @@ -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__", {}):
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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])
Expand Down
12 changes: 8 additions & 4 deletions src/_pytask/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,26 +7,30 @@
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.

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
Expand All @@ -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
Expand Down
49 changes: 31 additions & 18 deletions src/_pytask/task_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,22 +26,29 @@
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
from _pytask.typing import attach_task_metadata
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]]:
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -254,16 +267,16 @@ 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):
return 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
Expand Down
11 changes: 11 additions & 0 deletions tests/test_data_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions tests/test_task_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from functools import partial
from pathlib import Path
from typing import NamedTuple
from uuid import UUID

import pytest

Expand Down Expand Up @@ -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

Expand Down
14 changes: 14 additions & 0 deletions tests/test_typing.py
Original file line number Diff line number Diff line change
@@ -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(): ...
Expand Down
Loading