From 991f3c2ba31ebed9f8e1ef2565c2f5dc4f76b7f6 Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Tue, 14 Nov 2023 19:11:45 +0100 Subject: [PATCH 1/4] Be even lazier. --- docs/source/changes.md | 5 +++- src/_pytask/collect.py | 10 ++++++- src/_pytask/dag.py | 40 ------------------------- src/_pytask/dag_utils.py | 27 +++-------------- src/_pytask/execute.py | 50 +++++++++++++++++++++---------- src/_pytask/persist.py | 13 ++++++++- tests/test_collect.py | 18 ++++++++++++ tests/test_dag_utils.py | 15 +--------- tests/test_execute.py | 63 +++++++++++++++++++++------------------- 9 files changed, 115 insertions(+), 126 deletions(-) diff --git a/docs/source/changes.md b/docs/source/changes.md index 7ac04f26..40cd0251 100644 --- a/docs/source/changes.md +++ b/docs/source/changes.md @@ -18,8 +18,11 @@ releases are available on [PyPI](https://pypi.org/project/pytask) and when a product annotation is used with the argument name `produces`. And, allow `produces` to intake any node. - {pull}`490` refactors and better tests parsing of dependencies. +- {pull}`496` makes pytask even lazier. Now, when a task produces a node whose hash + remains the same, the consecutive tasks are not executed. It remained from when pytask + relied on timestamps. -## 0.4.2 - 2023-11-8 +## 0.4.2 - 2023-11-08 - {pull}`449` simplifies the code building the plugin manager. - {pull}`451` improves `collect_command.py` and renames `graph.py` to `dag_command.py`. diff --git a/src/_pytask/collect.py b/src/_pytask/collect.py index 5f35dce9..6fcb1206 100644 --- a/src/_pytask/collect.py +++ b/src/_pytask/collect.py @@ -23,6 +23,7 @@ from _pytask.console import is_jupyter from _pytask.exceptions import CollectionError from _pytask.mark import MarkGenerator +from _pytask.mark_utils import get_all_marks from _pytask.mark_utils import has_mark from _pytask.node_protocols import PNode from _pytask.node_protocols import PPathNode @@ -246,6 +247,13 @@ def pytask_collect_task( """ if (name.startswith("task_") or has_mark(obj, "task")) and is_task_function(obj): + if has_mark(obj, "try_first") and has_mark(obj, "try_last"): + msg = ( + "The task cannot have mixed priorities. Do not apply " + "'@pytask.mark.try_first' and '@pytask.mark.try_last' at the same time." + ) + raise ValueError(msg) + path_nodes = Path.cwd() if path is None else path.parent dependencies = parse_dependencies_from_task_function( session, path, name, path_nodes, obj @@ -254,7 +262,7 @@ def pytask_collect_task( session, path, name, path_nodes, obj ) - markers = obj.pytask_meta.markers if hasattr(obj, "pytask_meta") else [] + markers = get_all_marks(obj) # Get the underlying function to avoid having different states of the function, # e.g. due to pytask_meta, in different layers of the wrapping. diff --git a/src/_pytask/dag.py b/src/_pytask/dag.py index 07ab23b5..0e221664 100644 --- a/src/_pytask/dag.py +++ b/src/_pytask/dag.py @@ -14,13 +14,9 @@ from _pytask.console import format_task_name from _pytask.console import render_to_string from _pytask.console import TASK_ICON -from _pytask.dag_utils import node_and_neighbors -from _pytask.dag_utils import task_and_descending_tasks -from _pytask.dag_utils import TopologicalSorter from _pytask.database_utils import DatabaseSession from _pytask.database_utils import State from _pytask.exceptions import ResolvingDependenciesError -from _pytask.mark import Mark from _pytask.node_protocols import PNode from _pytask.node_protocols import PTask from _pytask.nodes import PythonNode @@ -44,7 +40,6 @@ def pytask_dag(session: Session) -> bool | None: session=session, tasks=session.tasks ) session.hook.pytask_dag_modify_dag(session=session, dag=session.dag) - session.hook.pytask_dag_select_execution_dag(session=session, dag=session.dag) except Exception: # noqa: BLE001 report = DagReport.from_exception(sys.exc_info()) @@ -101,41 +96,6 @@ def _add_product(dag: nx.DiGraph, task: PTask, node: PNode) -> None: return dag -@hookimpl -def pytask_dag_select_execution_dag(session: Session, dag: nx.DiGraph) -> None: - """Select the tasks which need to be executed.""" - scheduler = TopologicalSorter.from_dag(dag) - visited_nodes: set[str] = set() - - while scheduler.is_active(): - task_signature = scheduler.get_ready()[0] - if task_signature not in visited_nodes: - task = dag.nodes[task_signature]["task"] - have_changed = _have_task_or_neighbors_changed(session, dag, task) - if have_changed: - visited_nodes.update(task_and_descending_tasks(task_signature, dag)) - else: - dag.nodes[task_signature]["task"].markers.append( - Mark("skip_unchanged", (), {}) - ) - scheduler.done(task_signature) - - -def _have_task_or_neighbors_changed( - session: Session, dag: nx.DiGraph, task: PTask -) -> bool: - """Indicate whether dependencies or products of a task have changed.""" - return any( - session.hook.pytask_dag_has_node_changed( - session=session, - dag=dag, - task=task, - node=dag.nodes[node_name].get("task") or dag.nodes[node_name].get("node"), - ) - for node_name in node_and_neighbors(dag, task.signature) - ) - - @hookimpl(trylast=True) def pytask_dag_has_node_changed(task: PTask, node: MetaNode) -> bool: """Indicate whether a single dependency or product has changed.""" diff --git a/src/_pytask/dag_utils.py b/src/_pytask/dag_utils.py index cb359e0c..c66c46ec 100644 --- a/src/_pytask/dag_utils.py +++ b/src/_pytask/dag_utils.py @@ -7,9 +7,6 @@ from typing import TYPE_CHECKING import networkx as nx -from _pytask.console import format_strings_as_flat_tree -from _pytask.console import format_task_name -from _pytask.console import TASK_ICON from _pytask.mark_utils import has_mark from attrs import define from attrs import field @@ -54,8 +51,11 @@ def node_and_neighbors(dag: nx.DiGraph, node: str) -> Iterable[str]: We cannot use ``dag.neighbors`` as it only considers successors as neighbors in a DAG. + The task node needs to be yield in the middle so that first predecessors are checked + and then the rest of the nodes. + """ - return itertools.chain([node], dag.predecessors(node), dag.successors(node)) + return itertools.chain(dag.predecessors(node), [node], dag.successors(node)) @define @@ -166,25 +166,6 @@ def _extract_priorities_from_tasks(tasks: list[PTask]) -> dict[str, int]: } for task in tasks } - tasks_w_mixed_priorities = [ - name for name, p in priorities.items() if p["try_first"] and p["try_last"] - ] - - if tasks_w_mixed_priorities: - name_to_task = {task.signature: task for task in tasks} - reduced_names = [] - for name in tasks_w_mixed_priorities: - reduced_name = format_task_name(name_to_task[name], "no_link") - reduced_names.append(reduced_name.plain) - - text = format_strings_as_flat_tree( - reduced_names, "Tasks with mixed priorities", TASK_ICON - ) - msg = ( - f"'try_first' and 'try_last' cannot be applied on the same task. See the " - f"following tasks for errors:\n\n{text}" - ) - raise ValueError(msg) # Recode to numeric values for sorting. numeric_mapping = {(True, False): 1, (False, False): 0, (False, True): -1} diff --git a/src/_pytask/execute.py b/src/_pytask/execute.py index b7dbd5f8..8baa30ee 100644 --- a/src/_pytask/execute.py +++ b/src/_pytask/execute.py @@ -16,6 +16,7 @@ from _pytask.console import format_strings_as_flat_tree from _pytask.console import unify_styles from _pytask.dag_utils import descending_tasks +from _pytask.dag_utils import node_and_neighbors from _pytask.dag_utils import TopologicalSorter from _pytask.database_utils import update_states_in_database from _pytask.exceptions import ExecutionError @@ -28,6 +29,7 @@ from _pytask.node_protocols import PTask from _pytask.outcomes import count_outcomes from _pytask.outcomes import Exit +from _pytask.outcomes import SkippedUnchanged from _pytask.outcomes import TaskOutcome from _pytask.outcomes import WouldBeExecuted from _pytask.reports import ExecutionReport @@ -124,28 +126,44 @@ def pytask_execute_task_setup(session: Session, task: PTask) -> None: 2. Create the directory where the product will be placed. """ - for dependency in session.dag.predecessors(task.signature): - node = session.dag.nodes[dependency]["node"] - if not node.state(): - msg = f"{task.name!r} requires missing node {node.name!r}." - if IS_FILE_SYSTEM_CASE_SENSITIVE: - msg += ( - "\n\n(Hint: Your file-system is case-sensitive. Check the paths' " - "capitalization carefully.)" - ) - raise NodeNotFoundError(msg) + if has_mark(task, "would_be_executed"): + raise WouldBeExecuted + + dag = session.dag + + needs_to_be_executed = session.config["force"] + if not needs_to_be_executed: + predecessors = set(dag.predecessors(task.signature)) | {task.signature} + for node_signature in node_and_neighbors(dag, task.signature): + node = dag.nodes[node_signature].get("task") or dag.nodes[ + node_signature + ].get("node") + if node_signature in predecessors and not node.state(): + msg = f"{task.name!r} requires missing node {node.name!r}." + if IS_FILE_SYSTEM_CASE_SENSITIVE: + msg += ( + "\n\n(Hint: Your file-system is case-sensitive. Check the " + "paths' capitalization carefully.)" + ) + raise NodeNotFoundError(msg) + + has_changed = session.hook.pytask_dag_has_node_changed( + session=session, dag=dag, task=task, node=node + ) + if has_changed: + needs_to_be_executed = True + break + + if not needs_to_be_executed: + raise SkippedUnchanged # Create directory for product if it does not exist. Maybe this should be a `setup` # method for the node classes. - for product in session.dag.successors(task.signature): - node = session.dag.nodes[product]["node"] + for product in dag.successors(task.signature): + node = dag.nodes[product]["node"] if isinstance(node, PPathNode): node.path.parent.mkdir(parents=True, exist_ok=True) - would_be_executed = has_mark(task, "would_be_executed") - if would_be_executed: - raise WouldBeExecuted - def _safe_load(node: PNode, task: PTask, is_product: bool) -> Any: try: diff --git a/src/_pytask/persist.py b/src/_pytask/persist.py index 7880e37c..dd6027c7 100644 --- a/src/_pytask/persist.py +++ b/src/_pytask/persist.py @@ -46,7 +46,18 @@ def pytask_execute_task_setup(session: Session, task: PTask) -> None: ) if all_nodes_exist: - raise Persisted + any_node_changed = any( + session.hook.pytask_dag_has_node_changed( + session=session, + dag=session.dag, + task=task, + node=session.dag.nodes[name].get("task") + or session.dag.nodes[name]["node"], + ) + for name in node_and_neighbors(session.dag, task.signature) + ) + if any_node_changed: + raise Persisted @hookimpl diff --git a/tests/test_collect.py b/tests/test_collect.py index e17622b5..78902669 100644 --- a/tests/test_collect.py +++ b/tests/test_collect.py @@ -661,3 +661,21 @@ def task_example() -> Annotated[int, 1]: ... result = runner.invoke(cli, [tmp_path.as_posix()]) assert result.exit_code == ExitCode.COLLECTION_FAILED assert "The return annotation of the task" in result.output + + +@pytest.mark.end_to_end() +def test_scheduling_w_mixed_priorities(runner, tmp_path): + source = """ + import pytask + + @pytask.mark.try_last + @pytask.mark.try_first + def task_mixed(): pass + """ + tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) + + result = runner.invoke(cli, [tmp_path.as_posix()]) + + assert result.exit_code == ExitCode.COLLECTION_FAILED + assert "Could not collect" in result.output + assert "The task cannot have" in result.output diff --git a/tests/test_dag_utils.py b/tests/test_dag_utils.py index 158322fa..6226783a 100644 --- a/tests/test_dag_utils.py +++ b/tests/test_dag_utils.py @@ -75,7 +75,7 @@ def test_node_and_neighbors(dag): if dag.nodes[sig]["task"].name == f".::{i}" ) nodes = node_and_neighbors(dag, task.signature) - node_names = sorted(dag.nodes[sig]["task"].name for sig in nodes) + node_names = [dag.nodes[sig]["task"].name for sig in nodes] assert node_names == [f".::{j}" for j in range(i - 1, i + 2)] @@ -115,19 +115,6 @@ def test_node_and_neighbors(dag): {"c12d8d4f7e2e3128d27878d1fb3d8e3583e90e68000a13634dfbf21f4d1456f3": 0}, id="test no priority", ), - pytest.param( - [ - Task( - base_name="1", - path=Path(), - function=None, - markers=[Mark("try_first", (), {}), Mark("try_last", (), {})], - ) - ], - pytest.raises(ValueError, match="'try_first' and 'try_last' cannot be"), - {".::1": 1}, - id="test mixed priorities", - ), pytest.param( [ Task( diff --git a/tests/test_execute.py b/tests/test_execute.py index a78af8ae..d1c7df1f 100644 --- a/tests/test_execute.py +++ b/tests/test_execute.py @@ -85,21 +85,18 @@ def test_node_not_found_in_task_setup(tmp_path): """ source = """ import pytask + from typing_extensions import Annotated + from pathlib import Path - @pytask.mark.produces(["out_1.txt", "deleted.txt"]) - def task_1(produces): - for product in produces.values(): - product.touch() + def task_1() -> Annotated[..., (Path("out_1.txt"), Path("deleted.txt"))]: + return "", "" - @pytask.mark.depends_on("out_1.txt") - @pytask.mark.produces("out_2.txt") - def task_2(depends_on, produces): - depends_on.with_name("deleted.txt").unlink() - produces.touch() + def task_2(path = Path("out_1.txt")) -> Annotated[..., Path("out_2.txt")]: + path.with_name("deleted.txt").unlink() + return "" - @pytask.mark.depends_on(["deleted.txt", "out_2.txt"]) - def task_3(depends_on): - pass + def task_3(paths = [Path("deleted.txt"), Path("out_2.txt")]): + ... """ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) @@ -302,24 +299,6 @@ def task_y(): pass assert session.execution_reports[2].task.name.endswith("task_y") -@pytest.mark.end_to_end() -def test_scheduling_w_mixed_priorities(runner, tmp_path): - source = """ - import pytask - - @pytask.mark.try_last - @pytask.mark.try_first - def task_mixed(): pass - """ - tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) - - result = runner.invoke(cli, [tmp_path.as_posix()]) - - assert result.exit_code == ExitCode.DAG_FAILED - assert "Failures during resolving dependencies" in result.output - assert "'try_first' and 'try_last' cannot be applied" in result.output - - @pytest.mark.end_to_end() @pytest.mark.parametrize("show_errors_immediately", [True, False]) def test_show_errors_immediately(runner, tmp_path, show_errors_immediately): @@ -988,3 +967,27 @@ def task_example(a = PythonNode(value={"a": 1}, hash=True)): result = runner.invoke(cli, [tmp_path.as_posix()]) assert result.exit_code == ExitCode.FAILED assert "TypeError: unhashable type: 'dict'" in result.output + + +def test_task_is_not_reexecuted(runner, tmp_path): + source = """ + from typing_extensions import Annotated + from pathlib import Path + + def task_first() -> Annotated[str, Path("out.txt")]: + return "Hello, World!" + + def task_second(path = Path("out.txt")) -> Annotated[str, Path("copy.txt")]: + return path.read_text() + """ + tmp_path.joinpath("task_example.py").write_text(textwrap.dedent(source)) + + result = runner.invoke(cli, [tmp_path.as_posix()]) + assert result.exit_code == ExitCode.OK + assert "2 Succeeded" in result.output + + tmp_path.joinpath("out.txt").write_text("Changed text.") + result = runner.invoke(cli, [tmp_path.as_posix()]) + assert result.exit_code == ExitCode.OK + assert "1 Succeeded" in result.output + assert "1 Skipped because unchanged" in result.output From ceaf152edcf4dd027dd9f6d6ccdd75630bf28914 Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Tue, 14 Nov 2023 19:26:54 +0100 Subject: [PATCH 2/4] Be even lazier. --- docs/source/reference_guides/hookspecs.md | 1 - src/_pytask/dag.py | 21 --------------------- src/_pytask/database_utils.py | 19 +++++++++++++++++++ src/_pytask/execute.py | 5 ++--- src/_pytask/hookspecs.py | 23 ----------------------- src/_pytask/persist.py | 5 ++--- 6 files changed, 23 insertions(+), 51 deletions(-) diff --git a/docs/source/reference_guides/hookspecs.md b/docs/source/reference_guides/hookspecs.md index e902fce8..10a2512d 100644 --- a/docs/source/reference_guides/hookspecs.md +++ b/docs/source/reference_guides/hookspecs.md @@ -77,7 +77,6 @@ your plugin. ```{eval-rst} .. autofunction:: pytask_dag .. autofunction:: pytask_dag_create_dag -.. autofunction:: pytask_dag_select_execution_dag .. autofunction:: pytask_dag_log ``` diff --git a/src/_pytask/dag.py b/src/_pytask/dag.py index 0e221664..d36e6c57 100644 --- a/src/_pytask/dag.py +++ b/src/_pytask/dag.py @@ -14,8 +14,6 @@ from _pytask.console import format_task_name from _pytask.console import render_to_string from _pytask.console import TASK_ICON -from _pytask.database_utils import DatabaseSession -from _pytask.database_utils import State from _pytask.exceptions import ResolvingDependenciesError from _pytask.node_protocols import PNode from _pytask.node_protocols import PTask @@ -27,7 +25,6 @@ from rich.tree import Tree if TYPE_CHECKING: - from _pytask.node_protocols import MetaNode from pathlib import Path from _pytask.session import Session @@ -96,24 +93,6 @@ def _add_product(dag: nx.DiGraph, task: PTask, node: PNode) -> None: return dag -@hookimpl(trylast=True) -def pytask_dag_has_node_changed(task: PTask, node: MetaNode) -> bool: - """Indicate whether a single dependency or product has changed.""" - # If node does not exist, we receive None. - node_state = node.state() - if node_state is None: - return True - - with DatabaseSession() as session: - db_state = session.get(State, (task.signature, node.signature)) - - # If the node is not in the database. - if db_state is None: - return True - - return node_state != db_state.hash_ - - def _check_if_dag_has_cycles(dag: nx.DiGraph) -> None: """Check if DAG has cycles.""" try: diff --git a/src/_pytask/database_utils.py b/src/_pytask/database_utils.py index 8ea53d07..6c9c7553 100644 --- a/src/_pytask/database_utils.py +++ b/src/_pytask/database_utils.py @@ -11,6 +11,8 @@ from sqlalchemy.orm import sessionmaker if TYPE_CHECKING: + from _pytask.node_protocols import MetaNode + from _pytask.node_protocols import PTask from _pytask.session import Session @@ -62,3 +64,20 @@ def update_states_in_database(session: Session, task_signature: str) -> None: node = session.dag.nodes[name].get("task") or session.dag.nodes[name]["node"] hash_ = node.state() _create_or_update_state(task_signature, node.signature, hash_) + + +def has_node_changed(task: PTask, node: MetaNode) -> bool: + """Indicate whether a single dependency or product has changed.""" + # If node does not exist, we receive None. + node_state = node.state() + if node_state is None: + return True + + with DatabaseSession() as session: + db_state = session.get(State, (task.signature, node.signature)) + + # If the node is not in the database. + if db_state is None: + return True + + return node_state != db_state.hash_ diff --git a/src/_pytask/execute.py b/src/_pytask/execute.py index 8baa30ee..59edb8c6 100644 --- a/src/_pytask/execute.py +++ b/src/_pytask/execute.py @@ -18,6 +18,7 @@ from _pytask.dag_utils import descending_tasks from _pytask.dag_utils import node_and_neighbors from _pytask.dag_utils import TopologicalSorter +from _pytask.database_utils import has_node_changed from _pytask.database_utils import update_states_in_database from _pytask.exceptions import ExecutionError from _pytask.exceptions import NodeLoadError @@ -147,9 +148,7 @@ def pytask_execute_task_setup(session: Session, task: PTask) -> None: ) raise NodeNotFoundError(msg) - has_changed = session.hook.pytask_dag_has_node_changed( - session=session, dag=dag, task=task, node=node - ) + has_changed = has_node_changed(task=task, node=node) if has_changed: needs_to_be_executed = True break diff --git a/src/_pytask/hookspecs.py b/src/_pytask/hookspecs.py index 1ed4e841..8e944e9f 100644 --- a/src/_pytask/hookspecs.py +++ b/src/_pytask/hookspecs.py @@ -13,7 +13,6 @@ if TYPE_CHECKING: - from _pytask.node_protocols import MetaNode from _pytask.models import NodeInfo from _pytask.node_protocols import PNode import click @@ -245,28 +244,6 @@ def pytask_dag_modify_dag(session: Session, dag: nx.DiGraph) -> None: """ -@hookspec -def pytask_dag_select_execution_dag(session: Session, dag: nx.DiGraph) -> None: - """Select the subgraph which needs to be executed. - - This hook determines which of the tasks have to be re-run because something has - changed. - - """ - - -@hookspec(firstresult=True) -def pytask_dag_has_node_changed( - session: Session, dag: nx.DiGraph, task: PTask, node: MetaNode -) -> None: - """Select the subgraph which needs to be executed. - - This hook determines which of the tasks have to be re-run because something has - changed. - - """ - - @hookspec def pytask_dag_log(session: Session, report: DagReport) -> None: """Log errors during resolving dependencies.""" diff --git a/src/_pytask/persist.py b/src/_pytask/persist.py index dd6027c7..49b61510 100644 --- a/src/_pytask/persist.py +++ b/src/_pytask/persist.py @@ -6,6 +6,7 @@ from _pytask.config import hookimpl from _pytask.dag_utils import node_and_neighbors +from _pytask.database_utils import has_node_changed from _pytask.database_utils import update_states_in_database from _pytask.mark_utils import has_mark from _pytask.outcomes import Persisted @@ -47,9 +48,7 @@ def pytask_execute_task_setup(session: Session, task: PTask) -> None: if all_nodes_exist: any_node_changed = any( - session.hook.pytask_dag_has_node_changed( - session=session, - dag=session.dag, + has_node_changed( task=task, node=session.dag.nodes[name].get("task") or session.dag.nodes[name]["node"], From cff0828e0133ba2a7aa50b03e9f2f52c3b594f1f Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Tue, 14 Nov 2023 19:35:09 +0100 Subject: [PATCH 3/4] fix. --- tests/test_execute.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_execute.py b/tests/test_execute.py index d1c7df1f..e3b4bdac 100644 --- a/tests/test_execute.py +++ b/tests/test_execute.py @@ -88,10 +88,10 @@ def test_node_not_found_in_task_setup(tmp_path): from typing_extensions import Annotated from pathlib import Path - def task_1() -> Annotated[..., (Path("out_1.txt"), Path("deleted.txt"))]: + def task_1() -> Annotated[None, (Path("out_1.txt"), Path("deleted.txt"))]: return "", "" - def task_2(path = Path("out_1.txt")) -> Annotated[..., Path("out_2.txt")]: + def task_2(path = Path("out_1.txt")) -> Annotated[str, Path("out_2.txt")]: path.with_name("deleted.txt").unlink() return "" From 3f6d04d82afc9346d1204cbbff59c5f1c3a92561 Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Tue, 14 Nov 2023 19:45:51 +0100 Subject: [PATCH 4/4] Fix. --- src/_pytask/mark/structures.py | 3 ++- tests/test_mark_utils.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/_pytask/mark/structures.py b/src/_pytask/mark/structures.py index de2592ab..ae077b13 100644 --- a/src/_pytask/mark/structures.py +++ b/src/_pytask/mark/structures.py @@ -6,6 +6,7 @@ from typing import Iterable from typing import Mapping +from _pytask.mark_utils import get_all_marks from _pytask.models import CollectionMetadata from _pytask.typing import is_task_function from attrs import define @@ -122,7 +123,7 @@ def __call__(self, *args: Any, **kwargs: Any) -> MarkDecorator: def get_unpacked_marks(obj: Callable[..., Any]) -> list[Mark]: """Obtain the unpacked marks that are stored on an object.""" - mark_list = obj.pytask_meta.markers if hasattr(obj, "pytask_meta") else [] + mark_list = get_all_marks(obj) return normalize_mark_list(mark_list) diff --git a/tests/test_mark_utils.py b/tests/test_mark_utils.py index aad16886..0050acea 100644 --- a/tests/test_mark_utils.py +++ b/tests/test_mark_utils.py @@ -219,7 +219,7 @@ def func(): func.pytask_meta = CollectionMetadata(markers=markers) obj, result_markers = remove_marks(func, marker_name) - markers = obj.pytask_meta.markers if hasattr(obj, "pytask_meta") else [] + markers = get_all_marks(obj) assert markers == expected_others assert result_markers == expected_markers