From 3dbcfb5f743fb9175e6fafc792803d6306e9bbbe Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Sat, 20 Jan 2024 23:42:38 +0100 Subject: [PATCH 01/11] Remove depends_on and produces markers. --- docs/source/_static/md/markers.md | 8 - docs/source/changes.md | 5 + .../interfaces_for_dependencies_products.md | 26 +- .../defining_dependencies_products.md | 175 +---------- .../repeating_tasks_with_different_inputs.md | 60 ---- docs/source/tutorials/write_a_task.md | 19 -- ...encies_products_dependencies_decorators.py | 18 -- ...pendencies_products_products_decorators.py | 20 -- ...pendencies_products_relative_decorators.py | 8 - ...tasks_with_different_inputs1_decorators.py | 12 - ...tasks_with_different_inputs2_decorators.py | 16 - ...tasks_with_different_inputs3_decorators.py | 13 - ...tasks_with_different_inputs4_decorators.py | 12 - ...tasks_with_different_inputs5_decorators.py | 22 -- docs_src/tutorials/write_a_task_decorators.py | 21 -- src/_pytask/collect.py | 5 + src/_pytask/collect_utils.py | 295 +----------------- src/_pytask/config.py | 8 - src/_pytask/mark/__init__.pyi | 15 - src/_pytask/mark/structures.py | 12 +- src/_pytask/models.py | 16 + src/pytask/__init__.py | 4 - tests/test_build.py | 13 +- tests/test_clean.py | 14 +- tests/test_collect.py | 158 +--------- tests/test_collect_command.py | 82 ++--- tests/test_collect_utils.py | 158 +--------- tests/test_dag.py | 23 +- tests/test_dag_command.py | 18 +- tests/test_database.py | 8 +- tests/test_debugging.py | 12 +- tests/test_dry_run.py | 48 ++- tests/test_execute.py | 165 ++-------- tests/test_live.py | 11 +- tests/test_mark.py | 79 +---- tests/test_mark_cli.py | 7 +- tests/test_mark_utils.py | 94 +++--- tests/test_persist.py | 12 +- tests/test_profile.py | 5 +- tests/test_skipping.py | 58 ++-- tests/test_task.py | 182 ++++++----- tests/test_tree_util.py | 42 ++- 42 files changed, 366 insertions(+), 1613 deletions(-) delete mode 100644 docs_src/tutorials/defining_dependencies_products_dependencies_decorators.py delete mode 100644 docs_src/tutorials/defining_dependencies_products_products_decorators.py delete mode 100644 docs_src/tutorials/defining_dependencies_products_relative_decorators.py delete mode 100644 docs_src/tutorials/repeating_tasks_with_different_inputs1_decorators.py delete mode 100644 docs_src/tutorials/repeating_tasks_with_different_inputs2_decorators.py delete mode 100644 docs_src/tutorials/repeating_tasks_with_different_inputs3_decorators.py delete mode 100644 docs_src/tutorials/repeating_tasks_with_different_inputs4_decorators.py delete mode 100644 docs_src/tutorials/repeating_tasks_with_different_inputs5_decorators.py delete mode 100644 docs_src/tutorials/write_a_task_decorators.py diff --git a/docs/source/_static/md/markers.md b/docs/source/_static/md/markers.md index 2ce8c7d7..99e8c202 100644 --- a/docs/source/_static/md/markers.md +++ b/docs/source/_static/md/markers.md @@ -6,10 +6,6 @@ $ pytask markers ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ Marker ┃ Description ┃ ┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ -│ pytask.mark.depends_on │ Add dependencies to a task. See this │ -│ │ tutorial for more information: │ -│ │ https://bit.ly/3JlxylS. │ -│ │ │ │ pytask.mark.persist │ Prevent execution of a task if all │ │ │ products exist and even ifsomething has │ │ │ changed (dependencies, source file, │ @@ -21,10 +17,6 @@ $ pytask markers │ │ another run will skip the task with │ │ │ success. │ │ │ │ -│ pytask.mark.produces │ Add products to a task. See this │ -│ │ tutorial for more information: │ -│ │ https://bit.ly/3JlxylS. │ -│ │ │ │ pytask.mark.skip │ Skip a task and all its dependent tasks.│ │ │ │ │ pytask.mark.skip_ancestor_failed │ Internal decorator applied to tasks if │ diff --git a/docs/source/changes.md b/docs/source/changes.md index ebea8973..06de04e7 100644 --- a/docs/source/changes.md +++ b/docs/source/changes.md @@ -5,6 +5,11 @@ chronological order. Releases follow [semantic versioning](https://semver.org/) releases are available on [PyPI](https://pypi.org/project/pytask) and [Anaconda.org](https://anaconda.org/conda-forge/pytask). +## 0.5.0 - 2024-xx-xx + +- \{pull}\`\` removes the deprecated `@pytask.mark.depends_on` and + `@pytask.mark.produces`. + ## 0.4.6 - {pull}`548` fixes the type hints for {meth}`~pytask.Task.execute` and diff --git a/docs/source/how_to_guides/interfaces_for_dependencies_products.md b/docs/source/how_to_guides/interfaces_for_dependencies_products.md index 774240cb..5a20a4a6 100644 --- a/docs/source/how_to_guides/interfaces_for_dependencies_products.md +++ b/docs/source/how_to_guides/interfaces_for_dependencies_products.md @@ -16,12 +16,12 @@ In general, pytask regards everything as a task dependency if it is not marked a product. Thus, you can also think of the following examples as how to inject values into a task. When we talk about products later, the same interfaces will be used. -| | `def task(arg: ... = ...)` | `Annotated[..., value]` | `@task(kwargs=...)` | `@pytask.mark.depends_on(...)` | -| --------------------------------------- | :------------------------: | :---------------------: | :-----------------: | :----------------------------: | -| Not deprecated | ✅ | ✅ | ✅ | ❌ | -| No type annotations required | ✅ | ❌ | ✅ | ✅ | -| Flexible choice of argument name | ✅ | ✅ | ✅ | ❌ | -| Supports third-party functions as tasks | ❌ | ❌ | ✅ | ❌ | +| | `def task(arg: ... = ...)` | `Annotated[..., value]` | `@task(kwargs=...)` | +| --------------------------------------- | :------------------------: | :---------------------: | :-----------------: | +| Not deprecated | ✅ | ✅ | ✅ | +| No type annotations required | ✅ | ❌ | ✅ | +| Flexible choice of argument name | ✅ | ✅ | ✅ | +| Supports third-party functions as tasks | ❌ | ❌ | ✅ | (default-argument)= @@ -58,13 +58,13 @@ dictionary. It applies to dependencies and products alike. ## Products -| | `def task(arg: Annotated[..., Product] = ...)` | `Annotated[..., value, Product]` | `produces` | `@task(produces=...)` | `def task() -> Annotated[..., value]` | `@pytask.mark.produces(...)` | -| --------------------------------------------------------- | :--------------------------------------------: | :------------------------------: | :--------: | :-------------------: | :-----------------------------------: | :--------------------------: | -| Not deprecated | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | -| No type annotations required | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ | -| Flexible choice of argument name | ✅ | ✅ | ❌ | ✅ | ➖ | ❌ | -| Supports third-party functions as tasks | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | -| Allows to pass custom node while preserving type of value | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | +| | `def task(arg: Annotated[..., Product] = ...)` | `Annotated[..., value, Product]` | `produces` | `@task(produces=...)` | `def task() -> Annotated[..., value]` | +| --------------------------------------------------------- | :--------------------------------------------: | :------------------------------: | :--------: | :-------------------: | :-----------------------------------: | +| Not deprecated | ✅ | ✅ | ✅ | ✅ | ✅ | +| No type annotations required | ❌ | ❌ | ✅ | ✅ | ❌ | +| Flexible choice of argument name | ✅ | ✅ | ❌ | ✅ | ➖ | +| Supports third-party functions as tasks | ❌ | ❌ | ❌ | ✅ | ❌ | +| Allows to pass custom node while preserving type of value | ❌ | ✅ | ✅ | ✅ | ✅ | ### `Product` annotation diff --git a/docs/source/tutorials/defining_dependencies_products.md b/docs/source/tutorials/defining_dependencies_products.md index 0472b666..71831554 100644 --- a/docs/source/tutorials/defining_dependencies_products.md +++ b/docs/source/tutorials/defining_dependencies_products.md @@ -11,11 +11,6 @@ You find a tutorial on type hints {doc}`here <../type_hints>`. If you want to avoid type annotations for now, look at the tab named `produces`. -```{warning} -The `Decorators` tab documents the deprecated approach that should not be used anymore -and will be removed in version v0.5. -``` - ```{seealso} In this tutorial, we only deal with local files. If you want to use pytask with files online, S3, GCP, Azure, etc., read the @@ -89,26 +84,6 @@ passed to this argument is automatically treated as a task product. Here, we pas path as the default argument. ```` - -````{tab-item} Decorators -:sync: decorators - -```{warning} -This approach is deprecated and will be removed in v0.5 -``` - -```{literalinclude} ../../../docs_src/tutorials/defining_dependencies_products_products_decorators.py -:emphasize-lines: 9, 10 -``` - -The {func}`@pytask.mark.produces ` marker attaches a product to a -task. After the task has finished, pytask will check whether the file exists. - -Add `produces` as an argument of the task function to get access to the same path inside -the task function. - -```` - ````` ```{tip} @@ -170,24 +145,6 @@ pytask assumes that all function arguments that are not passed to the argument :emphasize-lines: 9 ``` -```` - -````{tab-item} Decorators -:sync: decorators - -```{warning} -This approach is deprecated and will be removed in v0.5 -``` - -Equivalent to products, you can use the -{func}`@pytask.mark.depends_on ` decorator to specify that -`data.pkl` is a dependency of the task. Use `depends_on` as a function argument to -access the dependency path inside the function and load the data. - -```{literalinclude} ../../../docs_src/tutorials/defining_dependencies_products_dependencies_decorators.py -:emphasize-lines: 9, 11 -``` - ```` ````` @@ -228,25 +185,6 @@ are assumed to point to a location relative to the task module. :emphasize-lines: 4 ``` -```` - -````{tab-item} Decorators -:sync: decorators - -```{warning} -This approach is deprecated and will be removed in v0.5 -``` - -You can also use absolute and relative paths as strings that obey the same rules as the -{class}`pathlib.Path`. - -```{literalinclude} ../../../docs_src/tutorials/defining_dependencies_products_relative_decorators.py -:emphasize-lines: 6 -``` - -If you use `depends_on` or `produces` as arguments for the task function, you will have -access to the paths of the targets as {class}`pathlib.Path`. - ```` ````` @@ -286,7 +224,7 @@ structures if needed. ```` -````{tab-item} prodouces +````{tab-item} produces :sync: produces If your task has multiple products, group them in one container like a dictionary @@ -300,117 +238,6 @@ You can do the same with dependencies. ```{literalinclude} ../../../docs_src/tutorials/defining_dependencies_products_multiple2_produces.py ``` -```` - -````{tab-item} Decorators -:sync: decorators - -```{warning} -This approach is deprecated and will be removed in v0.5 -``` - -The easiest way to attach multiple dependencies or products to a task is to pass a -{class}`dict` (highly recommended), {class}`list`, or another iterator to the marker -containing the paths. - -To assign labels to dependencies or products, pass a dictionary. For example, - -```python -from typing import Dict - - -@pytask.mark.produces({"first": BLD / "data_0.pkl", "second": BLD / "data_1.pkl"}) -def task_create_random_data(produces: Dict[str, Path]) -> None: - ... -``` - -Then, use `produces` inside the task function. - -```pycon ->>> produces["first"] -BLD / "data_0.pkl" - ->>> produces["second"] -BLD / "data_1.pkl" -``` - -You can also use lists and other iterables. - -```python -@pytask.mark.produces([BLD / "data_0.pkl", BLD / "data_1.pkl"]) -def task_create_random_data(produces): - ... -``` - -Inside the function, the arguments `depends_on` or `produces` become a dictionary where -keys are the positions in the list. - -```pycon ->>> produces -{0: BLD / "data_0.pkl", 1: BLD / "data_1.pkl"} -``` - -Why does pytask recommend dictionaries and convert lists, tuples, or other -iterators to dictionaries? First, dictionaries with positions as keys behave very -similarly to lists. - -Secondly, dictionary keys are more descriptive and do not assume a fixed -ordering. Both attributes are especially desirable in complex projects. - -**Multiple decorators** - -pytask merges multiple decorators of one kind into a single dictionary. This might help -you to group dependencies and apply them to multiple tasks. - -```python -common_dependencies = pytask.mark.depends_on( - {"first_text": "text_1.txt", "second_text": "text_2.txt"} -) - - -@common_dependencies -@pytask.mark.depends_on("text_3.txt") -def task_example(depends_on): - ... -``` - -Inside the task, `depends_on` will be - -```pycon ->>> depends_on -{"first_text": ... / "text_1.txt", "second_text": "text_2.txt", 0: "text_3.txt"} -``` - -**Nested dependencies and products** - -Dependencies and products can be nested containers consisting of tuples, lists, and -dictionaries. It is beneficial if you want more structure and nesting. - -Here is an example of a task that fits some model on data. It depends on a module -containing the code for the model, which is not actively used but ensures that the task -is rerun when the model is changed. And it depends on the data. - -```python -@pytask.mark.depends_on( - { - "model": [SRC / "models" / "model.py"], - "data": {"a": SRC / "data" / "a.pkl", "b": SRC / "data" / "b.pkl"}, - } -) -@pytask.mark.produces(BLD / "models" / "fitted_model.pkl") -def task_fit_model(depends_on, produces): - ... -``` - -`depends_on` within the function will be - -```python -{ - "model": [SRC / "models" / "model.py"], - "data": {"a": SRC / "data" / "a.pkl", "b": SRC / "data" / "b.pkl"}, -} -``` - ```` ````` diff --git a/docs/source/tutorials/repeating_tasks_with_different_inputs.md b/docs/source/tutorials/repeating_tasks_with_different_inputs.md index 5fed02c7..8e5a10e5 100644 --- a/docs/source/tutorials/repeating_tasks_with_different_inputs.md +++ b/docs/source/tutorials/repeating_tasks_with_different_inputs.md @@ -35,18 +35,6 @@ different seeds and output paths as default arguments of the function. ```{literalinclude} ../../../docs_src/tutorials/repeating_tasks_with_different_inputs1_produces.py ``` -```` - -````{tab-item} Decorators -:sync: decorators - -```{warning} -This approach is deprecated and will be removed in v0.5 -``` - -```{literalinclude} ../../../docs_src/tutorials/repeating_tasks_with_different_inputs1_decorators.py -``` - ```` ````` @@ -83,18 +71,6 @@ You can also add dependencies to repeated tasks just like with any other task. ```{literalinclude} ../../../docs_src/tutorials/repeating_tasks_with_different_inputs2_produces.py ``` -```` - -````{tab-item} Decorators -:sync: decorators - -```{warning} -This approach is deprecated and will be removed in v0.5 -``` - -```{literalinclude} ../../../docs_src/tutorials/repeating_tasks_with_different_inputs2_decorators.py -``` - ```` ````` @@ -155,18 +131,6 @@ For example, the following function is parametrized with tuples. ```{literalinclude} ../../../docs_src/tutorials/repeating_tasks_with_different_inputs3_produces.py ``` -```` - -````{tab-item} Decorators -:sync: decorators - -```{warning} -This approach is deprecated and will be removed in v0.5 -``` - -```{literalinclude} ../../../docs_src/tutorials/repeating_tasks_with_different_inputs3_decorators.py -``` - ```` ````` @@ -208,18 +172,6 @@ a unique name for the iteration. ```{literalinclude} ../../../docs_src/tutorials/repeating_tasks_with_different_inputs4_produces.py ``` -```` - -````{tab-item} Decorators -:sync: decorators - -```{warning} -This approach is deprecated and will be removed in v0.5 -``` - -```{literalinclude} ../../../docs_src/tutorials/repeating_tasks_with_different_inputs4_decorators.py -``` - ```` ````` @@ -306,18 +258,6 @@ Following these three tips, the parametrization becomes ```{literalinclude} ../../../docs_src/tutorials/repeating_tasks_with_different_inputs5_produces.py ``` -```` - -````{tab-item} Decorators -:sync: decorators - -```{warning} -This approach is deprecated and will be removed in v0.5 -``` - -```{literalinclude} ../../../docs_src/tutorials/repeating_tasks_with_different_inputs5_decorators.py -``` - ```` ````` diff --git a/docs/source/tutorials/write_a_task.md b/docs/source/tutorials/write_a_task.md index 878f37cf..8165ffc2 100644 --- a/docs/source/tutorials/write_a_task.md +++ b/docs/source/tutorials/write_a_task.md @@ -94,25 +94,6 @@ the default value of the argument. :emphasize-lines: 9 ``` -```` - -````{tab-item} Decorators - -```{warning} -This approach is deprecated and will be removed in v0.5 -``` - -To specify a product, pass the path to the -{func}`@pytask.mark.produces ` decorator. Then, add `produces` as -an argument name to use the path inside the task function. - -```{literalinclude} ../../../docs_src/tutorials/write_a_task_decorators.py -:emphasize-lines: 10, 11 -``` - -To let pytask track the product of the task, you need to use the -{func}`@pytask.mark.produces ` decorator. - ```` ````` diff --git a/docs_src/tutorials/defining_dependencies_products_dependencies_decorators.py b/docs_src/tutorials/defining_dependencies_products_dependencies_decorators.py deleted file mode 100644 index 3f86a1a0..00000000 --- a/docs_src/tutorials/defining_dependencies_products_dependencies_decorators.py +++ /dev/null @@ -1,18 +0,0 @@ -from pathlib import Path - -import matplotlib.pyplot as plt -import pandas as pd -import pytask -from my_project.config import BLD - - -@pytask.mark.depends_on(BLD / "data.pkl") -@pytask.mark.produces(BLD / "plot.png") -def task_plot_data(depends_on: Path, produces: Path) -> None: - df = pd.read_pickle(depends_on) - - _, ax = plt.subplots() - df.plot(x="x", y="y", ax=ax, kind="scatter") - - plt.savefig(produces) - plt.close() diff --git a/docs_src/tutorials/defining_dependencies_products_products_decorators.py b/docs_src/tutorials/defining_dependencies_products_products_decorators.py deleted file mode 100644 index dbb5fbf0..00000000 --- a/docs_src/tutorials/defining_dependencies_products_products_decorators.py +++ /dev/null @@ -1,20 +0,0 @@ -from pathlib import Path - -import numpy as np -import pandas as pd -import pytask -from my_project.config import BLD - - -@pytask.mark.produces(BLD / "data.pkl") -def task_create_random_data(produces: Path) -> None: - rng = np.random.default_rng(0) - beta = 2 - - x = rng.normal(loc=5, scale=10, size=1_000) - epsilon = rng.standard_normal(1_000) - - y = beta * x + epsilon - - df = pd.DataFrame({"x": x, "y": y}) - df.to_pickle(produces) diff --git a/docs_src/tutorials/defining_dependencies_products_relative_decorators.py b/docs_src/tutorials/defining_dependencies_products_relative_decorators.py deleted file mode 100644 index 0b10e63f..00000000 --- a/docs_src/tutorials/defining_dependencies_products_relative_decorators.py +++ /dev/null @@ -1,8 +0,0 @@ -from pathlib import Path - -import pytask - - -@pytask.mark.produces("../bld/data.pkl") -def task_create_random_data(produces: Path) -> None: - ... diff --git a/docs_src/tutorials/repeating_tasks_with_different_inputs1_decorators.py b/docs_src/tutorials/repeating_tasks_with_different_inputs1_decorators.py deleted file mode 100644 index 0b0890a0..00000000 --- a/docs_src/tutorials/repeating_tasks_with_different_inputs1_decorators.py +++ /dev/null @@ -1,12 +0,0 @@ -from pathlib import Path - -import pytask -from pytask import task - - -for seed in range(10): - - @task - @pytask.mark.produces(Path(f"data_{seed}.pkl")) - def task_create_random_data(produces: Path, seed: int = seed) -> None: - ... diff --git a/docs_src/tutorials/repeating_tasks_with_different_inputs2_decorators.py b/docs_src/tutorials/repeating_tasks_with_different_inputs2_decorators.py deleted file mode 100644 index f332054e..00000000 --- a/docs_src/tutorials/repeating_tasks_with_different_inputs2_decorators.py +++ /dev/null @@ -1,16 +0,0 @@ -from pathlib import Path - -import pytask -from my_project.config import SRC -from pytask import task - - -for seed in range(10): - - @task - @pytask.mark.depends_on(SRC / "parameters.yml") - @pytask.mark.produces(f"data_{seed}.pkl") - def task_create_random_data( - depends_on: Path, produces: Path, seed: int = seed - ) -> None: - ... diff --git a/docs_src/tutorials/repeating_tasks_with_different_inputs3_decorators.py b/docs_src/tutorials/repeating_tasks_with_different_inputs3_decorators.py deleted file mode 100644 index 14b2b404..00000000 --- a/docs_src/tutorials/repeating_tasks_with_different_inputs3_decorators.py +++ /dev/null @@ -1,13 +0,0 @@ -from pathlib import Path -from typing import Tuple - -import pytask -from pytask import task - - -for seed in ((0,), (1,)): - - @task - @pytask.mark.produces(Path(f"data_{seed[0]}.pkl")) - def task_create_random_data(produces: Path, seed: Tuple[int] = seed) -> None: - ... diff --git a/docs_src/tutorials/repeating_tasks_with_different_inputs4_decorators.py b/docs_src/tutorials/repeating_tasks_with_different_inputs4_decorators.py deleted file mode 100644 index 148d89f6..00000000 --- a/docs_src/tutorials/repeating_tasks_with_different_inputs4_decorators.py +++ /dev/null @@ -1,12 +0,0 @@ -from pathlib import Path - -import pytask -from pytask import task - - -for seed, id_ in ((0, "first"), (1, "second")): - - @task(id=id_) - @pytask.mark.produces(Path(f"out_{seed}.txt")) - def task_create_random_data(produces: Path, seed: int = seed) -> None: - ... diff --git a/docs_src/tutorials/repeating_tasks_with_different_inputs5_decorators.py b/docs_src/tutorials/repeating_tasks_with_different_inputs5_decorators.py deleted file mode 100644 index 33091f59..00000000 --- a/docs_src/tutorials/repeating_tasks_with_different_inputs5_decorators.py +++ /dev/null @@ -1,22 +0,0 @@ -from pathlib import Path -from typing import NamedTuple - -from pytask import task - - -class _Arguments(NamedTuple): - seed: int - path_to_data: Path - - -ID_TO_KWARGS = { - "first": _Arguments(seed=0, path_to_data=Path("data_0.pkl")), - "second": _Arguments(seed=1, path_to_data=Path("data_1.pkl")), -} - - -for id_, kwargs in ID_TO_KWARGS.items(): - - @task(id=id_, kwargs=kwargs) - def task_create_random_data(seed: int, produces: Path) -> None: - ... diff --git a/docs_src/tutorials/write_a_task_decorators.py b/docs_src/tutorials/write_a_task_decorators.py deleted file mode 100644 index c7e8c9af..00000000 --- a/docs_src/tutorials/write_a_task_decorators.py +++ /dev/null @@ -1,21 +0,0 @@ -# Content of task_data_preparation.py. -from pathlib import Path - -import numpy as np -import pandas as pd -import pytask -from my_project.config import BLD - - -@pytask.mark.produces(BLD / "data.pkl") -def task_create_random_data(produces: Path) -> None: - rng = np.random.default_rng(0) - beta = 2 - - x = rng.normal(loc=5, scale=10, size=1_000) - epsilon = rng.standard_normal(1_000) - - y = beta * x + epsilon - - df = pd.DataFrame({"x": x, "y": y}) - df.to_pickle(produces) diff --git a/src/_pytask/collect.py b/src/_pytask/collect.py index 3b262568..afa1c9bc 100644 --- a/src/_pytask/collect.py +++ b/src/_pytask/collect.py @@ -21,6 +21,7 @@ from _pytask.console import get_file from _pytask.exceptions import CollectionError from _pytask.exceptions import NodeNotCollectedError +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 @@ -235,6 +236,10 @@ def _is_filtered_object(obj: Any) -> bool: See :issue:`507`. """ + # Filter :class:`pytask.mark.MarkGenerator` which can raise errors on some marks. + if isinstance(obj, MarkGenerator): + return True + # Filter :class:`pytask.Task` and :class:`pytask.TaskWithoutPath` objects. if isinstance(obj, PTask) and inspect.isclass(obj): return True diff --git a/src/_pytask/collect_utils.py b/src/_pytask/collect_utils.py index 054a01e3..7c4c355b 100644 --- a/src/_pytask/collect_utils.py +++ b/src/_pytask/collect_utils.py @@ -1,35 +1,22 @@ """Contains utility functions for :mod:`_pytask.collect`.""" from __future__ import annotations -import itertools import sys -import uuid -import warnings -from pathlib import Path from typing import Any from typing import Callable -from typing import Generator -from typing import Iterable from typing import TYPE_CHECKING import attrs from _pytask._inspect import get_annotations from _pytask.exceptions import NodeNotCollectedError -from _pytask.mark_utils import has_mark -from _pytask.mark_utils import remove_marks from _pytask.models import NodeInfo from _pytask.node_protocols import PNode from _pytask.nodes import PythonNode -from _pytask.shared import find_duplicates from _pytask.task_utils import parse_keyword_arguments_from_signature_defaults -from _pytask.tree_util import PyTree from _pytask.tree_util import tree_leaves -from _pytask.tree_util import tree_map from _pytask.tree_util import tree_map_with_path from _pytask.typing import no_default from _pytask.typing import ProductType -from attrs import define -from attrs import field from typing_extensions import get_origin if sys.version_info >= (3, 9): @@ -38,205 +25,21 @@ from typing_extensions import Annotated if TYPE_CHECKING: + from pathlib import Path from _pytask.session import Session __all__ = [ - "depends_on", "parse_dependencies_from_task_function", - "parse_nodes", "parse_products_from_task_function", - "produces", ] -def depends_on(objects: PyTree[Any]) -> PyTree[Any]: - """Specify dependencies for a task. - - Parameters - ---------- - objects - Can be any valid Python object or an iterable of any Python objects. To be - valid, it must be parsed by some hook implementation for the - :func:`_pytask.hookspecs.pytask_collect_node` entry-point. - - """ - return objects - - -def produces(objects: PyTree[Any]) -> PyTree[Any]: - """Specify products of a task. - - Parameters - ---------- - objects - Can be any valid Python object or an iterable of any Python objects. To be - valid, it must be parsed by some hook implementation for the - :func:`_pytask.hookspecs.pytask_collect_node` entry-point. - - """ - return objects - - -def parse_nodes( # noqa: PLR0913 - session: Session, - task_path: Path | None, - task_name: str, - node_path: Path, - obj: Any, - parser: Callable[..., Any], -) -> Any: - """Parse nodes from object.""" - arg_name = parser.__name__ - objects = _extract_nodes_from_function_markers(obj, parser) - nodes = _convert_objects_to_node_dictionary(objects, arg_name) - return tree_map( - lambda x: _collect_decorator_node( - session, - node_path, - task_name, - NodeInfo( - arg_name=arg_name, - path=(), - value=x, - task_path=task_path, - task_name=task_name, - ), - ), - nodes, - ) - - -def _extract_nodes_from_function_markers( - function: Callable[..., Any], parser: Callable[..., Any] -) -> Generator[Any, None, None]: - """Extract nodes from a marker. - - The parser is a functions which is used to document the marker with the correct - signature. Using the function as a parser for the ``args`` and ``kwargs`` of the - marker provides the expected error message for misspecification. - - """ - marker_name = parser.__name__ - _, markers = remove_marks(function, marker_name) - for marker in markers: - parsed = parser(*marker.args, **marker.kwargs) - yield parsed - - -def _convert_objects_to_node_dictionary(objects: Any, when: str) -> dict[Any, Any]: - """Convert objects to node dictionary.""" - list_of_dicts = [_convert_to_dict(x) for x in objects] - _check_that_names_are_not_used_multiple_times(list_of_dicts, when) - return _merge_dictionaries(list_of_dicts) - - -@define(frozen=True) -class _Placeholder: - """A placeholder to mark unspecified keys in dictionaries.""" - - scalar: bool = field(default=False) - id_: uuid.UUID = field(factory=uuid.uuid4) - - -def _convert_to_dict(x: Any, first_level: bool = True) -> Any | dict[Any, Any]: - """Convert any object to a dictionary.""" - if isinstance(x, dict): - return {k: _convert_to_dict(v, False) for k, v in x.items()} - if isinstance(x, Iterable) and not isinstance(x, str): - if first_level: - return { - _Placeholder(): _convert_to_dict(element, False) - for i, element in enumerate(x) - } - return {i: _convert_to_dict(element, False) for i, element in enumerate(x)} - if first_level: - return {_Placeholder(scalar=True): x} - return x - - -def _check_that_names_are_not_used_multiple_times( - list_of_dicts: list[dict[Any, Any]], when: str -) -> None: - """Check that names of nodes are not assigned multiple times. - - Tuples in the list have either one or two elements. The first element in the two - element tuples is the name and cannot occur twice. - - """ - names_with_provisional_keys = list( - itertools.chain.from_iterable(dict_.keys() for dict_ in list_of_dicts) - ) - names = [x for x in names_with_provisional_keys if not isinstance(x, _Placeholder)] - duplicated = find_duplicates(names) - - if duplicated: - msg = f"'@pytask.mark.{when}' has nodes with the same name: {duplicated}" - raise ValueError(msg) - - -def _union_of_dictionaries(dicts: list[dict[Any, Any]]) -> dict[Any, Any]: - """Merge multiple dictionaries in one. - - Examples - -------- - >>> a, b = {"a": 0}, {"b": 1} - >>> _union_of_dictionaries([a, b]) - {'a': 0, 'b': 1} - - >>> a, b = {'a': 0}, {'a': 1} - >>> _union_of_dictionaries([a, b]) - {'a': 1} - - """ - return dict(itertools.chain.from_iterable(dict_.items() for dict_ in dicts)) - - -def _merge_dictionaries(list_of_dicts: list[dict[Any, Any]]) -> dict[Any, Any]: - """Merge multiple dictionaries. - - The function does not perform a deep merge. It simply merges the dictionary based on - the first level keys which are either unique names or placeholders. During the merge - placeholders will be replaced by an incrementing integer. - - Examples - -------- - >>> a, b = {"a": 0}, {"b": 1} - >>> _merge_dictionaries([a, b]) - {'a': 0, 'b': 1} - - >>> a, b = {_Placeholder(): 0}, {_Placeholder(): 1} - >>> _merge_dictionaries([a, b]) - {0: 0, 1: 1} - - """ - merged_dict = _union_of_dictionaries(list_of_dicts) - - if len(merged_dict) == 1 and isinstance(next(iter(merged_dict)), _Placeholder): - placeholder, value = next(iter(merged_dict.items())) - out = value if placeholder.scalar else {0: value} - else: - counter = itertools.count() - out = {} - for k, v in merged_dict.items(): - if isinstance(k, _Placeholder): - while True: - possible_key = next(counter) - if possible_key not in merged_dict and possible_key not in out: - out[possible_key] = v - break - else: - out[k] = v - - return out - - _ERROR_MULTIPLE_DEPENDENCY_DEFINITIONS = """The task uses multiple ways to define \ dependencies. Dependencies should be defined with either - as default value for the function argument 'depends_on'. - as '@pytask.task(kwargs={"depends_on": ...})' -- or with the deprecated '@pytask.mark.depends_on' and a 'depends_on' function argument. Use only one of the two ways! @@ -250,27 +53,13 @@ def parse_dependencies_from_task_function( session: Session, task_path: Path | None, task_name: str, node_path: Path, obj: Any ) -> dict[str, Any]: """Parse dependencies from task function.""" - has_depends_on_decorator = False - has_depends_on_argument = False dependencies = {} - if has_mark(obj, "depends_on"): - has_depends_on_decorator = True - nodes = parse_nodes(session, task_path, task_name, node_path, obj, depends_on) - dependencies["depends_on"] = nodes - task_kwargs = obj.pytask_meta.kwargs if hasattr(obj, "pytask_meta") else {} signature_defaults = parse_keyword_arguments_from_signature_defaults(obj) kwargs = {**signature_defaults, **task_kwargs} kwargs.pop("produces", None) - # Parse dependencies from task when @task is used. - if "depends_on" in kwargs: - has_depends_on_argument = True - - if has_depends_on_decorator and has_depends_on_argument: - raise NodeNotCollectedError(_ERROR_MULTIPLE_DEPENDENCY_DEFINITIONS) - parameters_with_product_annot = _find_args_with_product_annotation(obj) parameters_with_node_annot = _find_args_with_node_annotation(obj) @@ -326,7 +115,7 @@ def parse_dependencies_from_task_function( ) dependencies[parameter_name] = PythonNode(value=value, name=node_name) else: - dependencies[parameter_name] = nodes + dependencies[parameter_name] = nodes # type: ignore[assignment] return dependencies @@ -360,7 +149,6 @@ def _find_args_with_node_annotation(func: Callable[..., Any]) -> dict[str, PNode - 'typing.Annotated[Path, Product] = Path(...)' (recommended) - '@pytask.mark.task(kwargs={'produces': Path(...)})' - as a default argument for 'produces': 'produces = Path(...)' -- '@pytask.mark.produces(Path(...))' (deprecated) Read more about products in the documentation: https://tinyurl.com/pytask-deps-prods. """ @@ -377,18 +165,10 @@ def parse_products_from_task_function( # noqa: C901 If multiple ways were used to specify products. """ - has_produces_decorator = False has_produces_argument = False has_annotation = False has_return = False has_task_decorator = False - out = {} - - # Parse products from decorators. - if has_mark(obj, "produces"): - has_produces_decorator = True - nodes = parse_nodes(session, task_path, task_name, node_path, obj, produces) - out = {"produces": nodes} task_kwargs = obj.pytask_meta.kwargs if hasattr(obj, "pytask_meta") else {} signature_defaults = parse_keyword_arguments_from_signature_defaults(obj) @@ -477,7 +257,6 @@ def parse_products_from_task_function( # noqa: C901 if ( sum( ( - has_produces_decorator, has_produces_argument, has_annotation, has_return, @@ -509,56 +288,6 @@ def _find_args_with_product_annotation(func: Callable[..., Any]) -> list[str]: return args_with_product_annot -_ERROR_WRONG_TYPE_DECORATOR = """'@pytask.mark.depends_on', '@pytask.mark.produces', \ -and their function arguments can only accept values of type 'str' and 'pathlib.Path' \ -or the same values nested in tuples, lists, and dictionaries. Here, {node} has type \ -{node_type}. -""" - - -_WARNING_STRING_DEPRECATED = """Using strings to specify a {kind} is deprecated. Pass \ -a 'pathlib.Path' instead with 'Path("{node}")'. -""" - - -def _collect_decorator_node( - session: Session, path: Path, name: str, node_info: NodeInfo -) -> PNode: - """Collect nodes for a task. - - Raises - ------ - NodeNotCollectedError - If the node could not collected. - - """ - node = node_info.value - kind = {"depends_on": "dependency", "produces": "product"}.get(node_info.arg_name) - - if not isinstance(node, (str, Path)): - raise NodeNotCollectedError( - _ERROR_WRONG_TYPE_DECORATOR.format(node=node, node_type=type(node)) - ) - - if isinstance(node, str): - warnings.warn( - _WARNING_STRING_DEPRECATED.format(kind=kind, node=node), - category=FutureWarning, - stacklevel=1, - ) - node = Path(node) - node_info = node_info._replace(value=node) - - collected_node = session.hook.pytask_collect_node( - session=session, path=path, node_info=node_info - ) - if collected_node is None: # pragma: no cover - msg = f"{node!r} cannot be parsed as a {kind} for task {name!r} in {path!r}." - raise NodeNotCollectedError(msg) - - return collected_node - - def _collect_dependency( session: Session, path: Path, name: str, node_info: NodeInfo ) -> PNode: @@ -572,16 +301,6 @@ def _collect_dependency( """ node = node_info.value - # If we encounter a string and the argument name is 'depends_on', we convert it. - if isinstance(node, str) and node_info.arg_name == "depends_on": - warnings.warn( - _WARNING_STRING_DEPRECATED.format(kind="dependency", node=node), - category=FutureWarning, - stacklevel=1, - ) - node = Path(node) - node_info = node_info._replace(value=node) - if isinstance(node, PythonNode) and node.value is no_default: # 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 @@ -619,16 +338,6 @@ def _collect_product( """ node = node_info.value - # If we encounter a string and the argument name is 'produces', we convert it. - if isinstance(node, str) and node_info.arg_name == "produces": - warnings.warn( - _WARNING_STRING_DEPRECATED.format(kind="product", node=node), - category=FutureWarning, - stacklevel=1, - ) - node = Path(node) - node_info = node_info._replace(value=node) - collected_node = session.hook.pytask_collect_node( session=session, path=path, node_info=node_info ) diff --git a/src/_pytask/config.py b/src/_pytask/config.py index ef176100..d42d5eed 100644 --- a/src/_pytask/config.py +++ b/src/_pytask/config.py @@ -80,14 +80,6 @@ def pytask_parse_config(config: dict[str, Any]) -> None: config["paths"] = parse_paths(config["paths"]) config["markers"] = { - "depends_on": ( - "Add dependencies to a task. See this tutorial for more information: " - "[link https://bit.ly/3JlxylS]https://bit.ly/3JlxylS[/]." - ), - "produces": ( - "Add products to a task. See this tutorial for more information: " - "[link https://bit.ly/3JlxylS]https://bit.ly/3JlxylS[/]." - ), "try_first": "Try to execute a task a early as possible.", "try_last": "Try to execute a task a late as possible.", **config["markers"], diff --git a/src/_pytask/mark/__init__.pyi b/src/_pytask/mark/__init__.pyi index f9d55332..3b25e360 100644 --- a/src/_pytask/mark/__init__.pyi +++ b/src/_pytask/mark/__init__.pyi @@ -1,4 +1,3 @@ -from pathlib import Path from typing import Any from typing_extensions import deprecated from _pytask.mark.expression import Expression @@ -15,20 +14,6 @@ def select_by_keyword(session: Session, dag: nx.DiGraph) -> set[str]: ... def select_by_mark(session: Session, dag: nx.DiGraph) -> set[str]: ... class MarkGenerator: - @deprecated( - "'@pytask.mark.produces' is deprecated starting pytask v0.4.0 and will be removed in v0.5.0. To upgrade your project to the new syntax, read the tutorial on product and dependencies: https://tinyurl.com/pytask-deps-prods.", # noqa: E501 - category=FutureWarning, - stacklevel=1, - ) - @staticmethod - def produces(objects: PyTree[str | Path]) -> None: ... - @deprecated( - "'@pytask.mark.depends_on' is deprecated starting pytask v0.4.0 and will be removed in v0.5.0. To upgrade your project to the new syntax, read the tutorial on product and dependencies: https://tinyurl.com/pytask-deps-prods.", # noqa: E501 - category=FutureWarning, - stacklevel=1, - ) - @staticmethod - def depends_on(objects: PyTree[str | Path]) -> None: ... @deprecated( "'@pytask.mark.task' is deprecated starting pytask v0.4.0 and will be removed in v0.5.0. Use '@task' from 'from pytask import task' instead.", # noqa: E501 category=FutureWarning, diff --git a/src/_pytask/mark/structures.py b/src/_pytask/mark/structures.py index 126591ca..1efbc8c2 100644 --- a/src/_pytask/mark/structures.py +++ b/src/_pytask/mark/structures.py @@ -162,9 +162,9 @@ def store_mark(obj: Callable[..., Any], mark: Mark) -> None: ) -_DEPRECATION_DECORATOR = """'@pytask.mark.{}' is deprecated starting pytask \ -v0.4.0 and will be removed in v0.5.0. To upgrade your project to the new syntax, read \ -the tutorial on product and dependencies: https://tinyurl.com/pytask-deps-prods. +_DEPRECATION_DECORATOR = """'@pytask.mark.{}' was removed in pytask v0.5.0. To upgrade \ +your project to the new syntax, read the tutorial on product and dependencies: \ +https://tinyurl.com/pytask-deps-prods. """ @@ -194,11 +194,7 @@ def __getattr__(self, name: str) -> MarkDecorator | Any: raise AttributeError(msg) if name in ("depends_on", "produces"): - warnings.warn( - _DEPRECATION_DECORATOR.format(name), - category=FutureWarning, - stacklevel=1, - ) + raise RuntimeError(_DEPRECATION_DECORATOR.format(name)) # If the name is not in the set of known marks after updating, # then it really is time to issue a warning or an error. diff --git a/src/_pytask/models.py b/src/_pytask/models.py index 1427b487..73e2ffd0 100644 --- a/src/_pytask/models.py +++ b/src/_pytask/models.py @@ -1,6 +1,8 @@ """Contains code on models, containers and there like.""" from __future__ import annotations +from enum import auto +from enum import Enum from typing import Any from typing import Callable from typing import NamedTuple @@ -12,6 +14,8 @@ from attrs import field if TYPE_CHECKING: + from _pytask.node_protocols import PTask + from _pytask.node_protocols import PNode from pathlib import Path from _pytask.tree_util import PyTree from _pytask.mark import Mark @@ -60,3 +64,15 @@ class NodeInfo(NamedTuple): value: Any task_path: Path | None task_name: str + + +class NodeStatus(Enum): + """The status of a node.""" + + CHANGED = auto() + DOES_NOT_EXIST = auto() + + +class NodeStatusEntry(NamedTuple): + node: PNode | PTask + reason: NodeStatus diff --git a/src/pytask/__init__.py b/src/pytask/__init__.py index 8c530cc2..d8ed8131 100644 --- a/src/pytask/__init__.py +++ b/src/pytask/__init__.py @@ -9,10 +9,8 @@ from _pytask.click import ColoredCommand from _pytask.click import ColoredGroup from _pytask.click import EnumChoice -from _pytask.collect_utils import depends_on from _pytask.collect_utils import parse_dependencies_from_task_function from _pytask.collect_utils import parse_products_from_task_function -from _pytask.collect_utils import produces from _pytask.compat import check_for_optional_program from _pytask.compat import import_optional_dependency from _pytask.console import console @@ -135,7 +133,6 @@ "console", "count_outcomes", "create_database", - "depends_on", "get_all_marks", "get_marks", "get_plugin_manager", @@ -148,7 +145,6 @@ "parse_dependencies_from_task_function", "parse_products_from_task_function", "parse_warning_filter", - "produces", "remove_internal_traceback_frames_from_exc_info", "remove_marks", "set_marks", diff --git a/tests/test_build.py b/tests/test_build.py index 96f158e6..526079bb 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -39,17 +39,10 @@ def test_collection_failed(runner, tmp_path): @pytest.mark.end_to_end() def test_building_dag_failed(runner, tmp_path): source = """ - import pytask + from pathlib import Path - @pytask.mark.depends_on("in.txt") - @pytask.mark.produces("out.txt") - def task_passes_1(): - pass - - @pytask.mark.depends_on("out.txt") - @pytask.mark.produces("in.txt") - def task_passes_2(): - pass + def task_passes_1(in_path = Path("in.txt"), produces = Path("out.txt")): ... + def task_passes_2(in_path = Path("out.txt"), produces = Path("in.txt")): ... """ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) diff --git a/tests/test_clean.py b/tests/test_clean.py index 91c59f0e..0dd39611 100644 --- a/tests/test_clean.py +++ b/tests/test_clean.py @@ -14,10 +14,9 @@ _PROJECT_TASK = """ import pytask +from pathlib import Path -@pytask.mark.depends_on("in.txt") -@pytask.mark.produces("out.txt") -def task_write_text(depends_on, produces): +def task_write_text(path = Path("in.txt"), produces = Path("out.txt")): produces.write_text("a") """ @@ -26,7 +25,7 @@ def task_write_text(depends_on, produces): import pytask from pathlib import Path -def task_write_text(in_path=Path("in.txt"), produces=Path("out.txt")): +def task_write_text(path=Path("in.txt"), produces=Path("out.txt")): produces.write_text("a") """ @@ -46,10 +45,9 @@ def project(request, tmp_path): _GIT_PROJECT_TASK = """ import pytask +from pathlib import Path -@pytask.mark.depends_on("in_tracked.txt") -@pytask.mark.produces("out.txt") -def task_write_text(depends_on, produces): +def task_write_text(path = Path("in_tracked.txt"), produces = Path("out.txt")): produces.write_text("a") """ @@ -58,7 +56,7 @@ def task_write_text(depends_on, produces): import pytask from pathlib import Path -def task_write_text(in_path=Path("in_tracked.txt"), produces=Path("out.txt")): +def task_write_text(path=Path("in_tracked.txt"), produces=Path("out.txt")): produces.write_text("a") """ diff --git a/tests/test_collect.py b/tests/test_collect.py index 6d01eec6..f9001e2a 100644 --- a/tests/test_collect.py +++ b/tests/test_collect.py @@ -28,13 +28,10 @@ ) def test_collect_file_with_relative_path(tmp_path, depends_on, produces): source = f""" - import pytask from pathlib import Path - @pytask.mark.depends_on({depends_on}) - @pytask.mark.produces({produces}) - def task_write_text(depends_on, produces): - produces.write_text(depends_on.read_text()) + def task_write_text(path=Path({depends_on}), produces=Path({produces})): + produces.write_text(path.read_text()) """ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) tmp_path.joinpath("in.txt").write_text("Relative paths work.") @@ -67,38 +64,13 @@ def task_example( @pytest.mark.end_to_end() -def test_collect_depends_on_that_is_not_str_or_path(capsys, tmp_path): - """If a node cannot be parsed because unknown type, raise an error.""" - source = """ - import pytask - - @pytask.mark.depends_on(True) - def task_with_non_path_dependency(): - pass - """ - tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) - - session = build(paths=tmp_path) - - assert session.exit_code == ExitCode.COLLECTION_FAILED - assert session.collection_reports[0].outcome == CollectionOutcome.FAIL - exc_info = session.collection_reports[0].exc_info - assert isinstance(exc_info[1], NodeNotCollectedError) - captured = capsys.readouterr().out - assert "'@pytask.mark.depends_on'" in captured - # Assert tracebacks are hidden. - assert "_pytask/collect.py" not in captured - - -@pytest.mark.end_to_end() -def test_collect_produces_that_is_not_str_or_path(tmp_path, capsys): +@pytest.mark.xfail(reason="!!!") +def test_collect_produces_that_is_not_str_or_path(tmp_path): """If a node cannot be parsed because unknown type, raise an error.""" source = """ import pytask - @pytask.mark.produces(True) - def task_with_non_path_dependency(): - pass + def task_with_non_path_dependency(produces=True): ... """ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) @@ -108,25 +80,19 @@ def task_with_non_path_dependency(): assert session.collection_reports[0].outcome == CollectionOutcome.FAIL exc_info = session.collection_reports[0].exc_info assert isinstance(exc_info[1], NodeNotCollectedError) - captured = capsys.readouterr().out - assert "'@pytask.mark.depends_on'" in captured @pytest.mark.end_to_end() def test_collect_nodes_with_the_same_name(runner, tmp_path): """Nodes with the same filename, not path, are not mistaken for each other.""" source = """ - import pytask + from pathlib import Path - @pytask.mark.depends_on("text.txt") - @pytask.mark.produces("out_0.txt") - def task_0(depends_on, produces): - produces.write_text(depends_on.read_text()) + def task_0(path=Path("text.txt"), produces=Path("out_0.txt")): + produces.write_text(path.read_text()) - @pytask.mark.depends_on("sub/text.txt") - @pytask.mark.produces("out_1.txt") - def task_1(depends_on, produces): - produces.write_text(depends_on.read_text()) + def task_1(path=Path("sub/text.txt"), produces=Path("out_1.txt")): + produces.write_text(path.read_text()) """ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) @@ -374,6 +340,7 @@ def task_my_task(): @pytest.mark.end_to_end() +@pytest.mark.xfail(reason="!!!") @pytest.mark.parametrize("decorator", ["", "@task"]) def test_collect_string_product_with_or_without_task_decorator( runner, tmp_path, decorator @@ -407,98 +374,6 @@ def task_write_text(out: Annotated[str, Product] = "out.txt") -> None: assert result.exit_code == ExitCode.FAILED -@pytest.mark.end_to_end() -def test_product_cannot_mix_different_product_types(tmp_path, capsys): - source = """ - import pytask - from typing_extensions import Annotated - from pytask import Product - from pathlib import Path - - @pytask.mark.produces("out_deco.txt") - def task_example( - path: Annotated[Path, Product], produces: Path = Path("out_sig.txt") - ): - ... - """ - tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) - session = build(paths=tmp_path) - - assert session.exit_code == ExitCode.COLLECTION_FAILED - assert len(session.tasks) == 0 - report = session.collection_reports[0] - assert report.outcome == CollectionOutcome.FAIL - captured = capsys.readouterr().out - assert "The task uses multiple ways" in captured - - -@pytest.mark.end_to_end() -def test_depends_on_cannot_mix_different_definitions(tmp_path, capsys): - source = """ - import pytask - from typing_extensions import Annotated - from pytask import Product - from pathlib import Path - - @pytask.mark.depends_on("input_1.txt") - def task_example( - depends_on: Path = "input_2.txt", - path: Annotated[Path, Product] = Path("out.txt") - ): - ... - """ - tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) - tmp_path.joinpath("input_1.txt").touch() - tmp_path.joinpath("input_2.txt").touch() - session = build(paths=tmp_path) - - assert session.exit_code == ExitCode.COLLECTION_FAILED - assert len(session.tasks) == 0 - report = session.collection_reports[0] - assert report.outcome == CollectionOutcome.FAIL - captured = capsys.readouterr().out - assert "The task uses multiple" in captured - - -@pytest.mark.end_to_end() -@pytest.mark.parametrize( - ("depends_on", "produces"), - [("'in.txt'", "Path('out.txt')"), ("Path('in.txt')", "'out.txt'")], -) -def test_deprecation_warning_for_strings_in_former_decorator_args( - runner, tmp_path, depends_on, produces -): - source = f""" - import pytask - from pathlib import Path - - @pytask.mark.depends_on({depends_on}) - @pytask.mark.produces({produces}) - def task_write_text(depends_on, produces): - produces.touch() - """ - tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) - tmp_path.joinpath("in.txt").touch() - - result = runner.invoke(cli, [tmp_path.as_posix()]) - assert "FutureWarning" in result.output - - -@pytest.mark.end_to_end() -def test_no_deprecation_warning_for_using_magic_produces(runner, tmp_path): - source = """ - import pytask - from pathlib import Path - - def task_write_text(depends_on, produces=Path("out.txt")): - produces.touch() - """ - tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) - - result = runner.invoke(cli, [tmp_path.as_posix()]) - assert "FutureWarning" not in result.output - - @pytest.mark.end_to_end() def test_setting_name_for_path_node_via_annotation(tmp_path): source = """ @@ -522,13 +397,11 @@ def task_example( @pytest.mark.end_to_end() def test_error_when_dependency_is_defined_in_kwargs_and_annotation(runner, tmp_path): source = """ - import pytask from pathlib import Path from typing_extensions import Annotated - from pytask import Product, PathNode - from pytask import PythonNode + from pytask import Product, PathNode, PythonNode, task - @pytask.mark.task(kwargs={"in_": "world"}) + @task(kwargs={"in_": "world"}) def task_example( in_: Annotated[str, PythonNode(name="string", value="hello")], path: Annotated[Path, Product, PathNode(path=Path("out.txt"), name="product")], @@ -544,14 +417,13 @@ def task_example( @pytest.mark.end_to_end() def test_error_when_product_is_defined_in_kwargs_and_annotation(runner, tmp_path): source = """ - import pytask from pathlib import Path from typing_extensions import Annotated - from pytask import Product, PathNode + from pytask import Product, PathNode, task node = PathNode(path=Path("out.txt"), name="product") - @pytask.mark.task(kwargs={"path": node}) + @task(kwargs={"path": node}) def task_example(path: Annotated[Path, Product, node]) -> None: path.write_text("text") """ diff --git a/tests/test_collect_command.py b/tests/test_collect_command.py index 94788da8..66cf9b97 100644 --- a/tests/test_collect_command.py +++ b/tests/test_collect_command.py @@ -19,12 +19,9 @@ @pytest.mark.end_to_end() def test_collect_task(runner, tmp_path): source = """ - import pytask + from pathlib import Path - @pytask.mark.depends_on("in.txt") - @pytask.mark.produces("out.txt") - def task_example(): - pass + def task_example(path=Path("in.txt"), produces=Path("out.txt")): ... """ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) tmp_path.joinpath("in.txt").touch() @@ -55,11 +52,9 @@ def task_example(): @pytest.mark.end_to_end() def test_collect_task_new_interface(runner, tmp_path): source = """ - import pytask from pathlib import Path - def task_example(depends_on=Path("in.txt"), arg=1, produces=Path("out.txt")): - pass + def task_example(depends_on=Path("in.txt"), arg=1, produces=Path("out.txt")): ... """ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) tmp_path.joinpath("in.txt").touch() @@ -91,12 +86,9 @@ def task_example(depends_on=Path("in.txt"), arg=1, produces=Path("out.txt")): @pytest.mark.end_to_end() def test_collect_task_in_root_dir(runner, tmp_path): source = """ - import pytask + from pathlib import Path - @pytask.mark.depends_on("in.txt") - @pytask.mark.produces("out.txt") - def task_example(): - pass + def task_example(path=Path("in.txt"), produces=Path("out.txt")): ... """ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) tmp_path.joinpath("in.txt").touch() @@ -117,13 +109,13 @@ def task_example(): @pytest.mark.end_to_end() def test_collect_parametrized_tasks(runner, tmp_path): source = """ - import pytask + from pytask import task + from pathlib import Path for arg, produces in [(0, "out_0.txt"), (1, "out_1.txt")]: - @pytask.mark.task - @pytask.mark.depends_on("in.txt") - def task_example(arg=arg, produces=produces): + @task + def task_example(depends_on=Path("in.txt"), arg=arg, produces=produces): pass """ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) @@ -136,24 +128,17 @@ def task_example(arg=arg, produces=produces): assert "" in captured assert "" in captured - assert "[1-out_1.txt]>" in captured + assert "[depends_on0-0-out_0.txt]>" in captured + assert "[depends_on1-1-out_1.txt]>" in captured @pytest.mark.end_to_end() def test_collect_task_with_expressions(runner, tmp_path): source = """ - import pytask - - @pytask.mark.depends_on("in_1.txt") - @pytask.mark.produces("out_1.txt") - def task_example_1(): - pass + from pathlib import Path - @pytask.mark.depends_on("in_2.txt") - @pytask.mark.produces("out_2.txt") - def task_example_2(): - pass + def task_example_1(path=Path("in_1.txt"), produces=Path("out_1.txt")): ... + def task_example_2(path=Path("in_2.txt"), produces=Path("out_2.txt")): ... """ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) tmp_path.joinpath("in_1.txt").touch() @@ -188,17 +173,12 @@ def task_example_2(): def test_collect_task_with_marker(runner, tmp_path): source = """ import pytask + from pathlib import Path @pytask.mark.wip - @pytask.mark.depends_on("in_1.txt") - @pytask.mark.produces("out_1.txt") - def task_example_1(): - pass - - @pytask.mark.depends_on("in_2.txt") - @pytask.mark.produces("out_2.txt") - def task_example_2(): - pass + def task_example_1(path=Path("in_1.txt"), produces=Path("out_1.txt")): ... + + def task_example_2(path=Path("in_2.txt"), produces=Path("out_2.txt")): ... """ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) tmp_path.joinpath("in_1.txt").touch() @@ -239,20 +219,14 @@ def task_example_2(): @pytest.mark.end_to_end() def test_collect_task_with_ignore_from_config(runner, tmp_path): source = """ - import pytask + from pathlib import Path - @pytask.mark.depends_on("in_1.txt") - @pytask.mark.produces("out_1.txt") - def task_example_1(): - pass + def task_example_1(path=Path("in_1.txt"), produces=Path("out_1.txt")): ... """ tmp_path.joinpath("task_example_1.py").write_text(textwrap.dedent(source)) source = """ - @pytask.mark.depends_on("in_2.txt") - @pytask.mark.produces("out_2.txt") - def task_example_2(): - pass + def task_example_2(path=Path("in_2.txt"), produces=Path("out_2.txt")): ... """ tmp_path.joinpath("task_example_2.py").write_text(textwrap.dedent(source)) tmp_path.joinpath("in_1.txt").touch() @@ -293,21 +267,17 @@ def task_example_2(): @pytest.mark.end_to_end() def test_collect_task_with_ignore_from_cli(runner, tmp_path): source = """ - import pytask + from pathlib import Path - @pytask.mark.depends_on("in_1.txt") - @pytask.mark.produces("out_1.txt") - def task_example_1(): - pass + def task_example_1(path=Path("in_1.txt"), produces=Path("out_1.txt")): ... """ tmp_path.joinpath("task_example_1.py").write_text(textwrap.dedent(source)) tmp_path.joinpath("in_1.txt").touch() source = """ - @pytask.mark.depends_on("in_2.txt") - @pytask.mark.produces("out_2.txt") - def task_example_2(): - pass + from pathlib import Path + + def task_example_2(path=Path("in_2.txt"), produces=Path("out_2.txt")): ... """ tmp_path.joinpath("task_example_2.py").write_text(textwrap.dedent(source)) diff --git a/tests/test_collect_utils.py b/tests/test_collect_utils.py index 55ee009d..ab5dbf5d 100644 --- a/tests/test_collect_utils.py +++ b/tests/test_collect_utils.py @@ -1,165 +1,13 @@ from __future__ import annotations -import itertools -from contextlib import ExitStack as does_not_raise # noqa: N813 +from typing import TYPE_CHECKING -import pytask import pytest -from _pytask.collect_utils import _check_that_names_are_not_used_multiple_times -from _pytask.collect_utils import _convert_objects_to_node_dictionary -from _pytask.collect_utils import _convert_to_dict -from _pytask.collect_utils import _extract_nodes_from_function_markers from _pytask.collect_utils import _find_args_with_product_annotation -from _pytask.collect_utils import _merge_dictionaries -from _pytask.collect_utils import _Placeholder -from pytask import depends_on -from pytask import produces -from pytask import Product from typing_extensions import Annotated - -ERROR = "'@pytask.mark.depends_on' has nodes with the same name:" - - -@pytest.mark.unit() -@pytest.mark.parametrize( - ("x", "expectation"), - [ - ([{0: "a"}, {0: "b"}], pytest.raises(ValueError, match=ERROR)), - ([{"a": 0}, {"a": 1}], pytest.raises(ValueError, match=ERROR)), - ([{"a": 0}, {"b": 0}, {"a": 1}], pytest.raises(ValueError, match=ERROR)), - ([{0: "a"}, {1: "a"}], does_not_raise()), - ([{"a": 0}, {0: "a"}], does_not_raise()), - ([{"a": 0}, {"b": 1}], does_not_raise()), - ], -) -def test_check_that_names_are_not_used_multiple_times(x, expectation): - with expectation: - _check_that_names_are_not_used_multiple_times(x, "depends_on") - - -@pytest.mark.integration() -@pytest.mark.parametrize("when", ["depends_on", "produces"]) -@pytest.mark.parametrize( - ("objects", "expectation", "expected"), - [ - ([0, 1], does_not_raise, {0: 0, 1: 1}), - ([{0: 0}, {1: 1}], does_not_raise, {0: 0, 1: 1}), - ([{0: 0}], does_not_raise, {0: 0}), - ([[0]], does_not_raise, {0: 0}), - ( - [((0, 0),), ((0, 1),)], - does_not_raise, - {0: {0: 0, 1: 0}, 1: {0: 0, 1: 1}}, - ), - ([{0: {0: {0: 0}}}, [2]], does_not_raise, {0: {0: {0: 0}}, 1: 2}), - ([{0: 0}, {0: 1}], ValueError, None), - ], -) -def test_convert_objects_to_node_dictionary(objects, when, expectation, expected): - expectation = ( - pytest.raises(expectation, match=f"'@pytask.mark.{when}' has nodes") - if expectation == ValueError - else expectation() - ) - with expectation: - nodes = _convert_objects_to_node_dictionary(objects, when) - assert nodes == expected - - -def _convert_placeholders_to_tuples(x): - counter = itertools.count() - return { - (next(counter), k.scalar) - if isinstance(k, _Placeholder) - else k: _convert_placeholders_to_tuples(v) if isinstance(v, dict) else v - for k, v in x.items() - } - - -@pytest.mark.unit() -@pytest.mark.parametrize( - ("x", "first_level", "expected"), - [ - (1, True, {(0, True): 1}), - ({1: 0}, False, {1: 0}), - ({1: [2, 3]}, False, {1: {0: 2, 1: 3}}), - ([2, 3], True, {(0, False): 2, (1, False): 3}), - ([2, 3], False, {0: 2, 1: 3}), - ], -) -def test__convert_to_dict(x, first_level, expected): - """We convert placeholders to a tuple consisting of the key and the scalar bool.""" - result = _convert_to_dict(x, first_level) - modified_result = _convert_placeholders_to_tuples(result) - assert modified_result == expected - - -@pytest.mark.unit() -@pytest.mark.parametrize( - ("list_of_dicts", "expected"), - [ - ([{1: 0}, {0: 1}], {1: 0, 0: 1}), - ([{_Placeholder(): 1}, {0: 0}], {1: 1, 0: 0}), - ([{_Placeholder(scalar=True): 1}], 1), - ([{_Placeholder(scalar=False): 1}], {0: 1}), - ], -) -def test_merge_dictionaries(list_of_dicts, expected): - result = _merge_dictionaries(list_of_dicts) - assert result == expected - - -@pytest.mark.unit() -@pytest.mark.parametrize("decorator", [pytask.mark.depends_on, pytask.mark.produces]) -@pytest.mark.parametrize( - ("values", "expected"), [("a", ["a"]), (["b"], [["b"]]), (["e", "f"], [["e", "f"]])] -) -def test_extract_args_from_mark(decorator, values, expected): - @decorator(values) - def task_example(): # pragma: no cover - pass - - parser = depends_on if decorator.name == "depends_on" else produces - result = list(_extract_nodes_from_function_markers(task_example, parser)) - assert result == expected - - -@pytest.mark.unit() -@pytest.mark.parametrize("decorator", [pytask.mark.depends_on, pytask.mark.produces]) -@pytest.mark.parametrize( - ("values", "expected"), - [ - ({"objects": "a"}, ["a"]), - ({"objects": ["b"]}, [["b"]]), - ({"objects": ["e", "f"]}, [["e", "f"]]), - ], -) -def test_extract_kwargs_from_mark(decorator, values, expected): - @decorator(**values) - def task_example(): # pragma: no cover - pass - - parser = depends_on if decorator.name == "depends_on" else produces - result = list(_extract_nodes_from_function_markers(task_example, parser)) - assert result == expected - - -@pytest.mark.unit() -@pytest.mark.parametrize("decorator", [pytask.mark.depends_on, pytask.mark.produces]) -@pytest.mark.parametrize( - ("args", "kwargs"), [(["a", "b"], {"objects": "a"}), (("a"), {"objects": "a"})] -) -def test_raise_error_for_invalid_args_to_depends_on_and_produces( - decorator, args, kwargs -): - @decorator(*args, **kwargs) - def task_example(): # pragma: no cover - pass - - parser = depends_on if decorator.name == "depends_on" else produces - with pytest.raises(TypeError): - list(_extract_nodes_from_function_markers(task_example, parser)) +if TYPE_CHECKING: + from pytask import Product @pytest.mark.unit() diff --git a/tests/test_dag.py b/tests/test_dag.py index cbdfae7e..f801b7cd 100644 --- a/tests/test_dag.py +++ b/tests/test_dag.py @@ -41,16 +41,12 @@ def test_pytask_dag_create_dag(): @pytest.mark.end_to_end() def test_cycle_in_dag(tmp_path, runner, snapshot_cli): source = """ - import pytask + from pathlib import Path - @pytask.mark.depends_on("out_2.txt") - @pytask.mark.produces("out_1.txt") - def task_1(produces): + def task_1(path = Path("out_2.txt"), produces = Path("out_1.txt")): produces.write_text("1") - @pytask.mark.depends_on("out_1.txt") - @pytask.mark.produces("out_2.txt") - def task_2(produces): + def task_2(path = Path("out_1.txt"), produces = Path("out_2.txt")): produces.write_text("2") """ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) @@ -65,14 +61,12 @@ def task_2(produces): @pytest.mark.end_to_end() def test_two_tasks_have_the_same_product(tmp_path, runner, snapshot_cli): source = """ - import pytask + from pathlib import Path - @pytask.mark.produces("out.txt") - def task_1(produces): + def task_1(produces = Path("out.txt")): produces.write_text("1") - @pytask.mark.produces("out.txt") - def task_2(produces): + def task_2(produces = Path("out.txt")): produces.write_text("2") """ tmp_path.joinpath("task_d.py").write_text(textwrap.dedent(source)) @@ -89,10 +83,9 @@ def test_has_node_changed_catches_notnotfounderror(runner, tmp_path): """Missing nodes raise NodeNotFoundError when they do not exist and their state is requested.""" source = """ - import pytask + from pathlib import Path - @pytask.mark.produces("file.txt") - def task_example(produces): + def task_example(produces = Path("file.txt")): produces.write_text("test") """ tmp_path.joinpath("task_example.py").write_text(textwrap.dedent(source)) diff --git a/tests/test_dag_command.py b/tests/test_dag_command.py index 96143212..cf0113b7 100644 --- a/tests/test_dag_command.py +++ b/tests/test_dag_command.py @@ -35,10 +35,9 @@ def test_create_graph_via_cli(tmp_path, runner, format_, layout, rankdir): pytest.xfail("gvplugin_pango.dll might be missing on Github Actions.") source = """ - import pytask + from pathlib import Path - @pytask.mark.depends_on("input.txt") - def task_example(): pass + def task_example(path=Path("input.txt")): ... """ tmp_path.joinpath("task_example.py").write_text(textwrap.dedent(source)) tmp_path.joinpath("input.txt").touch() @@ -77,8 +76,7 @@ def test_create_graph_via_task(tmp_path, runner, format_, layout, rankdir): from pathlib import Path import networkx as nx - @pytask.mark.depends_on("input.txt") - def task_example(depends_on): pass + def task_example(path=Path("input.txt")): ... def task_create_graph(): dag = pytask.build_dag({{"paths": Path(__file__).parent}}) @@ -106,10 +104,9 @@ def test_raise_error_with_graph_via_cli_missing_optional_dependency( monkeypatch, tmp_path, runner ): source = """ - import pytask + from pathlib import Path - @pytask.mark.depends_on("input.txt") - def task_example(): pass + def task_example(path=Path("input.txt")): ... """ tmp_path.joinpath("task_example.py").write_text(textwrap.dedent(source)) tmp_path.joinpath("input.txt").touch() @@ -175,10 +172,9 @@ def test_raise_error_with_graph_via_cli_missing_optional_program( monkeypatch.setattr("_pytask.compat.shutil.which", lambda x: None) # noqa: ARG005 source = """ - import pytask + from pathlib import Path - @pytask.mark.depends_on("input.txt") - def task_example(): pass + def task_example(path=Path("input.txt")): ... """ tmp_path.joinpath("task_example.py").write_text(textwrap.dedent(source)) tmp_path.joinpath("input.txt").touch() diff --git a/tests/test_database.py b/tests/test_database.py index 1639360c..82f6531d 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -17,11 +17,9 @@ def test_existence_of_hashes_in_db(tmp_path): """Modification dates of input and output files are stored in database.""" source = """ - import pytask + from pathlib import Path - @pytask.mark.depends_on("in.txt") - @pytask.mark.produces("out.txt") - def task_write(depends_on, produces): + def task_write(path=Path("in.txt"), produces=Path("out.txt")): produces.touch() """ task_path = tmp_path.joinpath("task_module.py") @@ -42,7 +40,7 @@ def task_write(depends_on, produces): with DatabaseSession() as db_session: task_id = session.tasks[0].signature out_path = tmp_path.joinpath("out.txt") - in_id = session.tasks[0].depends_on["depends_on"].signature + in_id = session.tasks[0].depends_on["path"].signature out_id = session.tasks[0].produces["produces"].signature for id_, path in ( diff --git a/tests/test_debugging.py b/tests/test_debugging.py index 77802a62..83e96743 100644 --- a/tests/test_debugging.py +++ b/tests/test_debugging.py @@ -71,12 +71,10 @@ def task_example(): @pytest.mark.skipif(sys.platform == "win32", reason="pexpect cannot spawn on Windows.") def test_post_mortem_on_error_w_kwargs(tmp_path): source = """ - import pytask from pathlib import Path - @pytask.mark.depends_on(Path(__file__).parent / "in.txt") - def task_example(depends_on): - a = depends_on.read_text() + def task_example(path=Path("in.txt")): + a = path.read_text() assert 0 """ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) @@ -113,12 +111,10 @@ def task_example(): @pytest.mark.skipif(sys.platform == "win32", reason="pexpect cannot spawn on Windows.") def test_trace_w_kwargs(tmp_path): source = """ - import pytask from pathlib import Path - @pytask.mark.depends_on(Path(__file__).parent / "in.txt") - def task_example(depends_on): - print(depends_on.read_text()) + def task_example(path=Path("in.txt")): + print(path.read_text()) """ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) tmp_path.joinpath("in.txt").write_text("I want you back.") diff --git a/tests/test_dry_run.py b/tests/test_dry_run.py index 13114c05..1c0e31dc 100644 --- a/tests/test_dry_run.py +++ b/tests/test_dry_run.py @@ -10,11 +10,9 @@ @pytest.mark.end_to_end() def test_dry_run(runner, tmp_path): source = """ - import pytask + from pathlib import Path - @pytask.mark.produces("out.txt") - def task_example(produces): - produces.touch() + def task_example(produces=Path("out.txt")): produces.touch() """ tmp_path.joinpath("task_example.py").write_text(textwrap.dedent(source)) @@ -29,20 +27,17 @@ def task_example(produces): def test_dry_run_w_subsequent_task(runner, tmp_path): """Subsequent tasks would be executed if their previous task changed.""" source = """ - import pytask + from pathlib import Path - @pytask.mark.depends_on("out.txt") - @pytask.mark.produces("out_2.txt") - def task_example(depends_on, produces): + def task_example(path=Path("out.txt"), produces=Path("out_2.txt")): produces.touch() """ tmp_path.joinpath("task_example_second.py").write_text(textwrap.dedent(source)) source = """ - import pytask + from pathlib import Path - @pytask.mark.produces("out.txt") - def task_example(produces): + def task_example(produces=Path("out.txt")): produces.touch() """ tmp_path.joinpath("task_example_first.py").write_text(textwrap.dedent(source)) @@ -67,20 +62,17 @@ def task_example(produces): def test_dry_run_w_subsequent_skipped_task(runner, tmp_path): """A skip is more important than a would be run.""" source_1 = """ - import pytask + from pathlib import Path - @pytask.mark.produces("out.txt") - def task_example(produces): + def task_example(produces=Path("out.txt")): produces.touch() """ tmp_path.joinpath("task_example_first.py").write_text(textwrap.dedent(source_1)) source_2 = """ - import pytask + from pathlib import Path - @pytask.mark.depends_on("out.txt") - @pytask.mark.produces("out_2.txt") - def task_example(depends_on, produces): + def task_example(path=Path("out.txt"), produces=Path("out_2.txt")): produces.touch() """ tmp_path.joinpath("task_example_second.py").write_text(textwrap.dedent(source_2)) @@ -106,12 +98,12 @@ def task_example(depends_on, produces): def test_dry_run_skip(runner, tmp_path): source = """ import pytask + from pathlib import Path @pytask.mark.skip def task_example_skip(): ... - @pytask.mark.produces("out.txt") - def task_example(produces): + def task_example(produces=Path("out.txt")): produces.touch() """ tmp_path.joinpath("task_example.py").write_text(textwrap.dedent(source)) @@ -128,14 +120,13 @@ def task_example(produces): def test_dry_run_skip_all(runner, tmp_path): source = """ import pytask + from pathlib import Path @pytask.mark.skip - @pytask.mark.produces("out.txt") - def task_example_skip(): ... + def task_example_skip(produces=Path("out.txt")): ... @pytask.mark.skip - @pytask.mark.depends_on("out.txt") - def task_example_skip_subsequent(): ... + def task_example_skip_subsequent(path=Path("out.txt")): ... """ tmp_path.joinpath("task_example.py").write_text(textwrap.dedent(source)) @@ -148,10 +139,9 @@ def task_example_skip_subsequent(): ... @pytest.mark.end_to_end() def test_dry_run_skipped_successful(runner, tmp_path): source = """ - import pytask + from pathlib import Path - @pytask.mark.produces("out.txt") - def task_example(produces): + def task_example(produces=Path("out.txt")): produces.touch() """ tmp_path.joinpath("task_example.py").write_text(textwrap.dedent(source)) @@ -171,10 +161,10 @@ def task_example(produces): def test_dry_run_persisted(runner, tmp_path): source = """ import pytask + from pathlib import Path @pytask.mark.persist - @pytask.mark.produces("out.txt") - def task_example(produces): + def task_example(produces=Path("out.txt")): produces.touch() """ tmp_path.joinpath("task_example.py").write_text(textwrap.dedent(source)) diff --git a/tests/test_execute.py b/tests/test_execute.py index ea8f0819..2ba048d2 100644 --- a/tests/test_execute.py +++ b/tests/test_execute.py @@ -42,10 +42,9 @@ def test_execute_w_autocollect(runner, tmp_path): @pytest.mark.end_to_end() def test_task_did_not_produce_node(tmp_path): source = """ - import pytask + from pathlib import Path - @pytask.mark.produces("out.txt") - def task_example(): ... + def task_example(produces=Path("out.txt")): ... """ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) @@ -59,10 +58,9 @@ def task_example(): ... @pytest.mark.end_to_end() def test_task_did_not_produce_multiple_nodes_and_all_are_shown(runner, tmp_path): source = """ - import pytask + from pathlib import Path - @pytask.mark.produces(["1.txt", "2.txt"]) - def task_example(): ... + def task_example(produces=[Path("1.txt"), Path("2.txt")]): ... """ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) @@ -112,46 +110,14 @@ def task_3(paths = [Path("deleted.txt"), Path("out_2.txt")]): assert isinstance(report.exc_info[1], NodeNotFoundError) -@pytest.mark.end_to_end() -@pytest.mark.parametrize( - "dependencies", - [[], ["in.txt"], ["in_1.txt", "in_2.txt"]], -) -@pytest.mark.parametrize("products", [["out.txt"], ["out_1.txt", "out_2.txt"]]) -def test_execution_w_varying_dependencies_products(tmp_path, dependencies, products): - source = f""" - import pytask - from pathlib import Path - - @pytask.mark.depends_on({dependencies}) - @pytask.mark.produces({products}) - def task_example(depends_on, produces): - if isinstance(produces, dict): - produces = produces.values() - elif isinstance(produces, Path): - produces = [produces] - for product in produces: - product.touch() - """ - tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) - for dependency in dependencies: - tmp_path.joinpath(dependency).touch() - - session = build(paths=tmp_path) - assert session.exit_code == ExitCode.OK - - @pytest.mark.end_to_end() def test_depends_on_and_produces_can_be_used_in_task(tmp_path): source = """ - import pytask from pathlib import Path - @pytask.mark.depends_on("in.txt") - @pytask.mark.produces("out.txt") - def task_example(depends_on, produces): - assert isinstance(depends_on, Path) and isinstance(produces, Path) - produces.write_text(depends_on.read_text()) + def task_example(path=Path("in.txt"), produces=Path("out.txt")): + assert isinstance(path, Path) and isinstance(produces, Path) + produces.write_text(path.read_text()) """ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) tmp_path.joinpath("in.txt").write_text("Here I am. Once again.") @@ -162,90 +128,6 @@ def task_example(depends_on, produces): assert tmp_path.joinpath("out.txt").read_text() == "Here I am. Once again." -@pytest.mark.end_to_end() -def test_assert_multiple_dependencies_are_merged_to_dict(tmp_path, runner): - source = """ - import pytask - from pathlib import Path - - @pytask.mark.depends_on({3: "in_3.txt", 4: "in_4.txt"}) - @pytask.mark.depends_on(["in_1.txt", "in_2.txt"]) - @pytask.mark.depends_on("in_0.txt") - @pytask.mark.produces("out.txt") - def task_example(depends_on, produces): - expected = { - i: Path(__file__).parent.joinpath(f"in_{i}.txt").resolve() - for i in range(5) - } - assert depends_on == expected - produces.touch() - """ - tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) - for name in [f"in_{i}.txt" for i in range(5)]: - tmp_path.joinpath(name).touch() - - result = runner.invoke(cli, [tmp_path.as_posix()]) - - assert result.exit_code == ExitCode.OK - - -@pytest.mark.end_to_end() -def test_assert_multiple_products_are_merged_to_dict(tmp_path, runner): - source = """ - import pytask - from pathlib import Path - - @pytask.mark.depends_on("in.txt") - @pytask.mark.produces({3: "out_3.txt", 4: "out_4.txt"}) - @pytask.mark.produces(["out_1.txt", "out_2.txt"]) - @pytask.mark.produces("out_0.txt") - def task_example(depends_on, produces): - expected = { - i: Path(__file__).parent.joinpath(f"out_{i}.txt").resolve() - for i in range(5) - } - assert produces == expected - for product in produces.values(): - product.touch() - """ - tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) - tmp_path.joinpath("in.txt").touch() - - result = runner.invoke(cli, [tmp_path.as_posix()]) - - assert result.exit_code == ExitCode.OK - - -@pytest.mark.end_to_end() -@pytest.mark.parametrize("input_type", ["list", "dict"]) -def test_preserve_input_for_dependencies_and_products(tmp_path, input_type): - """Input type for dependencies and products is preserved.""" - path = tmp_path.joinpath("in.txt") - input_ = {0: path.as_posix()} if input_type == "dict" else [path.as_posix()] - path.touch() - - path = tmp_path.joinpath("out.txt") - output = {0: path.as_posix()} if input_type == "dict" else [path.as_posix()] - - source = f""" - import pytask - from pathlib import Path - - @pytask.mark.depends_on({input_}) - @pytask.mark.produces({output}) - def task_example(depends_on, produces): - for nodes in [depends_on, produces]: - assert isinstance(nodes, dict) - assert len(nodes) == 1 - assert 0 in nodes - produces[0].touch() - """ - tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) - - session = build(paths=tmp_path) - assert session.exit_code == ExitCode.OK - - @pytest.mark.end_to_end() @pytest.mark.parametrize("n_failures", [1, 2, 3]) def test_execution_stops_after_n_failures(tmp_path, n_failures): @@ -329,13 +211,11 @@ def task_error(): raise ValueError @pytest.mark.parametrize("verbose", [1, 2]) def test_traceback_of_previous_task_failed_is_not_shown(runner, tmp_path, verbose): source = """ - import pytask + from pathlib import Path - @pytask.mark.produces("in.txt") - def task_first(): raise ValueError + def task_first(produces=Path("in.txt")): raise ValueError - @pytask.mark.depends_on("in.txt") - def task_second(): pass + def task_second(path=Path("in.txt")): ... """ tmp_path.joinpath("task_example.py").write_text(textwrap.dedent(source)) @@ -429,9 +309,8 @@ def task_example({arg_name}: Annotated[Path, Product] = Path("out.txt")) -> None def test_task_errors_with_nested_product_annotation(tmp_path): source = """ from pathlib import Path - from typing_extensions import Annotated + from typing_extensions import Annotated, Dict from pytask import Product - from typing import Dict def task_example( paths_to_file: Dict[str, Annotated[Path, Product]] = {"a": Path("out.txt")} @@ -461,8 +340,7 @@ def test_task_with_hashed_python_node(runner, tmp_path, definition): import json from pathlib import Path from pytask import Product, PythonNode - from typing import Any - from typing_extensions import Annotated + from typing_extensions import Annotated, Any data = json.loads(Path(__file__).parent.joinpath("data.json").read_text()) @@ -711,9 +589,7 @@ def test_execute_tasks_via_functional_api(tmp_path): import sys from pathlib import Path from typing_extensions import Annotated - from pytask import PathNode - import pytask - from pytask import PythonNode + from pytask import PathNode, PythonNode, build node_text = PythonNode() @@ -726,7 +602,7 @@ def create_file(content: Annotated[str, node_text]) -> Annotated[str, node_file] return content if __name__ == "__main__": - session = pytask.build(tasks=[create_file, create_text]) + session = build(tasks=[create_file, create_text]) assert len(session.tasks) == 2 assert len(session.dag.nodes) == 5 sys.exit(session.exit_code) @@ -748,7 +624,6 @@ def test_pass_non_task_to_functional_api_that_are_ignored(): @pytest.mark.end_to_end() def test_multiple_product_annotations(runner, tmp_path): source = """ - from __future__ import annotations from pytask import Product from typing_extensions import Annotated from pathlib import Path @@ -879,11 +754,9 @@ def task_write_file(text: Annotated[str, node]) -> Annotated[str, Path("file.txt @pytest.mark.end_to_end() def test_check_if_root_nodes_are_available(tmp_path, runner): source = """ - import pytask + from pathlib import Path - @pytask.mark.depends_on("in.txt") - @pytask.mark.produces("out.txt") - def task_d(produces): + def task_d(path=Path("in.txt"), produces=Path("out.txt")): produces.write_text("1") """ tmp_path.joinpath("task_d.py").write_text(textwrap.dedent(source)) @@ -920,11 +793,9 @@ def test_check_if_root_nodes_are_available_with_separate_build_folder(tmp_path, tmp_path.joinpath("src").mkdir() tmp_path.joinpath("bld").mkdir() source = """ - import pytask + from pathlib import Path - @pytask.mark.depends_on("../bld/in.txt") - @pytask.mark.produces("out.txt") - def task_d(produces): + def task_d(path=Path("../bld/in.txt"), produces=Path("out.txt")): produces.write_text("1") """ tmp_path.joinpath("src", "task_d.py").write_text(textwrap.dedent(source)) diff --git a/tests/test_live.py b/tests/test_live.py index 468ce36b..65760fc5 100644 --- a/tests/test_live.py +++ b/tests/test_live.py @@ -259,12 +259,13 @@ def test_live_execution_skips_do_not_crowd_out_displayed_tasks(capsys, tmp_path) @pytest.mark.end_to_end() def test_full_execution_table_is_displayed_at_the_end_of_execution(tmp_path, runner): source = """ - import pytask + from pytask import task + from pathlib import Path - for produces in [f"{i}.txt" for i in range(4)]: + for i in range(4): - @pytask.mark.task - def task_create_file(produces=produces): + @task + def task_create_file(produces=Path(f"{i}.txt")): produces.touch() """ # Subfolder to reduce task id and be able to check the output later. @@ -277,7 +278,7 @@ def task_create_file(produces=produces): assert result.exit_code == ExitCode.OK for i in range(4): - assert f"{i}.txt" in result.output + assert f"[produces{i}]" in result.output @pytest.mark.end_to_end() diff --git a/tests/test_mark.py b/tests/test_mark.py index 8f27404a..aaeb4c59 100644 --- a/tests/test_mark.py +++ b/tests/test_mark.py @@ -1,6 +1,5 @@ from __future__ import annotations -import subprocess import sys import textwrap @@ -253,16 +252,12 @@ def test_configuration_failed(runner, tmp_path): @pytest.mark.end_to_end() def test_selecting_task_with_keyword_should_run_predecessor(runner, tmp_path): source = """ - import pytask + from pathlib import Path - @pytask.mark.produces("first.txt") - def task_first(produces): + def task_first(produces=Path("first.txt")): produces.touch() - - @pytask.mark.depends_on("first.txt") - def task_second(depends_on): - pass + def task_second(path=Path("first.txt")): ... """ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) @@ -276,15 +271,13 @@ def task_second(depends_on): def test_selecting_task_with_marker_should_run_predecessor(runner, tmp_path): source = """ import pytask + from pathlib import Path - @pytask.mark.produces("first.txt") - def task_first(produces): + def task_first(produces=Path("first.txt")): produces.touch() @pytask.mark.wip - @pytask.mark.depends_on("first.txt") - def task_second(depends_on): - pass + def task_second(path=Path("first.txt")): ... """ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) @@ -298,14 +291,11 @@ def task_second(depends_on): @pytest.mark.end_to_end() def test_selecting_task_with_keyword_ignores_other_task(runner, tmp_path): source = """ - import pytask + from pathlib import Path - @pytask.mark.depends_on("first.txt") - def task_first(): - pass + def task_first(path=Path("first.txt")): ... - def task_second(): - pass + def task_second(): ... """ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) @@ -320,14 +310,12 @@ def task_second(): def test_selecting_task_with_marker_ignores_other_task(runner, tmp_path): source = """ import pytask + from pathlib import Path - @pytask.mark.depends_on("first.txt") - def task_first(): - pass + def task_first(path=Path("first.txt")): ... @pytask.mark.wip - def task_second(): - pass + def task_second(): ... """ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) @@ -345,8 +333,7 @@ def test_selecting_task_with_unknown_marker_raises_warning(runner, tmp_path): import pytask @pytask.mark.wip - def task_example(): - pass + def task_example(): ... """ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) @@ -357,46 +344,6 @@ def task_example(): assert "Warnings" in result.output -@pytest.mark.end_to_end() -def test_deprecation_warnings_for_decorators(tmp_path): - source = """ - import pytask - - @pytask.mark.depends_on("in.txt") - @pytask.mark.produces("out.txt") - def task_write_text(depends_on, produces): - ... - """ - tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) - tmp_path.joinpath("in.txt").touch() - - result = subprocess.run( - ("pytest", tmp_path.joinpath("task_module.py").as_posix()), - capture_output=True, - check=False, - ) - assert b"FutureWarning: '@pytask.mark.depends_on'" in result.stdout - assert b"FutureWarning: '@pytask.mark.produces'" in result.stdout - - -@pytest.mark.end_to_end() -def test_deprecation_warnings_for_task_decorator(tmp_path): - source = """ - import pytask - - @pytask.mark.task - def task_write_text(): ... - """ - tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) - - result = subprocess.run( - ("pytest", tmp_path.joinpath("task_module.py").as_posix()), - capture_output=True, - check=False, - ) - assert b"FutureWarning: '@pytask.mark.task'" in result.stdout - - @pytest.mark.end_to_end() def test_different_mark_import(runner, tmp_path): source = """ diff --git a/tests/test_mark_cli.py b/tests/test_mark_cli.py index 23c0f54b..04949a5e 100644 --- a/tests/test_mark_cli.py +++ b/tests/test_mark_cli.py @@ -15,11 +15,14 @@ def test_show_markers(runner): assert all( marker in result.output for marker in ( - "depends_on", - "produces", + "filterwarnings", + "persist", "skip", "skip_ancestor_failed", "skip_unchanged", + "skipif", + "try_first", + "try_last", ) ) diff --git a/tests/test_mark_utils.py b/tests/test_mark_utils.py index fdcbc2a2..f0d82d31 100644 --- a/tests/test_mark_utils.py +++ b/tests/test_mark_utils.py @@ -19,12 +19,12 @@ [ ([], []), ( - [pytask.mark.produces(), pytask.mark.depends_on()], - [pytask.mark.produces(), pytask.mark.depends_on()], + [pytask.mark.mark1(), pytask.mark.mark2()], + [pytask.mark.mark1(), pytask.mark.mark2()], ), ( - [pytask.mark.produces(), pytask.mark.produces(), pytask.mark.depends_on()], - [pytask.mark.produces(), pytask.mark.produces(), pytask.mark.depends_on()], + [pytask.mark.mark1(), pytask.mark.mark1(), pytask.mark.mark2()], + [pytask.mark.mark1(), pytask.mark.mark1(), pytask.mark.mark2()], ), ], ) @@ -41,12 +41,12 @@ def test_get_all_marks_from_task(markers, expected): (None, []), ([], []), ( - [pytask.mark.produces(), pytask.mark.depends_on()], - [pytask.mark.produces(), pytask.mark.depends_on()], + [pytask.mark.mark1(), pytask.mark.mark2()], + [pytask.mark.mark1(), pytask.mark.mark2()], ), ( - [pytask.mark.produces(), pytask.mark.produces(), pytask.mark.depends_on()], - [pytask.mark.produces(), pytask.mark.produces(), pytask.mark.depends_on()], + [pytask.mark.mark1(), pytask.mark.mark1(), pytask.mark.mark2()], + [pytask.mark.mark1(), pytask.mark.mark1(), pytask.mark.mark2()], ), ], ) @@ -67,14 +67,14 @@ def func(): [ ([], "not_found", []), ( - [pytask.mark.produces(), pytask.mark.depends_on()], - "produces", - [pytask.mark.produces()], + [pytask.mark.mark1(), pytask.mark.mark2()], + "mark1", + [pytask.mark.mark1()], ), ( - [pytask.mark.produces(), pytask.mark.produces(), pytask.mark.depends_on()], - "produces", - [pytask.mark.produces(), pytask.mark.produces()], + [pytask.mark.mark1(), pytask.mark.mark1(), pytask.mark.mark2()], + "mark1", + [pytask.mark.mark1(), pytask.mark.mark1()], ), ], ) @@ -91,14 +91,14 @@ def test_get_marks_from_task(markers, marker_name, expected): (None, "not_found", []), ([], "not_found", []), ( - [pytask.mark.produces(), pytask.mark.depends_on()], - "produces", - [pytask.mark.produces()], + [pytask.mark.mark1(), pytask.mark.mark2()], + "mark1", + [pytask.mark.mark1()], ), ( - [pytask.mark.produces(), pytask.mark.produces(), pytask.mark.depends_on()], - "produces", - [pytask.mark.produces(), pytask.mark.produces()], + [pytask.mark.mark1(), pytask.mark.mark1(), pytask.mark.mark2()], + "mark1", + [pytask.mark.mark1(), pytask.mark.mark1()], ), ], ) @@ -117,14 +117,14 @@ def func(): @pytest.mark.parametrize( ("markers", "marker_name", "expected"), [ - ([pytask.mark.produces()], "not_found", False), + ([pytask.mark.mark1()], "not_found", False), ( - [pytask.mark.produces(), pytask.mark.depends_on()], - "produces", + [pytask.mark.mark1(), pytask.mark.mark2()], + "mark1", True, ), ( - [pytask.mark.produces(), pytask.mark.produces(), pytask.mark.depends_on()], + [pytask.mark.mark1(), pytask.mark.mark1(), pytask.mark.mark2()], "other", False, ), @@ -142,10 +142,10 @@ def test_has_mark_for_task(markers, marker_name, expected): [ (None, "not_found", False), ([], "not_found", False), - ([pytask.mark.produces(), pytask.mark.depends_on()], "produces", True), + ([pytask.mark.mark1(), pytask.mark.mark2()], "mark1", True), ( - [pytask.mark.produces(), pytask.mark.produces(), pytask.mark.depends_on()], - "produces", + [pytask.mark.mark1(), pytask.mark.mark1(), pytask.mark.mark2()], + "mark1", True, ), ], @@ -167,16 +167,16 @@ def func(): [ ([], "not_found", [], []), ( - [pytask.mark.produces(), pytask.mark.depends_on()], - "produces", - [pytask.mark.produces()], - [pytask.mark.depends_on()], + [pytask.mark.mark1(), pytask.mark.mark2()], + "mark1", + [pytask.mark.mark1()], + [pytask.mark.mark2()], ), ( - [pytask.mark.produces(), pytask.mark.produces(), pytask.mark.depends_on()], - "produces", - [pytask.mark.produces(), pytask.mark.produces()], - [pytask.mark.depends_on()], + [pytask.mark.mark1(), pytask.mark.mark1(), pytask.mark.mark2()], + "mark1", + [pytask.mark.mark1(), pytask.mark.mark1()], + [pytask.mark.mark2()], ), ], ) @@ -196,16 +196,16 @@ def test_remove_marks_from_task( (None, "not_found", [], []), ([], "not_found", [], []), ( - [pytask.mark.produces(), pytask.mark.depends_on()], - "produces", - [pytask.mark.produces()], - [pytask.mark.depends_on()], + [pytask.mark.mark1(), pytask.mark.mark2()], + "mark1", + [pytask.mark.mark1()], + [pytask.mark.mark2()], ), ( - [pytask.mark.produces(), pytask.mark.produces(), pytask.mark.depends_on()], - "produces", - [pytask.mark.produces(), pytask.mark.produces()], - [pytask.mark.depends_on()], + [pytask.mark.mark1(), pytask.mark.mark1(), pytask.mark.mark2()], + "mark1", + [pytask.mark.mark1(), pytask.mark.mark1()], + [pytask.mark.mark2()], ), ], ) @@ -229,8 +229,8 @@ def func(): "markers", [ [], - [pytask.mark.produces(), pytask.mark.depends_on()], - [pytask.mark.produces(), pytask.mark.produces(), pytask.mark.depends_on()], + [pytask.mark.mark1(), pytask.mark.mark2()], + [pytask.mark.mark1(), pytask.mark.mark1(), pytask.mark.mark2()], ], ) def test_set_marks_to_task(markers): @@ -244,8 +244,8 @@ def test_set_marks_to_task(markers): "markers", [ [], - [pytask.mark.produces(), pytask.mark.depends_on()], - [pytask.mark.produces(), pytask.mark.produces(), pytask.mark.depends_on()], + [pytask.mark.mark1(), pytask.mark.mark2()], + [pytask.mark.mark1(), pytask.mark.mark1(), pytask.mark.mark2()], ], ) def test_set_marks_to_obj(markers): diff --git a/tests/test_persist.py b/tests/test_persist.py index db57e2c8..8909b405 100644 --- a/tests/test_persist.py +++ b/tests/test_persist.py @@ -39,12 +39,11 @@ def test_multiple_runs_with_persist(tmp_path): """ source = """ import pytask + from pathlib import Path @pytask.mark.persist - @pytask.mark.depends_on("in.txt") - @pytask.mark.produces("out.txt") - def task_dummy(depends_on, produces): - produces.write_text(depends_on.read_text()) + def task_dummy(path=Path("in.txt"), produces=Path("out.txt")): + produces.write_text(path.read_text()) """ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) tmp_path.joinpath("in.txt").write_text("I'm not the reason you care.") @@ -91,11 +90,10 @@ def task_dummy(depends_on, produces): def test_migrating_a_whole_task_with_persist(tmp_path): source = """ import pytask + from pathlib import Path @pytask.mark.persist - @pytask.mark.depends_on("in.txt") - @pytask.mark.produces("out.txt") - def task_dummy(depends_on, produces): + def task_dummy(depends_on=Path("in.txt"), produces=Path("out.txt")): produces.write_text(depends_on.read_text()) """ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) diff --git a/tests/test_profile.py b/tests/test_profile.py index 6c3bfcda..2bd8c031 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -63,10 +63,9 @@ def task_example(): time.sleep(2) def test_profile_if_there_is_information_on_collected_tasks(tmp_path, runner): source = """ import time - import pytask + from pathlib import Path - @pytask.mark.produces("out.txt") - def task_example(produces): + def task_example(produces=Path("out.txt")): time.sleep(2) produces.write_text("There are nine billion bicycles in Beijing.") """ diff --git a/tests/test_skipping.py b/tests/test_skipping.py index 177c3a4c..fbe0606d 100644 --- a/tests/test_skipping.py +++ b/tests/test_skipping.py @@ -40,12 +40,10 @@ def task_dummy(): @pytest.mark.end_to_end() def test_skip_unchanged_w_dependencies_and_products(tmp_path): source = """ - import pytask + from pathlib import Path - @pytask.mark.depends_on("in.txt") - @pytask.mark.produces("out.txt") - def task_dummy(depends_on, produces): - produces.write_text(depends_on.read_text()) + def task_dummy(path=Path("in.txt"), produces=Path("out.txt")): + produces.write_text(path.read_text()) """ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) tmp_path.joinpath("in.txt").write_text("Original content of in.txt.") @@ -65,15 +63,12 @@ def task_dummy(depends_on, produces): @pytest.mark.end_to_end() def test_skipif_ancestor_failed(tmp_path): source = """ - import pytask + from pathlib import Path - @pytask.mark.produces("out.txt") - def task_first(): + def task_first(produces=Path("out.txt")): assert 0 - @pytask.mark.depends_on("out.txt") - def task_second(): - pass + def task_second(path=Path("out.txt")): ... """ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) @@ -89,15 +84,13 @@ def task_second(): def test_if_skip_decorator_is_applied_to_following_tasks(tmp_path): source = """ import pytask + from pathlib import Path @pytask.mark.skip - @pytask.mark.produces("out.txt") - def task_first(): + def task_first(produces=Path("out.txt")): assert 0 - @pytask.mark.depends_on("out.txt") - def task_second(): - pass + def task_second(path=Path("out.txt")): ... """ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) @@ -116,10 +109,10 @@ def task_second(): def test_skip_if_dependency_is_missing(tmp_path, mark_string): source = f""" import pytask + from pathlib import Path {mark_string} - @pytask.mark.depends_on("in.txt") - def task_first(): + def task_first(path=Path("in.txt")): assert 0 """ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) @@ -137,14 +130,13 @@ def task_first(): def test_skip_if_dependency_is_missing_only_for_one_task(runner, tmp_path, mark_string): source = f""" import pytask + from pathlib import Path {mark_string} - @pytask.mark.depends_on("in.txt") - def task_first(): + def task_first(path=Path("in.txt")): assert 0 - @pytask.mark.depends_on("in.txt") - def task_second(): + def task_second(path=Path("in.txt")): assert 0 """ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) @@ -161,14 +153,13 @@ def task_second(): def test_if_skipif_decorator_is_applied_skipping(tmp_path): source = """ import pytask + from pathlib import Path @pytask.mark.skipif(condition=True, reason="bla") - @pytask.mark.produces("out.txt") - def task_first(): + def task_first(produces=Path("out.txt")): assert False - @pytask.mark.depends_on("out.txt") - def task_second(): + def task_second(path=Path("out.txt")): assert False """ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) @@ -191,15 +182,13 @@ def task_second(): def test_if_skipif_decorator_is_applied_execute(tmp_path): source = """ import pytask + from pathlib import Path @pytask.mark.skipif(False, reason="bla") - @pytask.mark.produces("out.txt") - def task_first(produces): + def task_first(produces=Path("out.txt")): produces.touch() - @pytask.mark.depends_on("out.txt") - def task_second(depends_on): - pass + def task_second(path=Path("out.txt")): ... """ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) @@ -221,15 +210,14 @@ def test_if_skipif_decorator_is_applied_any_condition_matches(tmp_path): """Any condition of skipif has to be True and only their message is shown.""" source = """ import pytask + from pathlib import Path @pytask.mark.skipif(condition=False, reason="I am fine") @pytask.mark.skipif(condition=True, reason="No, I am not.") - @pytask.mark.produces("out.txt") - def task_first(): + def task_first(produces=Path("out.txt")): assert False - @pytask.mark.depends_on("out.txt") - def task_second(): + def task_second(path=Path("out.txt")): assert False """ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) diff --git a/tests/test_task.py b/tests/test_task.py index e4b00014..968480e8 100644 --- a/tests/test_task.py +++ b/tests/test_task.py @@ -14,11 +14,11 @@ def test_task_with_task_decorator(tmp_path, func_name, task_name): task_decorator_input = f"{task_name!r}" if task_name else task_name source = f""" - import pytask + from pytask import task + from pathlib import Path - @pytask.mark.task({task_decorator_input}) - @pytask.mark.produces("out.txt") - def {func_name}(produces): + @task({task_decorator_input}) + def {func_name}(produces=Path("out.txt")): produces.write_text("Hello. It's me.") """ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) @@ -36,13 +36,13 @@ def {func_name}(produces): @pytest.mark.end_to_end() def test_parametrization_in_for_loop(tmp_path, runner): source = """ - import pytask + from pytask import task + from pathlib import Path for i in range(2): - @pytask.mark.task - @pytask.mark.produces(f"out_{i}.txt") - def task_example(produces): + @task + def task_example(produces=Path(f"out_{i}.txt")): produces.write_text("Your advertisement could be here.") """ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) @@ -57,15 +57,14 @@ def task_example(produces): @pytest.mark.end_to_end() def test_parametrization_in_for_loop_from_markers(tmp_path, runner): source = """ - import pytask + from pytask import task + from pathlib import Path for i in range(2): - @pytask.mark.task - @pytask.mark.depends_on(f"in_{i}.txt") - @pytask.mark.produces(f"out_{i}.txt") - def example(depends_on, produces): - produces.write_text(depends_on.read_text()) + @task + def example(path=Path(f"in_{i}.txt"), produces=Path(f"out_{i}.txt")): + produces.write_text(path.read_text()) """ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) tmp_path.joinpath("in_0.txt").write_text("Your advertisement could be here.") @@ -74,20 +73,21 @@ def example(depends_on, produces): result = runner.invoke(cli, [tmp_path.as_posix()]) assert result.exit_code == ExitCode.OK - assert "example[depends_on0-produces0]" in result.output - assert "example[depends_on1-produces1]" in result.output + assert "example[path0-produces0]" in result.output + assert "example[path1-produces1]" in result.output @pytest.mark.end_to_end() def test_parametrization_in_for_loop_from_signature(tmp_path, runner): source = """ - import pytask + from pytask import task + from pathlib import Path for i in range(2): - @pytask.mark.task - def example(depends_on=f"in_{i}.txt", produces=f"out_{i}.txt"): - produces.write_text(depends_on.read_text()) + @task + def example(path=Path(f"in_{i}.txt"), produces=Path(f"out_{i}.txt")): + produces.write_text(path.read_text()) """ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) tmp_path.joinpath("in_0.txt").write_text("Your advertisement could be here.") @@ -96,20 +96,20 @@ def example(depends_on=f"in_{i}.txt", produces=f"out_{i}.txt"): result = runner.invoke(cli, [tmp_path.as_posix()]) assert result.exit_code == ExitCode.OK - assert "example[in_0.txt-out_0.txt]" in result.output - assert "example[in_1.txt-out_1.txt]" in result.output + assert "example[path0-produces0]" in result.output + assert "example[path1-produces1]" in result.output @pytest.mark.end_to_end() def test_parametrization_in_for_loop_from_markers_and_args(tmp_path, runner): source = """ - import pytask + from pytask import task + from pathlib import Path for i in range(2): - @pytask.mark.task - @pytask.mark.produces(f"out_{i}.txt") - def example(produces, i=i): + @task + def example(produces=Path(f"out_{i}.txt"), i=i): produces.write_text(str(i)) """ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) @@ -124,11 +124,12 @@ def example(produces, i=i): @pytest.mark.end_to_end() def test_parametrization_in_for_loop_from_decorator(tmp_path, runner): source = """ - import pytask + from pytask import task + from pathlib import Path for i in range(2): - @pytask.mark.task(name="deco_task", kwargs={"i": i, "produces": f"out_{i}.txt"}) + @task(name="deco_task", kwargs={"i": i, "produces": Path(f"out_{i}.txt")}) def example(produces, i): produces.write_text(str(i)) """ @@ -137,20 +138,19 @@ def example(produces, i): result = runner.invoke(cli, [tmp_path.as_posix()]) assert result.exit_code == ExitCode.OK - assert "deco_task[out_0.txt-0]" in result.output - assert "deco_task[out_1.txt-1]" in result.output + assert "deco_task[produces0-0]" in result.output + assert "deco_task[produces1-1]" in result.output @pytest.mark.end_to_end() def test_parametrization_in_for_loop_with_ids(tmp_path, runner): source = """ - import pytask + from pytask import task + from pathlib import Path for i in range(2): - @pytask.mark.task( - "deco_task", id=str(i), kwargs={"i": i, "produces": f"out_{i}.txt"} - ) + @task("deco_task", id=str(i), kwargs={"i": i, "produces": Path(f"out_{i}.txt")}) def example(produces, i): produces.write_text(str(i)) """ @@ -166,12 +166,13 @@ def example(produces, i): @pytest.mark.end_to_end() def test_parametrization_in_for_loop_with_error(tmp_path, runner): source = """ - import pytask + from pytask import task + from pathlib import Path for i in range(2): - @pytask.mark.task - def task_example(produces=f"out_{i}.txt"): + @task + def task_example(produces=Path(f"out_{i}.txt")): raise ValueError """ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) @@ -181,23 +182,24 @@ def task_example(produces=f"out_{i}.txt"): assert result.exit_code == ExitCode.FAILED assert "2 Failed" in result.output assert "Traceback" in result.output - assert "task_example[out_0.txt]" in result.output - assert "task_example[out_1.txt]" in result.output + assert "task_example[produces0]" in result.output + assert "task_example[produces1]" in result.output @pytest.mark.end_to_end() def test_parametrization_in_for_loop_from_decorator_w_irregular_dicts(tmp_path, runner): source = """ - import pytask + from pytask import task + from pathlib import Path ID_TO_KWARGS = { - "first": {"i": 0, "produces": "out_0.txt"}, - "second": {"produces": "out_1.txt"}, + "first": {"i": 0, "produces": Path("out_0.txt")}, + "second": {"produces": Path("out_1.txt")}, } for id_, kwargs in ID_TO_KWARGS.items(): - @pytask.mark.task(name="deco_task", id=id_, kwargs=kwargs) + @task(name="deco_task", id=id_, kwargs=kwargs) def example(produces, i): produces.write_text(str(i)) """ @@ -216,13 +218,13 @@ def example(produces, i): @pytest.mark.end_to_end() def test_parametrization_in_for_loop_with_one_iteration(tmp_path, runner): source = """ - import pytask + from pytask import task + from pathlib import Path for i in range(1): - @pytask.mark.task - @pytask.mark.produces(f"out_{i}.txt") - def task_example(produces): + @task + def task_example(produces=Path(f"out_{i}.txt")): produces.write_text("Your advertisement could be here.") """ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) @@ -237,18 +239,17 @@ def task_example(produces): @pytest.mark.end_to_end() def test_parametrization_in_for_loop_and_normal(tmp_path, runner): source = """ - import pytask + from pytask import task + from pathlib import Path for i in range(1): - @pytask.mark.task - @pytask.mark.produces(f"out_{i}.txt") - def task_example(produces): + @task + def task_example(produces=Path(f"out_{i}.txt")): produces.write_text("Your advertisement could be here.") - @pytask.mark.task - @pytask.mark.produces(f"out_1.txt") - def task_example(produces): + @task + def task_example(produces=Path(f"out_1.txt")): produces.write_text("Your advertisement could be here.") """ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) @@ -264,18 +265,17 @@ def task_example(produces): @pytest.mark.end_to_end() def test_parametrized_names_without_parametrization(tmp_path, runner): source = """ - import pytask + from pytask import task + from pathlib import Path for i in range(2): - @pytask.mark.task - @pytask.mark.produces(f"out_{i}.txt") - def task_example(produces): + @task + def task_example(produces=Path(f"out_{i}.txt")): produces.write_text("Your advertisement could be here.") - @pytask.mark.task - @pytask.mark.produces("out_2.txt") - def task_example(produces): + @task + def task_example(produces=Path("out_2.txt")): produces.write_text("Your advertisement could be here.") """ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) @@ -292,12 +292,12 @@ def task_example(produces): @pytest.mark.end_to_end() def test_order_of_decorator_does_not_matter(tmp_path, runner): source = """ - import pytask + from pytask import task, mark + from pathlib import Path - @pytask.mark.skip - @pytask.mark.task - @pytask.mark.produces(f"out.txt") - def task_example(produces): + @task + @mark.skip + def task_example(produces=Path(f"out.txt")): produces.write_text("Your advertisement could be here.") """ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) @@ -311,15 +311,13 @@ def task_example(produces): @pytest.mark.end_to_end() def test_task_function_with_partialed_args(tmp_path, runner): source = """ - import pytask import functools + from pathlib import Path def func(produces, content): produces.write_text(content) - task_func = pytask.mark.produces("out.txt")( - functools.partial(func, content="hello") - ) + task_func = functools.partial(func, content="hello", produces=Path("out.txt")) """ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) @@ -363,29 +361,26 @@ def test_parametrized_tasks_without_arguments_in_signature(tmp_path, runner): """ source = f""" - import pytask + from pytask import task from pathlib import Path for i in range(1): - @pytask.mark.task - @pytask.mark.produces(f"out_{{i}}.txt") - def task_example(): + @task + def task_example(produces=Path(f"out_{{i}}.txt")): Path("{tmp_path.as_posix()}").joinpath(f"out_{{i}}.txt").write_text( "I use globals. How funny." ) - @pytask.mark.task - @pytask.mark.produces("out_1.txt") - def task_example(): + @task + def task_example(produces=Path("out_1.txt")): Path("{tmp_path.as_posix()}").joinpath("out_1.txt").write_text( "I use globals. How funny." ) - @pytask.mark.task(id="hello") - @pytask.mark.produces("out_2.txt") - def task_example(): + @task(id="hello") + def task_example(produces=Path("out_2.txt")): Path("{tmp_path.as_posix()}").joinpath("out_2.txt").write_text( "I use globals. How funny." ) @@ -395,8 +390,8 @@ def task_example(): result = runner.invoke(cli, [tmp_path.as_posix()]) assert result.exit_code == ExitCode.OK - assert "task_example[0]" in result.output - assert "task_example[1]" in result.output + assert "task_example[produces0]" in result.output + assert "task_example[produces1]" in result.output assert "task_example[hello]" in result.output assert "Collected 3 tasks" in result.output @@ -404,10 +399,10 @@ def task_example(): @pytest.mark.end_to_end() def test_that_dynamically_creates_tasks_are_captured(runner, tmp_path): source = """ - import pytask + from pytask import task _DEFINITION = ''' - @pytask.mark.task + @task def task_example(): pass ''' @@ -431,9 +426,9 @@ def task_example(): ) def test_raise_errors_for_irregular_ids(runner, tmp_path, irregular_id): source = f""" - import pytask + from pytask import task - @pytask.mark.task(id={irregular_id}) + @task(id={irregular_id}) def task_example(): pass """ @@ -465,9 +460,9 @@ def task_func(i=i): @pytest.mark.end_to_end() def test_task_receives_unknown_kwarg(runner, tmp_path): source = """ - import pytask + from pytask import task - @pytask.mark.task(kwargs={"i": 1}) + @task(kwargs={"i": 1}) def task_example(): pass """ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) @@ -478,20 +473,18 @@ def task_example(): pass @pytest.mark.end_to_end() def test_task_receives_namedtuple(runner, tmp_path): source = """ - import pytask from typing_extensions import NamedTuple, Annotated from pathlib import Path - from pytask import Product, PythonNode + from pytask import Product, PythonNode, task class Args(NamedTuple): path_in: Path arg: str path_out: Path - args = Args(Path("input.txt"), "world!", Path("output.txt")) - @pytask.mark.task(kwargs=args) + @task(kwargs=args) def task_example( path_in: Path, arg: str, path_out: Annotated[Path, Product] ) -> None: @@ -508,12 +501,11 @@ def task_example( @pytest.mark.end_to_end() def test_task_kwargs_overwrite_default_arguments(runner, tmp_path): source = """ - import pytask - from pytask import Product + from pytask import Product, task from pathlib import Path from typing_extensions import Annotated - @pytask.mark.task(kwargs={ + @task(kwargs={ "in_path": Path("in.txt"), "addition": "world!", "out_path": Path("out.txt") }) def task_example( diff --git a/tests/test_tree_util.py b/tests/test_tree_util.py index 8c93cb15..7b9be7a9 100644 --- a/tests/test_tree_util.py +++ b/tests/test_tree_util.py @@ -12,22 +12,19 @@ @pytest.mark.end_to_end() -@pytest.mark.parametrize("decorator_name", ["depends_on", "produces"]) -def test_task_with_complex_product_did_not_produce_node(tmp_path, decorator_name): +@pytest.mark.parametrize("arg_name", ["depends_on", "produces"]) +def test_task_with_complex_product_did_not_produce_node(tmp_path, arg_name): source = f""" - import pytask - + from pathlib import Path complex = [ - "out.txt", - ("tuple_out.txt",), - ["list_out.txt"], - {{"a": "dict_out.txt", "b": {{"c": "dict_out_2.txt"}}}}, + Path("out.txt"), + (Path("tuple_out.txt"),), + [Path("list_out.txt")], + {{"a": Path("dict_out.txt"), "b": {{"c": Path("dict_out_2.txt")}}}}, ] - - @pytask.mark.{decorator_name}(complex) - def task_example(): + def task_example({arg_name}=complex): pass """ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) @@ -36,14 +33,14 @@ def task_example(): assert session.exit_code == ExitCode.FAILED - products = tree_map(lambda x: x.load(), getattr(session.tasks[0], decorator_name)) - expected = { - 0: tmp_path / "out.txt", - 1: {0: tmp_path / "tuple_out.txt"}, - 2: {0: tmp_path / "list_out.txt"}, - 3: {"a": tmp_path / "dict_out.txt", "b": {"c": tmp_path / "dict_out_2.txt"}}, - } - expected = {decorator_name: expected} + products = tree_map(lambda x: x.load(), getattr(session.tasks[0], arg_name)) + expected = [ + tmp_path / "out.txt", + (tmp_path / "tuple_out.txt",), + [tmp_path / "list_out.txt"], + {"a": tmp_path / "dict_out.txt", "b": {"c": tmp_path / "dict_out_2.txt"}}, + ] + expected = {arg_name: expected} assert products == expected @@ -51,11 +48,12 @@ def task_example(): def test_profile_with_pytree(tmp_path, runner): source = """ import time - import pytask from pytask.tree_util import tree_leaves + from pathlib import Path - @pytask.mark.produces([{"out_1": "out_1.txt"}, {"out_2": "out_2.txt"}]) - def task_example(produces): + def task_example( + produces=[{"out_1": Path("out_1.txt")}, {"out_2": Path("out_2.txt")}] + ): time.sleep(2) for p in tree_leaves(produces): p.write_text("There are nine billion bicycles in Beijing.") From cfe3e92b9ee7dbf58abcf0ea4e01e1f0daacf657 Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Sun, 21 Jan 2024 00:02:24 +0100 Subject: [PATCH 02/11] Fix. --- src/_pytask/collect_utils.py | 2 ++ tests/test_collect_utils.py | 6 +----- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/_pytask/collect_utils.py b/src/_pytask/collect_utils.py index 7c4c355b..6cd3152a 100644 --- a/src/_pytask/collect_utils.py +++ b/src/_pytask/collect_utils.py @@ -170,6 +170,8 @@ def parse_products_from_task_function( # noqa: C901 has_return = False has_task_decorator = False + out: dict[str, Any] = {} + task_kwargs = obj.pytask_meta.kwargs if hasattr(obj, "pytask_meta") else {} signature_defaults = parse_keyword_arguments_from_signature_defaults(obj) kwargs = {**signature_defaults, **task_kwargs} diff --git a/tests/test_collect_utils.py b/tests/test_collect_utils.py index ab5dbf5d..aabe7036 100644 --- a/tests/test_collect_utils.py +++ b/tests/test_collect_utils.py @@ -1,14 +1,10 @@ from __future__ import annotations -from typing import TYPE_CHECKING - import pytest from _pytask.collect_utils import _find_args_with_product_annotation +from pytask import Product # noqa: TCH002 from typing_extensions import Annotated -if TYPE_CHECKING: - from pytask import Product - @pytest.mark.unit() def test_find_args_with_product_annotation(): From 115efc77016226efcdc34173f0012c5374f6184d Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Sun, 21 Jan 2024 00:13:07 +0100 Subject: [PATCH 03/11] Fix docs. --- docs/source/reference_guides/api.md | 2 -- pyproject.toml | 1 + 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/source/reference_guides/api.md b/docs/source/reference_guides/api.md index b7efd089..ee6a5464 100644 --- a/docs/source/reference_guides/api.md +++ b/docs/source/reference_guides/api.md @@ -251,10 +251,8 @@ Nodes are the interface for different kinds of dependencies or products. To parse dependencies and products from nodes, use the following functions. ```{eval-rst} -.. autofunction:: pytask.depends_on .. autofunction:: pytask.parse_dependencies_from_task_function .. autofunction:: pytask.parse_products_from_task_function -.. autofunction:: pytask.produces ``` ## Tasks diff --git a/pyproject.toml b/pyproject.toml index 4a42ab3d..61b30ca9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,7 @@ all = ['universal-pathlib; python_version<"3.12"'] docs = [ "furo", "ipython", + "matplotlib", "myst-parser", "nbsphinx", "sphinx", From 942ed31159321854750ac1bc335b9b8f0412faac Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Sun, 21 Jan 2024 11:00:56 +0100 Subject: [PATCH 04/11] Remove invalid tests. --- tests/test_collect.py | 40 ---------------------------------------- 1 file changed, 40 deletions(-) diff --git a/tests/test_collect.py b/tests/test_collect.py index f9001e2a..e57ab04a 100644 --- a/tests/test_collect.py +++ b/tests/test_collect.py @@ -13,7 +13,6 @@ from pytask import CollectionOutcome from pytask import ExitCode from pytask import NodeInfo -from pytask import NodeNotCollectedError from pytask import Session from pytask import Task @@ -63,25 +62,6 @@ def task_example( assert tmp_path.joinpath("out.txt").exists() -@pytest.mark.end_to_end() -@pytest.mark.xfail(reason="!!!") -def test_collect_produces_that_is_not_str_or_path(tmp_path): - """If a node cannot be parsed because unknown type, raise an error.""" - source = """ - import pytask - - def task_with_non_path_dependency(produces=True): ... - """ - tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) - - session = build(paths=tmp_path) - - assert session.exit_code == ExitCode.COLLECTION_FAILED - assert session.collection_reports[0].outcome == CollectionOutcome.FAIL - exc_info = session.collection_reports[0].exc_info - assert isinstance(exc_info[1], NodeNotCollectedError) - - @pytest.mark.end_to_end() def test_collect_nodes_with_the_same_name(runner, tmp_path): """Nodes with the same filename, not path, are not mistaken for each other.""" @@ -339,26 +319,6 @@ def task_my_task(): assert outcome == CollectionOutcome.SUCCESS -@pytest.mark.end_to_end() -@pytest.mark.xfail(reason="!!!") -@pytest.mark.parametrize("decorator", ["", "@task"]) -def test_collect_string_product_with_or_without_task_decorator( - runner, tmp_path, decorator -): - source = f""" - from pytask import task - - {decorator} - def task_write_text(produces="out.txt"): - produces.touch() - """ - tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) - result = runner.invoke(cli, [tmp_path.as_posix()]) - assert result.exit_code == ExitCode.OK - assert tmp_path.joinpath("out.txt").exists() - assert "FutureWarning" in result.output - - @pytest.mark.end_to_end() def test_collect_string_product_raises_error_with_annotation(runner, tmp_path): """The string is not converted to a path.""" From 25fc30b49f195b101a1f88688092603716cfcc3a Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Sun, 21 Jan 2024 11:22:29 +0100 Subject: [PATCH 05/11] Remove more. --- docs/source/changes.md | 2 +- docs/source/reference_guides/api.md | 24 ++---------------------- src/_pytask/models.py | 16 ---------------- 3 files changed, 3 insertions(+), 39 deletions(-) diff --git a/docs/source/changes.md b/docs/source/changes.md index 06de04e7..2e9545b8 100644 --- a/docs/source/changes.md +++ b/docs/source/changes.md @@ -7,7 +7,7 @@ releases are available on [PyPI](https://pypi.org/project/pytask) and ## 0.5.0 - 2024-xx-xx -- \{pull}\`\` removes the deprecated `@pytask.mark.depends_on` and +- {pull}`551` removes the deprecated `@pytask.mark.depends_on` and `@pytask.mark.produces`. ## 0.4.6 diff --git a/docs/source/reference_guides/api.md b/docs/source/reference_guides/api.md index ee6a5464..e7c07527 100644 --- a/docs/source/reference_guides/api.md +++ b/docs/source/reference_guides/api.md @@ -63,36 +63,16 @@ The remaining exceptions convey specific errors. ## Marks -pytask uses marks to attach additional information to task functions which is processed -by the host or by plugins. The following marks are available by default. +pytask uses marks to attach additional information to task functions that the host or +plugins process. The following marks are available by default. ### Built-in marks ```{eval-rst} -.. function:: pytask.mark.depends_on(objects: Any | Iterable[Any] | dict[Any, Any]) - - Specify dependencies for a task. - - :type objects: Any | Iterable[Any] | dict[Any, Any] - :param objects: - Can be any valid Python object or an iterable of any Python objects. To be - valid, it must be parsed by some hook implementation for the - :func:`_pytask.hookspecs.pytask_collect_node` entry-point. - .. function:: pytask.mark.persist() A marker for a task which should be persisted. -.. function:: pytask.mark.produces(objects: Any | Iterable[Any] | dict[Any, Any]) - - Specify products of a task. - - :type objects: Any | Iterable[Any] | dict[Any, Any] - :param objects: - Can be any valid Python object or an iterable of any Python objects. To be - valid, it must be parsed by some hook implementation for the - :func:`_pytask.hookspecs.pytask_collect_node` entry-point. - .. function:: pytask.mark.skipif(condition: bool, *, reason: str) Skip a task based on a condition and provide a necessary reason. diff --git a/src/_pytask/models.py b/src/_pytask/models.py index 73e2ffd0..1427b487 100644 --- a/src/_pytask/models.py +++ b/src/_pytask/models.py @@ -1,8 +1,6 @@ """Contains code on models, containers and there like.""" from __future__ import annotations -from enum import auto -from enum import Enum from typing import Any from typing import Callable from typing import NamedTuple @@ -14,8 +12,6 @@ from attrs import field if TYPE_CHECKING: - from _pytask.node_protocols import PTask - from _pytask.node_protocols import PNode from pathlib import Path from _pytask.tree_util import PyTree from _pytask.mark import Mark @@ -64,15 +60,3 @@ class NodeInfo(NamedTuple): value: Any task_path: Path | None task_name: str - - -class NodeStatus(Enum): - """The status of a node.""" - - CHANGED = auto() - DOES_NOT_EXIST = auto() - - -class NodeStatusEntry(NamedTuple): - node: PNode | PTask - reason: NodeStatus From cfca8b9adf9dfcdd56b974f6c0b3fe436d4d34fe Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Sun, 21 Jan 2024 12:40:19 +0100 Subject: [PATCH 06/11] Simplify parsing of products. --- src/_pytask/collect_utils.py | 52 ++++++++++++++---------------------- tests/test_collect.py | 17 ++++++++++++ 2 files changed, 37 insertions(+), 32 deletions(-) diff --git a/src/_pytask/collect_utils.py b/src/_pytask/collect_utils.py index 6cd3152a..022cffe9 100644 --- a/src/_pytask/collect_utils.py +++ b/src/_pytask/collect_utils.py @@ -1,6 +1,7 @@ """Contains utility functions for :mod:`_pytask.collect`.""" from __future__ import annotations +import inspect import sys from typing import Any from typing import Callable @@ -143,18 +144,23 @@ def _find_args_with_node_annotation(func: Callable[..., Any]) -> dict[str, PNode return args_with_node_annotation -_ERROR_MULTIPLE_PRODUCT_DEFINITIONS = """The task uses multiple ways to define \ -products. Products should be defined with either +_ERROR_MULTIPLE_TASK_RETURN_DEFINITIONS = """The task uses multiple ways to parse \ +products from the return of the task function. Use either -- 'typing.Annotated[Path, Product] = Path(...)' (recommended) -- '@pytask.mark.task(kwargs={'produces': Path(...)})' -- as a default argument for 'produces': 'produces = Path(...)' +def task_example() -> Annotated[str, Path("file.txt")]: + ... -Read more about products in the documentation: https://tinyurl.com/pytask-deps-prods. +or + +@task(produces=Path("file.txt")) +def task_example() -> str: + ... + +Read more about products in the documentation: http://tinyurl.com/pytask-return. """ -def parse_products_from_task_function( # noqa: C901 +def parse_products_from_task_function( session: Session, task_path: Path | None, task_name: str, node_path: Path, obj: Any ) -> dict[str, Any]: """Parse products from task function. @@ -162,11 +168,10 @@ def parse_products_from_task_function( # noqa: C901 Raises ------ NodeNotCollectedError - If multiple ways were used to specify products. + If multiple ways to parse products from the return of the task function are + used. """ - has_produces_argument = False - has_annotation = False has_return = False has_task_decorator = False @@ -176,17 +181,13 @@ def parse_products_from_task_function( # noqa: C901 signature_defaults = parse_keyword_arguments_from_signature_defaults(obj) kwargs = {**signature_defaults, **task_kwargs} + parameters = list(inspect.signature(obj).parameters) parameters_with_product_annot = _find_args_with_product_annotation(obj) parameters_with_node_annot = _find_args_with_node_annotation(obj) # Allow to collect products from 'produces'. - if "produces" in kwargs: - if "produces" not in parameters_with_product_annot: - parameters_with_product_annot.append("produces") - # If there are more parameters with a product annotation, we want to raise an - # error later to warn about mixing different interfaces. - if set(parameters_with_product_annot) - {"produces"}: - has_produces_argument = True + if "produces" in parameters and "produces" not in parameters_with_product_annot: + parameters_with_product_annot.append("produces") if "return" in parameters_with_node_annot: parameters_with_product_annot.append("return") @@ -195,9 +196,6 @@ def parse_products_from_task_function( # noqa: C901 if parameters_with_product_annot: out = {} for parameter_name in parameters_with_product_annot: - if parameter_name != "return": - has_annotation = True - if ( parameter_name not in kwargs and parameter_name not in parameters_with_node_annot @@ -256,18 +254,8 @@ def parse_products_from_task_function( # noqa: C901 ) out = {"return": collected_products} - if ( - sum( - ( - has_produces_argument, - has_annotation, - has_return, - has_task_decorator, - ) - ) - >= 2 # noqa: PLR2004 - ): - raise NodeNotCollectedError(_ERROR_MULTIPLE_PRODUCT_DEFINITIONS) + if sum((has_return, has_task_decorator)) == 2: # noqa: PLR2004 + raise NodeNotCollectedError(_ERROR_MULTIPLE_TASK_RETURN_DEFINITIONS) return out diff --git a/tests/test_collect.py b/tests/test_collect.py index e57ab04a..e9c928a8 100644 --- a/tests/test_collect.py +++ b/tests/test_collect.py @@ -545,3 +545,20 @@ def task_example( result = runner.invoke(cli, [tmp_path.as_posix()]) assert result.exit_code == ExitCode.COLLECTION_FAILED assert "Parameter 'dependency' has multiple node annot" in result.output + + +@pytest.mark.end_to_end() +def test_error_if_multiple_return_annotations_are_used(runner, tmp_path): + source = """ + from pytask import task + from pathlib import Path + from typing_extensions import Annotated + + @task(produces=Path("file.txt")) + def task_example() -> Annotated[str, Path("file.txt")]: ... + """ + 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 "The task uses multiple ways to parse" in result.output From bb4bd84e8b653d4652fd4c782888863db136e6c8 Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Sun, 21 Jan 2024 21:25:25 +0100 Subject: [PATCH 07/11] ficx. --- src/_pytask/collect_utils.py | 2 ++ tests/test_execute.py | 14 ++++++++++++++ tests/test_mark.py | 17 ++++++++++++++++- 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/_pytask/collect_utils.py b/src/_pytask/collect_utils.py index 022cffe9..d39bc21b 100644 --- a/src/_pytask/collect_utils.py +++ b/src/_pytask/collect_utils.py @@ -196,6 +196,8 @@ def parse_products_from_task_function( if parameters_with_product_annot: out = {} for parameter_name in parameters_with_product_annot: + # Makes sure that missing products will show up as missing inputs during the + # execution. if ( parameter_name not in kwargs and parameter_name not in parameters_with_node_annot diff --git a/tests/test_execute.py b/tests/test_execute.py index 2ba048d2..9995e07e 100644 --- a/tests/test_execute.py +++ b/tests/test_execute.py @@ -72,6 +72,20 @@ def task_example(produces=[Path("1.txt"), Path("2.txt")]): ... assert "2.txt" in result.output +def task_missing_product(runner, tmp_path): + source = """ + from pathlib import Path + from typing import Annotated + from pytask import Product + + def task_with_non_path_dependency(path: Annotated[Path, Product]): ... + """ + tmp_path.joinpath("task_example.py").write_text(textwrap.dedent(source)) + + result = runner.invoke(cli, [tmp_path.as_posix()]) + assert result.exit_code == ExitCode.FAILED + + @pytest.mark.end_to_end() def test_node_not_found_in_task_setup(tmp_path): """Test for :class:`_pytask.exceptions.NodeNotFoundError` in task setup. diff --git a/tests/test_mark.py b/tests/test_mark.py index aaeb4c59..74eb916c 100644 --- a/tests/test_mark.py +++ b/tests/test_mark.py @@ -373,7 +373,22 @@ def task_write_text(): ... @pytest.mark.end_to_end() -def test_error_with_parametrize(runner, tmp_path): +@pytest.mark.parametrize("name", ["parametrize", "depends_on", "produces"]) +def test_error_with_depreacated_markers(runner, tmp_path, name): + source = f""" + from pytask import mark + + @mark.{name} + def task_write_text(): ... + """ + 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 f"@pytask.mark.{name}" in result.output + + +@pytest.mark.end_to_end() +def test_error_with_d(runner, tmp_path): source = """ from pytask import mark From db2cd7def59ebc2e95470a26b8c55f34e5d0cff4 Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Sun, 21 Jan 2024 21:31:02 +0100 Subject: [PATCH 08/11] fix. --- tests/test_execute.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_execute.py b/tests/test_execute.py index 9995e07e..a941c442 100644 --- a/tests/test_execute.py +++ b/tests/test_execute.py @@ -72,6 +72,7 @@ def task_example(produces=[Path("1.txt"), Path("2.txt")]): ... assert "2.txt" in result.output +@pytest.mark.end_to_end() def task_missing_product(runner, tmp_path): source = """ from pathlib import Path From df5b2704c6824dd8f34c6db1e1dc43a16940e5a8 Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Sun, 21 Jan 2024 21:35:36 +0100 Subject: [PATCH 09/11] Rename task to test. --- tests/test_execute.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_execute.py b/tests/test_execute.py index a941c442..13f008c8 100644 --- a/tests/test_execute.py +++ b/tests/test_execute.py @@ -73,7 +73,7 @@ def task_example(produces=[Path("1.txt"), Path("2.txt")]): ... @pytest.mark.end_to_end() -def task_missing_product(runner, tmp_path): +def test_missing_product(runner, tmp_path): source = """ from pathlib import Path from typing import Annotated From fca3f443db94cd2f778a5449fee0521fc6af3d8a Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Sun, 21 Jan 2024 21:39:54 +0100 Subject: [PATCH 10/11] Fix. --- tests/test_execute.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_execute.py b/tests/test_execute.py index 13f008c8..d2060bae 100644 --- a/tests/test_execute.py +++ b/tests/test_execute.py @@ -76,7 +76,7 @@ def task_example(produces=[Path("1.txt"), Path("2.txt")]): ... def test_missing_product(runner, tmp_path): source = """ from pathlib import Path - from typing import Annotated + from typing_extensions import Annotated from pytask import Product def task_with_non_path_dependency(path: Annotated[Path, Product]): ... From c253ada3a653080aa98304df161229ebb6b36d18 Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Sun, 21 Jan 2024 22:32:15 +0100 Subject: [PATCH 11/11] last changes. --- docs/source/changes.md | 7 ++----- docs/source/tutorials/configuration.md | 2 +- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/docs/source/changes.md b/docs/source/changes.md index 2e9545b8..2e53e5ac 100644 --- a/docs/source/changes.md +++ b/docs/source/changes.md @@ -7,13 +7,10 @@ releases are available on [PyPI](https://pypi.org/project/pytask) and ## 0.5.0 - 2024-xx-xx -- {pull}`551` removes the deprecated `@pytask.mark.depends_on` and - `@pytask.mark.produces`. - -## 0.4.6 - - {pull}`548` fixes the type hints for {meth}`~pytask.Task.execute` and {meth}`~pytask.TaskWithoutPath.execute`. Thanks to {user}`Ostheer`. +- {pull}`551` removes the deprecated `@pytask.mark.depends_on` and + `@pytask.mark.produces`. ## 0.4.5 - 2024-01-09 diff --git a/docs/source/tutorials/configuration.md b/docs/source/tutorials/configuration.md index 7492e2b3..20328084 100644 --- a/docs/source/tutorials/configuration.md +++ b/docs/source/tutorials/configuration.md @@ -21,7 +21,7 @@ configuration file. ```toml [tool.pytask.ini_options] -paths = "src" +paths = ["src"] ``` ## The location