From 9dd50181c7ee8c4c5c166b7d0118541ea0e3f6cb Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Thu, 1 Jan 2026 14:27:52 +0100 Subject: [PATCH 1/4] Remove attrs dependency. --- docs_src/how_to_guides/the_data_catalog.py | 4 +- pyproject.toml | 1 - src/_pytask/cache.py | 15 +++--- src/_pytask/clean.py | 4 +- src/_pytask/coiled_utils.py | 5 +- src/_pytask/collect_utils.py | 5 +- src/_pytask/dag_utils.py | 12 ++--- src/_pytask/data_catalog.py | 11 ++++- src/_pytask/explain.py | 12 ++--- src/_pytask/live.py | 17 +++---- src/_pytask/logging.py | 1 + src/_pytask/mark/__init__.py | 6 +-- src/_pytask/mark/expression.py | 5 +- src/_pytask/mark/structures.py | 16 ++++--- src/_pytask/models.py | 17 ++++--- src/_pytask/nodes.py | 54 ++++++++++++---------- src/_pytask/pluginmanager.py | 4 +- src/_pytask/reports.py | 12 ++--- src/_pytask/session.py | 20 ++++---- src/_pytask/task_utils.py | 10 ++-- src/_pytask/typing.py | 5 +- src/_pytask/warnings.py | 4 +- tests/test_collect_command.py | 15 +++--- tests/test_dag.py | 4 +- tests/test_execute.py | 8 ++-- tests/test_node_protocols.py | 18 ++++---- tests/test_nodes.py | 12 ++--- tests/test_task_utils.py | 10 ++-- uv.lock | 2 - 29 files changed, 159 insertions(+), 150 deletions(-) diff --git a/docs_src/how_to_guides/the_data_catalog.py b/docs_src/how_to_guides/the_data_catalog.py index 19e80b82e..27fe8a482 100644 --- a/docs_src/how_to_guides/the_data_catalog.py +++ b/docs_src/how_to_guides/the_data_catalog.py @@ -1,11 +1,11 @@ +from dataclasses import dataclass from pathlib import Path from typing import Any import cloudpickle -from attrs import define -@define +@dataclass class PickleNode: """A node for pickle files. diff --git a/pyproject.toml b/pyproject.toml index 70aa5f19b..5040f46c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,6 @@ classifiers = [ ] dynamic = ["version"] dependencies = [ - "attrs>=21.3.0", "click>=8.1.8,!=8.2.0", "click-default-group>=1.2.4", "networkx>=2.4.0", diff --git a/src/_pytask/cache.py b/src/_pytask/cache.py index 517c89b8d..c3e851fcd 100644 --- a/src/_pytask/cache.py +++ b/src/_pytask/cache.py @@ -5,6 +5,8 @@ import functools import hashlib import inspect +from dataclasses import dataclass +from dataclasses import field from inspect import FullArgSpec from typing import TYPE_CHECKING from typing import Any @@ -12,9 +14,6 @@ from typing import Protocol from typing import TypeVar -from attrs import define -from attrs import field - from _pytask._hashlib import hash_value if TYPE_CHECKING: @@ -35,17 +34,17 @@ class HasCache(Protocol): cache: Cache -@define +@dataclass class CacheInfo: hits: int = 0 misses: int = 0 -@define +@dataclass class Cache: - _cache: dict[str, Any] = field(factory=dict) - _sentinel: Any = field(factory=object) - cache_info: CacheInfo = field(factory=CacheInfo) + _cache: dict[str, Any] = field(default_factory=dict) + _sentinel: Any = field(default_factory=object) + cache_info: CacheInfo = field(default_factory=CacheInfo) def memoize(self, func: Callable[P, R]) -> Memoized[P, R]: func_module = getattr(func, "__module__", "") diff --git a/src/_pytask/clean.py b/src/_pytask/clean.py index 39030e8f0..bc9a6cde0 100644 --- a/src/_pytask/clean.py +++ b/src/_pytask/clean.py @@ -6,11 +6,11 @@ import itertools import shutil import sys +from dataclasses import dataclass from typing import TYPE_CHECKING from typing import Any import click -from attrs import define from _pytask.click import ColoredCommand from _pytask.click import EnumChoice @@ -243,7 +243,7 @@ def _find_all_unknown_paths( ) -@define(repr=False) +@dataclass(repr=False) class _RecursivePathNode: """A class for a path to a file or directory which recursively instantiates itself. diff --git a/src/_pytask/coiled_utils.py b/src/_pytask/coiled_utils.py index 6896c0343..68d6469ee 100644 --- a/src/_pytask/coiled_utils.py +++ b/src/_pytask/coiled_utils.py @@ -1,10 +1,9 @@ from __future__ import annotations +from dataclasses import dataclass from typing import TYPE_CHECKING from typing import Any -from attrs import define - if TYPE_CHECKING: from collections.abc import Callable @@ -12,7 +11,7 @@ from coiled.function import Function except ImportError: - @define + @dataclass class Function: cluster_kwargs: dict[str, Any] environ: dict[str, Any] diff --git a/src/_pytask/collect_utils.py b/src/_pytask/collect_utils.py index b09dcff90..36a4a3993 100644 --- a/src/_pytask/collect_utils.py +++ b/src/_pytask/collect_utils.py @@ -3,13 +3,12 @@ from __future__ import annotations import inspect +from dataclasses import replace from typing import TYPE_CHECKING from typing import Annotated from typing import Any from typing import get_origin -import attrs - from _pytask._inspect import get_annotations from _pytask.exceptions import NodeNotCollectedError from _pytask.models import NodeInfo @@ -308,7 +307,7 @@ def collect_dependency( # If a node is a dependency and its value is not set, the node is a product in # another task and the value will be set there. Thus, we wrap the original node # in another node to retrieve the value after it is set. - new_node = attrs.evolve(node, value=node) + new_node = replace(node, value=node) node_info = node_info._replace(value=new_node) collected_node = session.hook.pytask_collect_node( diff --git a/src/_pytask/dag_utils.py b/src/_pytask/dag_utils.py index c01147e3e..b1861a77a 100644 --- a/src/_pytask/dag_utils.py +++ b/src/_pytask/dag_utils.py @@ -3,11 +3,11 @@ from __future__ import annotations import itertools +from dataclasses import dataclass +from dataclasses import field from typing import TYPE_CHECKING import networkx as nx -from attrs import define -from attrs import field from _pytask.mark_utils import has_mark @@ -61,7 +61,7 @@ def node_and_neighbors(dag: nx.DiGraph, node: str) -> Iterable[str]: return itertools.chain(dag.predecessors(node), [node], dag.successors(node)) -@define +@dataclass class TopologicalSorter: """The topological sorter class. @@ -78,9 +78,9 @@ class TopologicalSorter: """ dag: nx.DiGraph - priorities: dict[str, int] = field(factory=dict) - _nodes_processing: set[str] = field(factory=set) - _nodes_done: set[str] = field(factory=set) + priorities: dict[str, int] = field(default_factory=dict) + _nodes_processing: set[str] = field(default_factory=set) + _nodes_done: set[str] = field(default_factory=set) @classmethod def from_dag(cls, dag: nx.DiGraph) -> TopologicalSorter: diff --git a/src/_pytask/data_catalog.py b/src/_pytask/data_catalog.py index 175f2246e..6531d1724 100644 --- a/src/_pytask/data_catalog.py +++ b/src/_pytask/data_catalog.py @@ -20,7 +20,6 @@ from _pytask.exceptions import NodeNotCollectedError from _pytask.models import NodeInfo from _pytask.node_protocols import PNode -from _pytask.node_protocols import PPathNode from _pytask.node_protocols import PProvisionalNode from _pytask.node_protocols import warn_about_upcoming_attributes_field_on_nodes from _pytask.nodes import PickleNode @@ -39,6 +38,14 @@ 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: + """Return True if the class looks like a path-based node.""" + for cls in node_type.__mro__: + if "path" in getattr(cls, "__annotations__", {}): + return True + return False + + @dataclass(kw_only=True) class DataCatalog: """A data catalog. @@ -115,7 +122,7 @@ def add(self, name: str, node: PNode | PProvisionalNode | Any = None) -> None: if node is None: filename = hashlib.sha256(name.encode()).hexdigest() - if isinstance(self.default_node, PPathNode): + if _is_path_node_type(self.default_node): assert self.path is not None self._entries[name] = self.default_node( name=name, path=self.path / f"{filename}.pkl" diff --git a/src/_pytask/explain.py b/src/_pytask/explain.py index 28dd59ee5..eb721620e 100644 --- a/src/_pytask/explain.py +++ b/src/_pytask/explain.py @@ -2,12 +2,12 @@ from __future__ import annotations +from dataclasses import dataclass +from dataclasses import field from typing import TYPE_CHECKING from typing import Any from typing import Literal -from attrs import define -from attrs import field from rich.text import Text from _pytask.console import console @@ -34,14 +34,14 @@ ] -@define +@dataclass class ChangeReason: """Represents a reason why a node changed.""" node_name: str node_type: NodeType reason: ReasonType - details: dict[str, Any] = field(factory=dict) + details: dict[str, Any] = field(default_factory=dict) verbose: int = 1 def __rich_console__( @@ -71,11 +71,11 @@ def __rich_console__( yield Text(f" • {self.node_name}: {self.reason}") -@define +@dataclass class TaskExplanation: """Represents the explanation for why a task needs to be executed.""" - reasons: list[ChangeReason] = field(factory=list) + reasons: list[ChangeReason] = field(default_factory=list) task: PTask | None = None outcome: TaskOutcome | None = None verbose: int = 1 diff --git a/src/_pytask/live.py b/src/_pytask/live.py index 577d08b1d..754467f65 100644 --- a/src/_pytask/live.py +++ b/src/_pytask/live.py @@ -3,13 +3,12 @@ from __future__ import annotations from dataclasses import dataclass +from dataclasses import field from typing import TYPE_CHECKING from typing import Any from typing import NamedTuple import click -from attrs import define -from attrs import field from rich.box import ROUNDED from rich.errors import LiveError from rich.live import Live @@ -85,7 +84,7 @@ def pytask_execute(session: Session) -> Generator[None, None, None]: return (yield) -@define(eq=False) +@dataclass(eq=False) class LiveManager: """A class for live displays during a session. @@ -106,7 +105,9 @@ class LiveManager: """ _live: Live = field( - factory=lambda: Live(renderable=None, console=console, auto_refresh=False) + default_factory=lambda: Live( + renderable=None, console=console, auto_refresh=False + ) ) def start(self) -> None: @@ -157,7 +158,7 @@ class _ReportEntry(NamedTuple): task: PTask -@define(eq=False, kw_only=True) +@dataclass(eq=False, kw_only=True) class LiveExecution: """A class for managing the table displaying task progress during the execution.""" @@ -168,8 +169,8 @@ class LiveExecution: initial_status: TaskExecutionStatus = TaskExecutionStatus.RUNNING sort_final_table: bool = False n_tasks: int | str = "x" - _reports: list[_ReportEntry] = field(factory=list) - _running_tasks: dict[str, _TaskEntry] = field(factory=dict) + _reports: list[_ReportEntry] = field(default_factory=list) + _running_tasks: dict[str, _TaskEntry] = field(default_factory=dict) @hookimpl(wrapper=True) def pytask_execute_build(self) -> Generator[None, None, None]: @@ -306,7 +307,7 @@ def update_report(self, new_report: ExecutionReport) -> None: self._update_table() -@define(eq=False, kw_only=True) +@dataclass(eq=False, kw_only=True) class LiveCollection: """A class for managing the live status during the collection.""" diff --git a/src/_pytask/logging.py b/src/_pytask/logging.py index cb309b88a..c203824d1 100644 --- a/src/_pytask/logging.py +++ b/src/_pytask/logging.py @@ -68,6 +68,7 @@ def pytask_log_session_header(session: Session) -> None: f"Platform: {sys.platform} -- Python {platform.python_version()}, " f"pytask {_pytask.__version__}, pluggy {pluggy.__version__}", highlight=False, + soft_wrap=True, ) console.print(f"Root: {session.config['root']}") if session.config["config"] is not None: diff --git a/src/_pytask/mark/__init__.py b/src/_pytask/mark/__init__.py index fd4a2f8ca..ee12856d6 100644 --- a/src/_pytask/mark/__init__.py +++ b/src/_pytask/mark/__init__.py @@ -3,11 +3,11 @@ from __future__ import annotations import sys +from dataclasses import dataclass from typing import TYPE_CHECKING from typing import Any import click -from attrs import define from rich.table import Table from _pytask.click import ColoredCommand @@ -117,7 +117,7 @@ def pytask_post_parse(config: dict[str, Any]) -> None: config["markers"] = parse_markers(config["markers"]) -@define(slots=True) +@dataclass(slots=True) class KeywordMatcher: """A matcher for keywords. @@ -189,7 +189,7 @@ def select_by_after_keyword(session: Session, after: str) -> set[str]: return ancestors -@define(slots=True) +@dataclass(slots=True) class MarkMatcher: """A matcher for markers which are present. diff --git a/src/_pytask/mark/expression.py b/src/_pytask/mark/expression.py index ec415b9ac..0c96d7ed3 100644 --- a/src/_pytask/mark/expression.py +++ b/src/_pytask/mark/expression.py @@ -31,10 +31,9 @@ from collections.abc import Iterator from collections.abc import Mapping from collections.abc import Sequence +from dataclasses import dataclass from typing import TYPE_CHECKING -from attrs import define - if TYPE_CHECKING: import types from typing import NoReturn @@ -53,7 +52,7 @@ class TokenType(enum.Enum): EOF = "end of input" -@define(frozen=True, slots=True) +@dataclass(frozen=True, slots=True) class Token: type_: TokenType value: str diff --git a/src/_pytask/mark/structures.py b/src/_pytask/mark/structures.py index 2dbb61b3b..ecbed3794 100644 --- a/src/_pytask/mark/structures.py +++ b/src/_pytask/mark/structures.py @@ -1,13 +1,10 @@ from __future__ import annotations import warnings +from dataclasses import dataclass from typing import TYPE_CHECKING from typing import Any -from attrs import define -from attrs import field -from attrs import validators - from _pytask.mark_utils import get_all_marks from _pytask.models import CollectionMetadata from _pytask.typing import TaskFunction @@ -19,7 +16,7 @@ from collections.abc import Mapping -@define(frozen=True) +@dataclass(frozen=True) class Mark: """A class for a mark containing the name, positional and keyword arguments. @@ -58,7 +55,7 @@ def combined_with(self, other: Mark) -> Mark: return Mark(self.name, self.args + other.args, {**self.kwargs, **other.kwargs}) -@define +@dataclass class MarkDecorator: """A decorator for applying a mark on task function. @@ -94,7 +91,12 @@ def task_function(): """ - mark: Mark = field(validator=validators.instance_of(Mark)) + mark: Mark + + def __post_init__(self) -> None: + if not isinstance(self.mark, Mark): + msg = f"'mark' must be a Mark instance, got {type(self.mark)!r}." + raise TypeError(msg) @property def name(self) -> str: diff --git a/src/_pytask/models.py b/src/_pytask/models.py index 3b12d442f..047948e4f 100644 --- a/src/_pytask/models.py +++ b/src/_pytask/models.py @@ -2,15 +2,14 @@ from __future__ import annotations +from dataclasses import dataclass +from dataclasses import field from typing import TYPE_CHECKING from typing import Any from typing import NamedTuple from uuid import UUID from uuid import uuid4 -from attrs import define -from attrs import field - if TYPE_CHECKING: from collections.abc import Callable from pathlib import Path @@ -19,7 +18,7 @@ from _pytask.tree_util import PyTree -@define +@dataclass class CollectionMetadata: """A class for carrying metadata from functions to tasks. @@ -52,16 +51,16 @@ class CollectionMetadata: information. """ - after: str | list[Callable[..., Any]] = field(factory=list) - attributes: dict[str, Any] = field(factory=dict) + after: str | list[Callable[..., Any]] = field(default_factory=list) + attributes: dict[str, Any] = field(default_factory=dict) annotation_locals: dict[str, Any] | None = None is_generator: bool = False id_: str | None = None - kwargs: dict[str, Any] = field(factory=dict) - markers: list[Mark] = field(factory=list) + kwargs: dict[str, Any] = field(default_factory=dict) + markers: list[Mark] = field(default_factory=list) name: str | None = None produces: PyTree[Any] | None = None - _id: UUID = field(factory=uuid4) + _id: UUID = field(default_factory=uuid4) class NodeInfo(NamedTuple): diff --git a/src/_pytask/nodes.py b/src/_pytask/nodes.py index 4a4425474..bf1cc825a 100644 --- a/src/_pytask/nodes.py +++ b/src/_pytask/nodes.py @@ -6,13 +6,12 @@ import inspect import pickle from contextlib import suppress +from dataclasses import dataclass +from dataclasses import field from os import stat_result -from pathlib import Path # noqa: TC003 from typing import TYPE_CHECKING from typing import Any -from attrs import define -from attrs import field from typing_extensions import deprecated from upath import UPath from upath._stat import UPathStatResult @@ -31,6 +30,7 @@ from collections.abc import Callable from io import BufferedReader from io import BufferedWriter + from pathlib import Path from _pytask.mark import Mark from _pytask.models import NodeInfo @@ -48,7 +48,7 @@ ] -@define(kw_only=True) +@dataclass(kw_only=True) class TaskWithoutPath(PTask): """The class for tasks without a source file. @@ -77,11 +77,13 @@ class TaskWithoutPath(PTask): name: str function: Callable[..., Any] - depends_on: dict[str, PyTree[PNode | PProvisionalNode]] = field(factory=dict) - produces: dict[str, PyTree[PNode | PProvisionalNode]] = field(factory=dict) - markers: list[Mark] = field(factory=list) - report_sections: list[tuple[str, str, str]] = field(factory=list) - attributes: dict[Any, Any] = field(factory=dict) + depends_on: dict[str, PyTree[PNode | PProvisionalNode]] = field( + default_factory=dict + ) + produces: dict[str, PyTree[PNode | PProvisionalNode]] = field(default_factory=dict) + markers: list[Mark] = field(default_factory=list) + report_sections: list[tuple[str, str, str]] = field(default_factory=list) + attributes: dict[Any, Any] = field(default_factory=dict) @property def signature(self) -> str: @@ -100,7 +102,7 @@ def execute(self, **kwargs: Any) -> Any: return self.function(**kwargs) -@define(kw_only=True) +@dataclass(kw_only=True) class Task(PTaskWithPath): """The class for tasks which are Python functions. @@ -131,13 +133,15 @@ class Task(PTaskWithPath): path: Path function: Callable[..., Any] name: str = field(default="", init=False) - depends_on: dict[str, PyTree[PNode | PProvisionalNode]] = field(factory=dict) - produces: dict[str, PyTree[PNode | PProvisionalNode]] = field(factory=dict) - markers: list[Mark] = field(factory=list) - report_sections: list[tuple[str, str, str]] = field(factory=list) - attributes: dict[Any, Any] = field(factory=dict) - - def __attrs_post_init__(self: Task) -> None: + depends_on: dict[str, PyTree[PNode | PProvisionalNode]] = field( + default_factory=dict + ) + produces: dict[str, PyTree[PNode | PProvisionalNode]] = field(default_factory=dict) + markers: list[Mark] = field(default_factory=list) + report_sections: list[tuple[str, str, str]] = field(default_factory=list) + attributes: dict[Any, Any] = field(default_factory=dict) + + def __post_init__(self: Task) -> None: """Change class after initialization.""" if not self.name: self.name = self.path.as_posix() + "::" + self.base_name @@ -157,7 +161,7 @@ def execute(self, **kwargs: Any) -> Any: return self.function(**kwargs) -@define(kw_only=True) +@dataclass(kw_only=True) class PathNode(PPathNode): """The class for a node which is a path. @@ -174,7 +178,7 @@ class PathNode(PPathNode): path: Path name: str = "" - attributes: dict[Any, Any] = field(factory=dict) + attributes: dict[Any, Any] = field(default_factory=dict) @property def signature(self) -> str: @@ -210,7 +214,7 @@ def save(self, value: bytes | str) -> None: raise TypeError(msg) -@define(kw_only=True) +@dataclass(kw_only=True) class PythonNode(PNode): """The class for a node which is a Python object. @@ -247,7 +251,7 @@ class PythonNode(PNode): value: Any | NoDefault = no_default hash: bool | Callable[[Any], int | str] = False node_info: NodeInfo | None = None - attributes: dict[Any, Any] = field(factory=dict) + attributes: dict[Any, Any] = field(default_factory=dict) @property def signature(self) -> str: @@ -303,7 +307,7 @@ def state(self) -> str | None: return "0" -@define +@dataclass class PickleNode(PPathNode): """A node for pickle files. @@ -324,7 +328,7 @@ class PickleNode(PPathNode): path: Path name: str = "" - attributes: dict[Any, Any] = field(factory=dict) + attributes: dict[Any, Any] = field(default_factory=dict) serializer: Callable[[Any, BufferedWriter], None] = field(default=pickle.dump) deserializer: Callable[[BufferedReader], Any] = field(default=pickle.load) @@ -356,7 +360,7 @@ def save(self, value: Any) -> None: self.serializer(value, f) -@define(kw_only=True) +@dataclass(kw_only=True) class DirectoryNode(PProvisionalNode): """The class for a provisional node that works with directories. @@ -378,7 +382,7 @@ class DirectoryNode(PProvisionalNode): name: str = "" pattern: str = "*" root_dir: Path | None = None - attributes: dict[Any, Any] = field(factory=dict) + attributes: dict[Any, Any] = field(default_factory=dict) @property def signature(self) -> str: diff --git a/src/_pytask/pluginmanager.py b/src/_pytask/pluginmanager.py index 2a7ef4a69..26add9e51 100644 --- a/src/_pytask/pluginmanager.py +++ b/src/_pytask/pluginmanager.py @@ -4,9 +4,9 @@ import importlib import sys +from dataclasses import dataclass from typing import TYPE_CHECKING -from attrs import define from pluggy import HookimplMarker from pluggy import PluginManager @@ -78,7 +78,7 @@ def get_plugin_manager() -> PluginManager: return pm -@define +@dataclass class _PluginManagerStorage: """A class to store the plugin manager. diff --git a/src/_pytask/reports.py b/src/_pytask/reports.py index 9c74335b9..0779b9678 100644 --- a/src/_pytask/reports.py +++ b/src/_pytask/reports.py @@ -2,11 +2,11 @@ from __future__ import annotations +from dataclasses import dataclass +from dataclasses import field from typing import TYPE_CHECKING from typing import ClassVar -from attrs import define -from attrs import field from rich.rule import Rule from rich.text import Text @@ -27,7 +27,7 @@ from _pytask.node_protocols import PTask -@define +@dataclass class CollectionReport: """A collection report for a task.""" @@ -58,7 +58,7 @@ def __rich_console__( yield "" -@define +@dataclass class DagReport: """A report for an error during the creation of the DAG.""" @@ -75,14 +75,14 @@ def __rich_console__( yield traceback -@define +@dataclass class ExecutionReport: """A report for an executed task.""" task: PTask outcome: TaskOutcome exc_info: OptionalExceptionInfo | None = None - sections: list[tuple[str, str, str]] = field(factory=list) + sections: list[tuple[str, str, str]] = field(default_factory=list) editor_url_scheme: ClassVar[str] = "file" show_locals: ClassVar[bool] = False diff --git a/src/_pytask/session.py b/src/_pytask/session.py index 871503b6f..79f7f06c0 100644 --- a/src/_pytask/session.py +++ b/src/_pytask/session.py @@ -2,12 +2,12 @@ from __future__ import annotations +from dataclasses import dataclass +from dataclasses import field from typing import TYPE_CHECKING from typing import Any import networkx as nx -from attrs import define -from attrs import field from pluggy import HookRelay from _pytask.outcomes import ExitCode @@ -20,7 +20,7 @@ from _pytask.warnings_utils import WarningReport -@define(kw_only=True) +@dataclass(kw_only=True) class Session: """The session of pytask. @@ -49,13 +49,13 @@ class Session: """ - config: dict[str, Any] = field(factory=dict) - collection_reports: list[CollectionReport] = field(factory=list) - dag: nx.DiGraph = field(factory=nx.DiGraph) - hook: HookRelay = field(factory=HookRelay) - tasks: list[PTask] = field(factory=list) + config: dict[str, Any] = field(default_factory=dict) + collection_reports: list[CollectionReport] = field(default_factory=list) + dag: nx.DiGraph = field(default_factory=nx.DiGraph) + hook: HookRelay = field(default_factory=HookRelay) + tasks: list[PTask] = field(default_factory=list) dag_report: DagReport | None = None - execution_reports: list[ExecutionReport] = field(factory=list) + execution_reports: list[ExecutionReport] = field(default_factory=list) exit_code: ExitCode = ExitCode.OK collection_start: float = float("inf") @@ -66,7 +66,7 @@ class Session: n_tasks_failed: int = 0 scheduler: Any = None should_stop: bool = False - warnings: list[WarningReport] = field(factory=list) + warnings: list[WarningReport] = field(default_factory=list) @classmethod def from_config(cls, config: dict[str, Any]) -> Session: diff --git a/src/_pytask/task_utils.py b/src/_pytask/task_utils.py index dd92b521e..0395a01f3 100644 --- a/src/_pytask/task_utils.py +++ b/src/_pytask/task_utils.py @@ -7,14 +7,14 @@ import inspect import sys from collections import defaultdict +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 TypeVar from typing import cast -import attrs - from _pytask.coiled_utils import Function from _pytask.coiled_utils import extract_coiled_function_kwargs from _pytask.console import get_file @@ -292,11 +292,11 @@ def _parse_task_kwargs(kwargs: Any) -> dict[str, Any]: # Handle namedtuples. if callable(getattr(kwargs, "_asdict", None)): return kwargs._asdict() - if attrs.has(type(kwargs)): - return attrs.asdict(kwargs) + if is_dataclass(kwargs) and not isinstance(kwargs, type): + return asdict(kwargs) msg = ( "'@task(kwargs=...) needs to be a dictionary, namedtuple or an " - "instance of an attrs class." + "instance of a dataclass." ) raise ValueError(msg) diff --git a/src/_pytask/typing.py b/src/_pytask/typing.py index 984e0b844..a3ee26f77 100644 --- a/src/_pytask/typing.py +++ b/src/_pytask/typing.py @@ -1,6 +1,7 @@ from __future__ import annotations import functools +from dataclasses import dataclass from enum import Enum from typing import TYPE_CHECKING from typing import Any @@ -9,8 +10,6 @@ from typing import Protocol from typing import runtime_checkable -from attrs import define - if TYPE_CHECKING: from typing import TypeAlias @@ -40,7 +39,7 @@ class TaskFunction(Protocol): pytask_meta: CollectionMetadata -@define(frozen=True) +@dataclass(frozen=True) class ProductType: """A class to mark products.""" diff --git a/src/_pytask/warnings.py b/src/_pytask/warnings.py index 3b2325e81..1062d6ae1 100644 --- a/src/_pytask/warnings.py +++ b/src/_pytask/warnings.py @@ -3,11 +3,11 @@ from __future__ import annotations from collections import defaultdict +from dataclasses import dataclass from typing import TYPE_CHECKING from typing import Any import click -from attrs import define from rich.padding import Padding from rich.panel import Panel @@ -89,7 +89,7 @@ def pytask_log_session_footer(session: Session) -> None: console.print(panel) -@define +@dataclass class _WarningsRenderable: """A renderable for warnings.""" diff --git a/tests/test_collect_command.py b/tests/test_collect_command.py index 0a4c76b5b..e287c154b 100644 --- a/tests/test_collect_command.py +++ b/tests/test_collect_command.py @@ -3,10 +3,10 @@ import pickle import sys import textwrap +from dataclasses import dataclass from pathlib import Path import pytest -from attrs import define from _pytask.collect_command import _find_common_ancestor_of_all_nodes from _pytask.collect_command import _print_collected_tasks @@ -304,7 +304,7 @@ def task_example_2(path=Path("in_2.txt"), produces=Path("out_2.txt")): ... assert "out_1.txt>" in captured -@define +@dataclass class Node: path: str @@ -469,10 +469,10 @@ def test_node_protocol_for_custom_nodes(runner, tmp_path): source = """ from typing import Annotated from pytask import Product - from attrs import define + from dataclasses import dataclass from pathlib import Path - @define + @dataclass class CustomNode: name: str value: str @@ -503,15 +503,16 @@ def test_node_protocol_for_custom_nodes_with_paths(runner, tmp_path): from typing import Any from pytask import Product from pathlib import Path - from attrs import define + from dataclasses import dataclass + from dataclasses import field import pickle - @define + @dataclass class PickleFile: name: str path: Path signature: str = "id" - attributes: dict[Any, Any] = {} + attributes: dict[Any, Any] = field(default_factory=dict) def state(self): return str(self.path.stat().st_mtime) diff --git a/tests/test_dag.py b/tests/test_dag.py index 612886168..8a81a0905 100644 --- a/tests/test_dag.py +++ b/tests/test_dag.py @@ -52,7 +52,7 @@ def task_2(path = Path("out_1.txt"), produces = Path("out_2.txt")): result = runner.invoke(cli, [tmp_path.as_posix()]) assert result.exit_code == ExitCode.DAG_FAILED - if sys.platform == "linux": + if sys.platform != "win32": assert result.output == snapshot_cli() @@ -71,7 +71,7 @@ def task_2(produces = Path("out.txt")): result = runner.invoke(cli, [tmp_path.as_posix()]) assert result.exit_code == ExitCode.DAG_FAILED - if sys.platform == "linux": + if sys.platform != "win32": assert result.output == snapshot_cli() diff --git a/tests/test_execute.py b/tests/test_execute.py index 6e76db2de..cd5175cdf 100644 --- a/tests/test_execute.py +++ b/tests/test_execute.py @@ -420,10 +420,10 @@ def test_custom_node_as_product(runner, tmp_path, product_def, return_def): import pickle from typing import Any from typing import Annotated - import attrs + from dataclasses import dataclass from pytask import Product - @attrs.define + @dataclass class PickleNode: path: Path name: str = "" @@ -750,10 +750,10 @@ def test_errors_during_loading_nodes_have_info(runner, tmp_path): from __future__ import annotations from pathlib import Path from typing import Any - import attrs + from dataclasses import dataclass import pickle - @attrs.define + @dataclass class PickleNode: name: str path: Path diff --git a/tests/test_node_protocols.py b/tests/test_node_protocols.py index faa3015be..697d627a9 100644 --- a/tests/test_node_protocols.py +++ b/tests/test_node_protocols.py @@ -12,15 +12,16 @@ def test_node_protocol_for_custom_nodes(runner, tmp_path): from typing import Annotated from typing import Any from pytask import Product - from attrs import define + from dataclasses import dataclass + from dataclasses import field from pathlib import Path - @define + @dataclass class CustomNode: name: str value: str signature: str = "id" - attributes: dict[Any, Any] = {} + attributes: dict[Any, Any] = field(default_factory=dict) def state(self): return self.value @@ -51,16 +52,17 @@ def test_node_protocol_for_custom_nodes_with_paths(runner, tmp_path): from typing import Any from pytask import Product from pathlib import Path - from attrs import define + from dataclasses import dataclass + from dataclasses import field import pickle - @define + @dataclass class PickleFile: name: str path: Path value: Path signature: str = "id" - attributes: dict[Any, Any] = {} + attributes: dict[Any, Any] = field(default_factory=dict) def state(self): return str(self.path.stat().st_mtime) @@ -94,10 +96,10 @@ def test_node_protocol_for_custom_nodes_adding_attributes(runner, tmp_path): source = """ from typing import Annotated from pytask import Product - from attrs import define + from dataclasses import dataclass from pathlib import Path - @define + @dataclass class CustomNode: name: str value: str diff --git a/tests/test_nodes.py b/tests/test_nodes.py index 0084349dd..4461aa79f 100644 --- a/tests/test_nodes.py +++ b/tests/test_nodes.py @@ -121,12 +121,12 @@ def test_hash_of_pickle_node(tmp_path, value, exists, expected): @pytest.mark.parametrize( ("node", "protocol", "expected"), [ - (PathNode, PNode, True), - (PathNode, PPathNode, True), - (PythonNode, PNode, True), - (PythonNode, PPathNode, False), - (PickleNode, PNode, True), - (PickleNode, PPathNode, True), + (PathNode(name="pathnode", path=Path("file.txt")), PNode, True), + (PathNode(name="pathnode", path=Path("file.txt")), PPathNode, True), + (PythonNode(name="node", value=None), PNode, True), + (PythonNode(name="node", value=None), PPathNode, False), + (PickleNode(name="node", path=Path("file.pkl")), PNode, True), + (PickleNode(name="node", path=Path("file.pkl")), PPathNode, True), ], ) def test_comply_with_protocol(node, protocol, expected): diff --git a/tests/test_task_utils.py b/tests/test_task_utils.py index d642a26ce..b49862727 100644 --- a/tests/test_task_utils.py +++ b/tests/test_task_utils.py @@ -1,12 +1,12 @@ from __future__ import annotations from contextlib import ExitStack as does_not_raise # noqa: N813 +from dataclasses import dataclass from functools import partial from pathlib import Path from typing import NamedTuple import pytest -from attrs import define from _pytask.task_utils import COLLECTED_TASKS from _pytask.task_utils import _arg_value_to_id_component @@ -41,8 +41,8 @@ class ExampleNT(NamedTuple): a: int = 1 -@define -class ExampleAttrs: +@dataclass +class ExampleDataclass: b: str = "wonderful" @@ -52,8 +52,8 @@ class ExampleAttrs: ({"hello": 1}, does_not_raise(), {"hello": 1}), (ExampleNT(), does_not_raise(), {"a": 1}), (ExampleNT, pytest.raises(TypeError, match=r"(_asdict\(\) missing 1)"), None), - (ExampleAttrs(), does_not_raise(), {"b": "wonderful"}), - (ExampleAttrs, pytest.raises(ValueError, match="@task"), None), + (ExampleDataclass(), does_not_raise(), {"b": "wonderful"}), + (ExampleDataclass, pytest.raises(ValueError, match="@task"), None), (1, pytest.raises(ValueError, match="@task"), None), ], ) diff --git a/uv.lock b/uv.lock index da73057c4..e131fe275 100644 --- a/uv.lock +++ b/uv.lock @@ -2654,7 +2654,6 @@ wheels = [ name = "pytask" source = { editable = "." } dependencies = [ - { name = "attrs" }, { name = "click" }, { name = "click-default-group" }, { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -2714,7 +2713,6 @@ typing = [ [package.metadata] requires-dist = [ - { name = "attrs", specifier = ">=21.3.0" }, { name = "click", specifier = ">=8.1.8,!=8.2.0" }, { name = "click-default-group", specifier = ">=1.2.4" }, { name = "networkx", specifier = ">=2.4.0" }, From 73174bb588dbb524f89b8d4fed737f7175a00a37 Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Thu, 1 Jan 2026 15:06:23 +0100 Subject: [PATCH 2/4] Fix. --- tests/test_path.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_path.py b/tests/test_path.py index 55d7edc72..4d72932ff 100644 --- a/tests/test_path.py +++ b/tests/test_path.py @@ -50,7 +50,7 @@ def test_relative_to(path, source, include_source, expected): ) def test_find_closest_ancestor(monkeypatch, path, potential_ancestors, expected): # Ensures that files are detected by an existing suffix not if they also exist. - monkeypatch.setattr("_pytask.nodes.Path.is_file", lambda x: bool(x.suffix)) + monkeypatch.setattr(Path, "is_file", lambda x: bool(x.suffix)) result = find_closest_ancestor(path, potential_ancestors) assert result == expected From c8d0c41dc7710031b9e2e3d90612feaa49f3ce7d Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Thu, 1 Jan 2026 15:21:44 +0100 Subject: [PATCH 3/4] fix. --- docs/source/reference_guides/api.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/source/reference_guides/api.md b/docs/source/reference_guides/api.md index 6777d793f..20b25ace0 100644 --- a/docs/source/reference_guides/api.md +++ b/docs/source/reference_guides/api.md @@ -203,6 +203,7 @@ Nodes are the interface for different kinds of dependencies or products. :members: .. autoclass:: pytask.PickleNode :members: + :exclude-members: serializer, deserializer .. autoclass:: pytask.PythonNode :members: .. autoclass:: pytask.DirectoryNode From dbebdc30bdd590980607bb5e6dd3d3115a46613d Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Thu, 1 Jan 2026 15:57:42 +0100 Subject: [PATCH 4/4] Changelog entry. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 080bef0bb..f79f38664 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ releases are available on [PyPI](https://pypi.org/project/pytask) and ## Unreleased -- Nothing yet. +- Removed the direct dependency on attrs and migrated internal models to dataclasses. ## 0.5.8 - 2025-12-30