diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 1a9975035..fbbdbc997 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -27,20 +27,22 @@ jobs:
fail-fast: false
matrix:
os: ['ubuntu-latest', 'macos-latest', 'windows-latest']
- python-version: ['3.7', '3.8', '3.9', '3.10', '3.11']
+ python-version: ['3.8', '3.9', '3.10', '3.11']
steps:
- uses: actions/checkout@v3
- - uses: conda-incubator/setup-miniconda@v2.2.0
+ - uses: mamba-org/setup-micromamba@v1
with:
- auto-update-conda: false
- python-version: ${{ matrix.python-version }}
- channels: conda-forge,nodefaults
- miniforge-variant: Mambaforge
-
- - name: Install core dependencies.
- shell: bash -l {0}
- run: mamba install -c conda-forge tox-conda coverage
+ environment-name: gha-testing
+ condarc: |
+ channels:
+ - nodefaults
+ - conda-forge
+ create-args: >-
+ python=${{ matrix.python-version }}
+ mamba
+ tox-conda
+ cache-environment: true
# Unit, integration, and end-to-end tests.
@@ -49,7 +51,7 @@ jobs:
run: tox -e pytest -- -m "unit or (not integration and not end_to_end)" --cov=src --cov=tests --cov-report=xml -n auto
- name: Upload coverage report for unit tests and doctests.
- if: runner.os == 'Linux' && matrix.python-version == '3.8'
+ if: runner.os == 'Linux' && matrix.python-version == '3.10'
shell: bash -l {0}
run: bash <(curl -s https://codecov.io/bash) -F unit -c
@@ -58,7 +60,7 @@ jobs:
run: tox -e pytest -- -m integration --cov=src --cov=tests --cov-report=xml -n auto
- name: Upload coverage reports of integration tests.
- if: runner.os == 'Linux' && matrix.python-version == '3.8'
+ if: runner.os == 'Linux' && matrix.python-version == '3.10'
shell: bash -l {0}
run: bash <(curl -s https://codecov.io/bash) -F integration -c
@@ -67,6 +69,6 @@ jobs:
run: tox -e pytest -- -m end_to_end --cov=src --cov=tests --cov-report=xml -n auto
- name: Upload coverage reports of end-to-end tests.
- if: runner.os == 'Linux' && matrix.python-version == '3.8'
+ if: runner.os == 'Linux' && matrix.python-version == '3.10'
shell: bash -l {0}
run: bash <(curl -s https://codecov.io/bash) -F end_to_end -c
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 329859e21..cdc3bb664 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -21,16 +21,13 @@ repos:
hooks:
- id: python-check-blanket-noqa
- id: python-check-mock-methods
- - id: python-no-eval
- exclude: expression.py
- id: python-no-log-warn
- - id: python-use-type-annotations
- id: text-unicode-replacement-char
- repo: https://github.com/asottile/reorder-python-imports
rev: v3.9.0
hooks:
- id: reorder-python-imports
- args: [--py37-plus, --add-import, 'from __future__ import annotations']
+ args: [--py38-plus, --add-import, 'from __future__ import annotations']
- repo: https://github.com/asottile/setup-cfg-fmt
rev: v2.3.0
hooks:
@@ -52,7 +49,7 @@ repos:
rev: 1.5.0
hooks:
- id: interrogate
- args: [-v, --fail-under=75]
+ args: [-vv, --fail-under=75]
exclude: ^(tests/|docs/|scripts/)
- repo: https://github.com/pre-commit/mirrors-mypy
rev: 'v1.3.0'
diff --git a/MANIFEST.in b/MANIFEST.in
index 44e6bda9c..84eeefdb0 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,6 +1,7 @@
include CITATION
include LICENSE
+recursive-include src *.pyi
recursive-include src py.typed
exclude .coveragerc
diff --git a/docs/rtd_environment.yml b/docs/rtd_environment.yml
index 0808dd98f..5ac57126d 100644
--- a/docs/rtd_environment.yml
+++ b/docs/rtd_environment.yml
@@ -3,7 +3,7 @@ channels:
- nodefaults
dependencies:
- - python >=3.7
+ - python >=3.8
- pip
- setuptools_scm
- toml
@@ -25,11 +25,12 @@ dependencies:
- click-default-group
- networkx >=2.4
- pluggy
- - pony >=0.7.15
- - pybaum >=0.1.1
+ - optree >=0.9
- pexpect
- rich
+ - sqlalchemy >=1.4.36
- tomli >=1.0.0
+ - typing_extensions
- pip:
- ../
diff --git a/docs/source/_static/images/markers.svg b/docs/source/_static/images/markers.svg
index f27a5e7dd..5febd4cd9 100644
--- a/docs/source/_static/images/markers.svg
+++ b/docs/source/_static/images/markers.svg
@@ -107,9 +107,6 @@
│ pytask.mark.depends_on │ Add dependencies to a task. See this tutorial for more │
│ │ │
-│ pytask.mark.parametrize │ The marker for pytest's way of repeating tasks which is │
-
-│ │ │
│ pytask.mark.persist │ Prevent execution of a task if all products exist and even if │
│ │ something has changed (dependencies, source file, products). │
│ │ This decorator might be useful for expensive tasks where only │
diff --git a/docs/source/_static/md/markers.md b/docs/source/_static/md/markers.md
index 60b3d0dfe..2ce8c7d72 100644
--- a/docs/source/_static/md/markers.md
+++ b/docs/source/_static/md/markers.md
@@ -10,10 +10,6 @@ $ pytask markers
│ │ tutorial for more information: │
│ │ https://bit.ly/3JlxylS. │
│ │ │
-│ pytask.mark.parametrize │ The marker for pytest's way of │
-│ │ repeating tasks which is explained in │
-│ │ this tutorial: https://bit.ly/3uqZqkk. │
-│ │ │
│ pytask.mark.persist │ Prevent execution of a task if all │
│ │ products exist and even ifsomething has │
│ │ changed (dependencies, source file, │
diff --git a/docs/source/changes.md b/docs/source/changes.md
index f0b23b6e3..7a535918c 100644
--- a/docs/source/changes.md
+++ b/docs/source/changes.md
@@ -5,7 +5,26 @@ 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.3.2 - 2023-xx-xx
+## 0.4.0 - 2023-xx-xx
+
+- {pull}`323` remove Python 3.7 support and use a new Github action to provide mamba.
+- {pull}`384` allows to parse dependencies from every function argument if `depends_on`
+ is not present.
+- {pull}`387` replaces pony with sqlalchemy.
+- {pull}`391` removes `@pytask.mark.parametrize`.
+- {pull}`394` allows to add products with {obj}`typing.Annotation` and
+ {obj}`~pytask.Product`.
+- {pull}`395` refactors all occurrences of pybaum to {mod}`_pytask.tree_util`.
+- {pull}`396` replaces pybaum with optree and adds paths to the name of
+ {class}`pytask.PythonNode`'s allowing for better hashing.
+- {pull}`397` adds support for {class}`typing.NamedTuple` and attrs classes in
+ `@pytask.mark.task(kwargs=...)`.
+- {pull}`398` deprecates the decorators `@pytask.mark.depends_on` and
+ `@pytask.mark.produces`.
+- {pull}`402` replaces ABCs with protocols allowing for more flexibility for users
+ implementing their own nodes.
+
+## 0.3.2 - 2023-06-07
- {pull}`345` updates the version numbers in animations.
- {pull}`352` publishes `db` that is required by pytask-environment.
diff --git a/docs/source/how_to_guides/index.md b/docs/source/how_to_guides/index.md
index 298308598..7f3c9aa56 100644
--- a/docs/source/how_to_guides/index.md
+++ b/docs/source/how_to_guides/index.md
@@ -14,7 +14,6 @@ maxdepth: 1
migrating_from_scripts_to_pytask
invoking_pytask_extended
capture_warnings
-repeating_tasks_with_different_inputs_the_pytest_way
how_to_influence_build_order
how_to_write_a_plugin
```
diff --git a/docs/source/how_to_guides/repeating_tasks_with_different_inputs_the_pytest_way.md b/docs/source/how_to_guides/repeating_tasks_with_different_inputs_the_pytest_way.md
deleted file mode 100644
index 7a81c571f..000000000
--- a/docs/source/how_to_guides/repeating_tasks_with_different_inputs_the_pytest_way.md
+++ /dev/null
@@ -1,213 +0,0 @@
-# Repeating tasks with different inputs - The pytest way
-
-:::{important}
-This guide shows you how to parametrize tasks with the pytest approach. For the new and
-preferred approach, see this
-{doc}`tutorial <../tutorials/repeating_tasks_with_different_inputs>`.
-:::
-
-Do you want to define a task repeating an action over a range of inputs? Parametrize
-your task function!
-
-:::{hint}
-The process of repeating a function with different inputs is called parametrizations.
-:::
-
-:::{seealso}
-If you want to know more about best practices for parametrizations, check out this
-{doc}`guide <../how_to_guides/bp_scalable_repetitions_of_tasks>` after you have made
-yourself familiar with this tutorial.
-:::
-
-## An example
-
-We reuse the previous example of a task that generates random data and repeat the same
-operation over some seeds to receive multiple, reproducible samples.
-
-First, we write the task for one seed.
-
-```python
-import numpy as np
-import pytask
-
-
-@pytask.mark.produces(BLD / "data_0.pkl")
-def task_create_random_data(produces):
- rng = np.random.default_rng(0)
- ...
-```
-
-In the next step, we repeat the same task over the numbers 0, 1, and 2 and pass them to
-the `seed` argument. We also vary the name of the produced file in every iteration.
-
-```python
-@pytask.mark.parametrize(
- "produces, seed",
- [(BLD / "data_0.pkl", 0), (BLD / "data_1.pkl", 1), (BLD / "data_2.pkl", 2)],
-)
-def task_create_random_data(seed, produces):
- rng = np.random.default_rng(seed)
- ...
-```
-
-The parametrize decorator receives two arguments. The first argument is
-`"produces, seed"` - the signature. It is a comma-separated string where each value
-specifies the name of a task function argument.
-
-:::{seealso}
-The signature is explained in detail {ref}`below `.
-:::
-
-The second argument of the parametrize decorator is a list with one element per
-iteration. Each element must provide one value for each argument name in the signature -
-two in this case.
-
-pytask executes the task function three times and passes the path from the list to the
-argument `produces` and the seed to `seed`.
-
-:::{note}
-If you use `produces` or `depends_on` in the signature of the parametrize decorator, the
-values are handled as if they were attached to the function with
-{func}`@pytask.mark.depends_on ` or
-{func}`@pytask.mark.produces `.
-:::
-
-## Un-parametrized dependencies
-
-To specify a dependency that is the same for all parametrizations, add it with
-{func}`@pytask.mark.depends_on `.
-
-```python
-@pytask.mark.depends_on(SRC / "common_dependency.file")
-@pytask.mark.parametrize(
- "produces, seed",
- [(BLD / "data_0.pkl", 0), (BLD / "data_1.pkl", 1), (BLD / "data_2.pkl", 2)],
-)
-def task_create_random_data(seed, produces):
- rng = np.random.default_rng(seed)
- ...
-```
-
-(parametrize-signature)=
-
-## The signature
-
-pytask allows for three different kinds of formats for the signature.
-
-1. The signature can be a comma-separated string like an entry in a CSV table. Note that
- white space is stripped from each name which you can use to separate the names for
- readability. Here are some examples:
-
- ```python
- "single_argument"
- "first_argument,second_argument"
- "first_argument, second_argument"
- ```
-
-1. The signature can be a tuple of strings where each string is one argument name. Here
- is an example.
-
- ```python
- ("first_argument", "second_argument")
- ```
-
-1. Finally, using a list of strings is also possible.
-
- ```python
- ["first_argument", "second_argument"]
- ```
-
-## The id
-
-Every task has a unique id that can be used to
-{doc}`select it <../tutorials/selecting_tasks>`. The normal id combines the path to the
-module where the task is defined, a double colon, and the name of the task function.
-Here is an example.
-
-```
-../task_example.py::task_example
-```
-
-This behavior would produce duplicate ids for parametrized tasks. Therefore, there exist
-multiple mechanisms to have unique ids.
-
-(auto-generated-ids)=
-
-### Auto-generated ids
-
-pytask construct ids by extending the task name with representations of the values used
-for each iteration. Booleans, floats, integers, and strings enter the task id directly.
-For example, a task function that receives four arguments, `True`, `1.0`, `2`, and
-`"hello"`, one of each dtype, has the following id.
-
-```
-task_example.py::task_example[True-1.0-2-hello]
-```
-
-Arguments with other dtypes cannot be converted to strings and, thus, are replaced with
-a combination of the argument name and the iteration counter.
-
-For example, the following function is parametrized with tuples.
-
-```python
-@pytask.mark.parametrize("i", [(0,), (1,)])
-def task_example(i):
- pass
-```
-
-Since the tuples are not converted to strings, the ids of the two tasks are
-
-```
-task_example.py::task_example[i0]
-task_example.py::task_example[i1]
-```
-
-### User-defined ids
-
-Instead of a function, you can also pass a list or another iterable of id values via
-`ids`.
-
-This code
-
-```python
-@pytask.mark.parametrize("i", [(0,), (1,)], ids=["first", "second"])
-def task_example(i):
- pass
-```
-
-produces these ids
-
-```
-task_example.py::task_example[first] # (0,)
-task_example.py::task_example[second] # (1,)
-```
-
-(how-to-parametrize-a-task-convert-other-objects)=
-
-### Convert other objects
-
-To change the representation of tuples and other objects, you can pass a function to the
-`ids` argument of the {func}`@pytask.mark.parametrize `
-decorator. The function is called for every argument and may return a boolean, number,
-or string, which will be integrated into the id. For every other return, the
-auto-generated value is used.
-
-We can use the hash value to get a unique representation of a tuple.
-
-```python
-def tuple_to_hash(value):
- if isinstance(value, tuple):
- return hash(a)
-
-
-@pytask.mark.parametrize("i", [(0,), (1,)], ids=tuple_to_hash)
-def task_example(i):
- pass
-```
-
-The tasks have the following ids:
-
-```
-task_example.py::task_example[3430018387555] # (0,)
-task_example.py::task_example[3430019387558] # (1,)
-```
diff --git a/docs/source/reference_guides/api.md b/docs/source/reference_guides/api.md
index 6254656b7..bfd922e29 100644
--- a/docs/source/reference_guides/api.md
+++ b/docs/source/reference_guides/api.md
@@ -47,35 +47,6 @@ by the host or by plugins. The following marks are available by default.
:func:`_pytask.hookspecs.pytask_collect_node` entry-point.
```
-```{eval-rst}
-.. function:: pytask.mark.parametrize(arg_names, arg_values, *, ids)
-
- Parametrize a task function.
-
- Parametrizing a task allows to execute the same task with different arguments.
-
- :type arg_names: str | list[str] | tuple[str, ...]
- :param arg_names:
- The names of the arguments which can either be given as a comma-separated
- string, a tuple of strings, or a list of strings.
- :type arg_values: Iterable[Sequence[Any] | Any]
- :param arg_values:
- The values which correspond to names in ``arg_names``. For one argument, it is a
- single iterable. For multiple argument names it is an iterable of iterables.
- :type ids: None | (Iterable[None | str | float | int | bool] | Callable[..., Any])
- :param ids:
- This argument can either be a list with ids or a function which is called with
- every value passed to the parametrized function.
-
- If you pass an iterable with ids, make sure to only use :obj:`bool`,
- :obj:`float`, :obj:`int`, or :obj:`str` as values which are used to create task
- ids like ``"task_dummpy.py::task_dummy[first_task_id]"``.
-
- If you pass a function, the function receives each value of the parametrization
- and may return a boolean, number, string or None. For the latter, the
- auto-generated value is used.
-```
-
```{eval-rst}
.. function:: pytask.mark.persist()
@@ -275,7 +246,13 @@ from {class}`pytask.MetaNode`.
Then, different kinds of nodes can be implemented.
```{eval-rst}
-.. autoclass:: pytask.FilePathNode
+.. autoclass:: pytask.PathNode
+ :members:
+```
+
+```{eval-rst}
+.. autoclass:: pytask.PythonNode
+ :members:
```
To parse dependencies and products from nodes, use the following functions.
diff --git a/docs/source/reference_guides/configuration.md b/docs/source/reference_guides/configuration.md
index cad669495..2799d66a5 100644
--- a/docs/source/reference_guides/configuration.md
+++ b/docs/source/reference_guides/configuration.md
@@ -42,6 +42,23 @@ are welcome to also support macOS.
````
+````{confval} database_url
+
+pytask uses a database to keep track of tasks, products, and dependencies over runs. By
+default, it will create an SQLite database in the project's root directory called
+`.pytask.sqlite3`. If you want to use a different name or a different dialect
+[supported by sqlalchemy](https://docs.sqlalchemy.org/en/latest/core/engines.html#backend-specific-urls),
+use either {option}`pytask build --database-url` or `database_url` in the config.
+
+```toml
+database_url = "sqlite:///.pytask.sqlite3"
+```
+
+Relative paths for SQLite databases are interpreted as either relative to the
+configuration file or the root directory.
+
+````
+
````{confval} editor_url_scheme
Depending on your terminal, pytask is able to turn task ids into clickable links to the
diff --git a/docs/source/reference_guides/hookspecs.md b/docs/source/reference_guides/hookspecs.md
index 474afcb42..20e958e5e 100644
--- a/docs/source/reference_guides/hookspecs.md
+++ b/docs/source/reference_guides/hookspecs.md
@@ -106,21 +106,6 @@ The following hooks traverse directories and collect tasks from files.
```
-## Parametrization
-
-The hooks to parametrize a task are called during the collection when a function is
-collected. Then, the function is duplicated according to the parametrization and the
-duplicates are collected with {func}`pytask_collect_task`.
-
-```{eval-rst}
-.. autofunction:: pytask_parametrize_task
-```
-
-```{eval-rst}
-.. autofunction:: pytask_parametrize_kwarg_to_marker
-
-```
-
## Resolving Dependencies
The following hooks are designed to build a DAG from tasks and dependencies and check
diff --git a/docs/source/tutorials/defining_dependencies_products.md b/docs/source/tutorials/defining_dependencies_products.md
index 98086b5ce..d245b2615 100644
--- a/docs/source/tutorials/defining_dependencies_products.md
+++ b/docs/source/tutorials/defining_dependencies_products.md
@@ -4,8 +4,8 @@ To ensure pytask executes all tasks in the correct order, define which dependenc
required and which products are produced by a task.
:::{important}
-If you do not specify dependencies and products as explained below, pytask will not be able
-to build a graph, a {term}`DAG`, and will not be able to execute all tasks in the
+If you do not specify dependencies and products as explained below, pytask will not be
+able to build a graph, a {term}`DAG`, and will not be able to execute all tasks in the
project correctly!
:::
@@ -19,15 +19,16 @@ def task_create_random_data(produces):
...
```
-The {func}`@pytask.mark.produces ` marker attaches a
-product to a task which is a {class}`pathlib.Path` to file. After the task has finished,
-pytask will check whether the file exists.
+The {func}`@pytask.mark.produces ` marker attaches a product to a
+task which is a {class}`pathlib.Path` to file. After the task has finished, pytask will
+check whether the file exists.
-Optionally, you can use `produces` as an argument of the task function and get access to
-the same path inside the task function.
+Add `produces` as an argument of the task function to get access to the same path inside
+the task function.
:::{tip}
-If you do not know about {mod}`pathlib` check out [^id3] and [^id4]. The module is beneficial for handling paths conveniently and across platforms.
+If you do not know about {mod}`pathlib` check out [^id3] and [^id4]. The module is
+beneficial for handling paths conveniently and across platforms.
:::
## Dependencies
@@ -44,7 +45,7 @@ def task_plot_data(depends_on, produces):
...
```
-Use `depends_on` as a function argument to work with the dependency path and, for
+Add `depends_on` as a function argument to work with the path of the dependency and, for
example, load the data.
## Conversion
@@ -61,9 +62,6 @@ def task_create_random_data(produces):
...
```
-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`.
-
## Multiple dependencies and products
The easiest way to attach multiple dependencies or products to a task is to pass a
@@ -108,7 +106,9 @@ 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, dictionaries use keys instead of positions that are more verbose and descriptive and do not assume a fixed ordering. Both attributes are especially desirable in complex projects.
+Secondly, dictionaries use keys instead of positions that are more verbose and
+descriptive and do not assume a fixed ordering. Both attributes are especially desirable
+in complex projects.
## Multiple decorators
diff --git a/docs/source/tutorials/repeating_tasks_with_different_inputs.md b/docs/source/tutorials/repeating_tasks_with_different_inputs.md
index 760fc37db..9b3c12f38 100644
--- a/docs/source/tutorials/repeating_tasks_with_different_inputs.md
+++ b/docs/source/tutorials/repeating_tasks_with_different_inputs.md
@@ -2,16 +2,6 @@
Do you want to repeat a task over a range of inputs? Loop over your task function!
-:::{important}
-Before v0.2.0, pytask supported only one approach to repeat tasks. It is also called
-parametrizations, and similarly to pytest, it uses a
-{func}`@pytask.mark.parametrize ` decorator. If you want to
-know more about it, you can find it
-{doc}`here <../how_to_guides/repeating_tasks_with_different_inputs_the_pytest_way>`.
-
-Here you find the new and preferred approach.
-:::
-
## An example
We reuse the task from the previous {doc}`tutorial `, which generates
@@ -71,9 +61,40 @@ and the name of the task function. Here is an example.
```
This behavior would produce duplicate ids for parametrized tasks. By default,
-auto-generated ids are used which are explained {ref}`here `.
+auto-generated ids are used.
+
+(auto-generated-ids)=
+
+### Auto-generated ids
+
+pytask construct ids by extending the task name with representations of the values used
+for each iteration. Booleans, floats, integers, and strings enter the task id directly.
+For example, a task function that receives four arguments, `True`, `1.0`, `2`, and
+`"hello"`, one of each dtype, has the following id.
+
+```
+task_data_preparation.py::task_create_random_data[True-1.0-2-hello]
+```
+
+Arguments with other dtypes cannot be converted to strings and, thus, are replaced with
+a combination of the argument name and the iteration counter.
-More powerful are user-defined ids.
+For example, the following function is parametrized with tuples.
+
+```python
+for i in [(0,), (1,)]:
+
+ @pytask.mark.task
+ def task_create_random_data(i=i):
+ pass
+```
+
+Since the tuples are not converted to strings, the ids of the two tasks are
+
+```
+task_data_preparation.py::task_create_random_data[i0]
+task_data_preparation.py::task_create_random_data[i1]
+```
(ids)=
diff --git a/environment.yml b/environment.yml
index 9d8019694..e04446494 100644
--- a/environment.yml
+++ b/environment.yml
@@ -5,7 +5,7 @@ channels:
- nodefaults
dependencies:
- - python >=3.7
+ - python >=3.8
- pip
- setuptools_scm
- toml
@@ -16,10 +16,11 @@ dependencies:
- click-default-group
- networkx >=2.4
- pluggy
- - pony >=0.7.15
- - pybaum >=0.1.1
+ - optree >=0.9
- rich
+ - sqlalchemy >=1.4.36
- tomli >=1.0.0
+ - typing_extensions
# Misc
- black
diff --git a/pyproject.toml b/pyproject.toml
index fe63803ce..d494d5b14 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -29,7 +29,7 @@ ignore-words-list = "falsy, hist, ines, unparseable"
[tool.ruff]
-target-version = "py37"
+target-version = "py38"
select = ["ALL"]
fix = true
extend-ignore = [
@@ -62,6 +62,9 @@ extend-ignore = [
"SLF001", # access private members.
"S603",
"S607",
+ # Temporary
+ "TD002",
+ "TD003",
]
@@ -80,6 +83,7 @@ convention = "numpy"
[tool.pytest.ini_options]
+addopts = ["--doctest-modules"]
testpaths = ["src", "tests"]
markers = [
"wip: Tests that are work-in-progress.",
@@ -88,3 +92,4 @@ markers = [
"end_to_end: Flag for tests that cover the whole program.",
]
norecursedirs = [".idea", ".tox"]
+filterwarnings = ["ignore:'@pytask.mark.*. is deprecated:DeprecationWarning"]
diff --git a/setup.cfg b/setup.cfg
index 6c065c0ee..1a39bbfb6 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -34,13 +34,14 @@ install_requires =
click
click-default-group
networkx>=2.4
+ optree>=0.9
packaging
pluggy
- pony>=0.7.15
- pybaum>=0.1.1
rich
+ sqlalchemy>=1.4.36
tomli>=1.0.0
-python_requires = >=3.7
+ typing-extensions
+python_requires = >=3.8
include_package_data = True
package_dir =
=src
diff --git a/src/_pytask/_inspect.py b/src/_pytask/_inspect.py
new file mode 100644
index 000000000..84413c1f7
--- /dev/null
+++ b/src/_pytask/_inspect.py
@@ -0,0 +1,140 @@
+from __future__ import annotations
+
+import functools
+import sys
+import types
+from typing import Any
+from typing import Callable
+from typing import Mapping
+
+
+__all__ = ["get_annotations"]
+
+
+if sys.version_info >= (3, 10):
+ from inspect import get_annotations
+else:
+
+ def get_annotations( # noqa: C901, PLR0912, PLR0915
+ obj: Callable[..., object] | type[Any] | types.ModuleType,
+ *,
+ globals: Mapping[str, Any] | None = None, # noqa: A002
+ locals: Mapping[str, Any] | None = None, # noqa: A002
+ eval_str: bool = False,
+ ) -> dict[str, Any]:
+ """Compute the annotations dict for an object.
+
+ obj may be a callable, class, or module.
+ Passing in an object of any other type raises TypeError.
+
+ Returns a dict. get_annotations() returns a new dict every time
+ it's called; calling it twice on the same object will return two
+ different but equivalent dicts.
+
+ This function handles several details for you:
+
+ * If eval_str is true, values of type str will
+ be un-stringized using eval(). This is intended
+ for use with stringized annotations
+ ("from __future__ import annotations").
+ * If obj doesn't have an annotations dict, returns an
+ empty dict. (Functions and methods always have an
+ annotations dict; classes, modules, and other types of
+ callables may not.)
+ * Ignores inherited annotations on classes. If a class
+ doesn't have its own annotations dict, returns an empty dict.
+ * All accesses to object members and dict values are done
+ using getattr() and dict.get() for safety.
+ * Always, always, always returns a freshly-created dict.
+
+ eval_str controls whether or not values of type str are replaced
+ with the result of calling eval() on those values:
+
+ * If eval_str is true, eval() is called on values of type str.
+ * If eval_str is false (the default), values of type str are unchanged.
+
+ globals and locals are passed in to eval(); see the documentation
+ for eval() for more information. If either globals or locals is
+ None, this function may replace that value with a context-specific
+ default, contingent on type(obj):
+
+ * If obj is a module, globals defaults to obj.__dict__.
+ * If obj is a class, globals defaults to
+ sys.modules[obj.__module__].__dict__ and locals
+ defaults to the obj class namespace.
+ * If obj is a callable, globals defaults to obj.__globals__,
+ although if obj is a wrapped function (using
+ functools.update_wrapper()) it is first unwrapped.
+ """
+ if isinstance(obj, type):
+ # class
+ obj_dict = getattr(obj, "__dict__", None)
+ if obj_dict and hasattr(obj_dict, "get"):
+ ann = obj_dict.get("__annotations__", None)
+ if isinstance(ann, types.GetSetDescriptorType):
+ ann = None
+ else:
+ ann = None
+
+ obj_globals = None
+ module_name = getattr(obj, "__module__", None)
+ if module_name:
+ module = sys.modules.get(module_name, None)
+ if module:
+ obj_globals = getattr(module, "__dict__", None)
+ obj_locals = dict(vars(obj))
+ unwrap = obj
+ elif isinstance(obj, types.ModuleType):
+ # module
+ ann = getattr(obj, "__annotations__", None)
+ obj_globals = obj.__dict__
+ obj_locals = None
+ unwrap = None
+ elif callable(obj):
+ # this includes types.Function, types.BuiltinFunctionType,
+ # types.BuiltinMethodType, functools.partial, functools.singledispatch,
+ # "class funclike" from Lib/test/test_inspect... on and on it goes.
+ ann = getattr(obj, "__annotations__", None)
+ obj_globals = getattr(obj, "__globals__", None)
+ obj_locals = None
+ unwrap = obj
+ else:
+ raise TypeError(f"{obj!r} is not a module, class, or callable.")
+
+ if ann is None:
+ return {}
+
+ if not isinstance(ann, dict):
+ raise ValueError(f"{obj!r}.__annotations__ is neither a dict nor None")
+
+ if not ann:
+ return {}
+
+ if not eval_str:
+ return dict(ann)
+
+ if unwrap is not None:
+ while True:
+ if hasattr(unwrap, "__wrapped__"):
+ unwrap = unwrap.__wrapped__
+ continue
+ if isinstance(unwrap, functools.partial):
+ unwrap = unwrap.func
+ continue
+ break
+ if hasattr(unwrap, "__globals__"):
+ obj_globals = unwrap.__globals__
+
+ if globals is None:
+ globals = obj_globals # noqa: A001
+ if locals is None:
+ locals = obj_locals # noqa: A001
+
+ eval_func = eval
+ return_value = {
+ key: value
+ if not isinstance(value, str)
+ else eval_func(value, globals, locals)
+ for key, value in ann.items()
+ }
+ return return_value
diff --git a/src/_pytask/capture.py b/src/_pytask/capture.py
index a4bf76e68..777c76c79 100644
--- a/src/_pytask/capture.py
+++ b/src/_pytask/capture.py
@@ -34,6 +34,7 @@
from tempfile import TemporaryFile
from typing import Any
from typing import AnyStr
+from typing import final
from typing import Generator
from typing import Generic
from typing import Iterator
@@ -46,14 +47,6 @@
from _pytask.nodes import Task
-if sys.version_info >= (3, 8):
- from typing import final
-else:
-
- def final(f: Any) -> Any:
- return f
-
-
class _CaptureMethod(enum.Enum):
FD = "fd"
NO = "no"
diff --git a/src/_pytask/clean.py b/src/_pytask/clean.py
index 7ec12f4ec..ecf515626 100644
--- a/src/_pytask/clean.py
+++ b/src/_pytask/clean.py
@@ -29,8 +29,8 @@
from _pytask.session import Session
from _pytask.shared import to_list
from _pytask.traceback import render_exc_info
+from _pytask.tree_util import tree_leaves
from attrs import define
-from pybaum.tree_util import tree_just_yield
if TYPE_CHECKING:
@@ -195,7 +195,10 @@ def _collect_all_paths_known_to_pytask(session: Session) -> set[Path]:
if session.config["config"]:
known_paths.add(session.config["config"])
known_paths.add(session.config["root"])
- known_paths.add(session.config["database_filename"])
+
+ database_url = session.config["database_url"]
+ if database_url.drivername == "sqlite" and database_url.database:
+ known_paths.add(Path(database_url.database))
# Add files tracked by git.
if is_git_installed():
@@ -215,7 +218,7 @@ def _yield_paths_from_task(task: Task) -> Generator[Path, None, None]:
"""Yield all paths attached to a task."""
yield task.path
for attribute in ("depends_on", "produces"):
- for node in tree_just_yield(getattr(task, attribute)):
+ for node in tree_leaves(getattr(task, attribute)):
if hasattr(node, "path") and isinstance(node.path, Path):
yield node.path
diff --git a/src/_pytask/cli.py b/src/_pytask/cli.py
index 7d42e3551..4b6dd39e9 100644
--- a/src/_pytask/cli.py
+++ b/src/_pytask/cli.py
@@ -66,7 +66,6 @@ def pytask_add_hooks(pm: pluggy.PluginManager) -> None:
from _pytask import mark
from _pytask import nodes
from _pytask import parameters
- from _pytask import parametrize
from _pytask import persist
from _pytask import profile
from _pytask import dag
@@ -89,7 +88,6 @@ def pytask_add_hooks(pm: pluggy.PluginManager) -> None:
pm.register(mark)
pm.register(nodes)
pm.register(parameters)
- pm.register(parametrize)
pm.register(persist)
pm.register(profile)
pm.register(dag)
diff --git a/src/_pytask/click.py b/src/_pytask/click.py
index b55fe0b63..d972cd257 100644
--- a/src/_pytask/click.py
+++ b/src/_pytask/click.py
@@ -242,11 +242,11 @@ def _format_help_text( # noqa: C901, PLR0912, PLR0915
if show_default_is_str or (show_default and (default_value is not None)):
if show_default_is_str:
- default_string = f"({param.show_default})" # type: ignore[attr-defined]
+ default_string = param.show_default # type: ignore[attr-defined]
elif isinstance(default_value, (list, tuple)):
default_string = ", ".join(str(d) for d in default_value)
elif inspect.isfunction(default_value):
- default_string = _("(dynamic)")
+ default_string = _("dynamic")
elif param.is_bool_flag and param.secondary_opts: # type: ignore[attr-defined]
# For boolean flags that have distinct True/False opts,
# use the opt without prefix instead of the value.
diff --git a/src/_pytask/collect.py b/src/_pytask/collect.py
index 7fd1dff92..26db423fd 100644
--- a/src/_pytask/collect.py
+++ b/src/_pytask/collect.py
@@ -6,15 +6,13 @@
import os
import sys
import time
-import warnings
from pathlib import Path
from typing import Any
from typing import Generator
from typing import Iterable
-from _pytask.collect_utils import depends_on
-from _pytask.collect_utils import parse_nodes
-from _pytask.collect_utils import produces
+from _pytask.collect_utils import parse_dependencies_from_task_function
+from _pytask.collect_utils import parse_products_from_task_function
from _pytask.config import hookimpl
from _pytask.config import IS_FILE_SYSTEM_CASE_SENSITIVE
from _pytask.console import console
@@ -22,7 +20,10 @@
from _pytask.console import format_task_id
from _pytask.exceptions import CollectionError
from _pytask.mark_utils import has_mark
-from _pytask.nodes import FilePathNode
+from _pytask.models import NodeInfo
+from _pytask.node_protocols import Node
+from _pytask.nodes import PathNode
+from _pytask.nodes import PythonNode
from _pytask.nodes import Task
from _pytask.outcomes import CollectionOutcome
from _pytask.outcomes import count_outcomes
@@ -94,7 +95,7 @@ def pytask_collect_file_protocol(
)
flat_reports = list(itertools.chain.from_iterable(new_reports))
except Exception: # noqa: BLE001
- node = FilePathNode.from_path(path)
+ node = PathNode.from_path(path)
flat_reports = [
CollectionReport.from_exception(
outcome=CollectionOutcome.FAIL, node=node, exc_info=sys.exc_info()
@@ -106,15 +107,6 @@ def pytask_collect_file_protocol(
return flat_reports
-_PARAMETRIZE_DEPRECATION_WARNING = """\
-The @pytask.mark.parametrize decorator is deprecated and will be removed in pytask \
-v0.4. Either upgrade your code to the new syntax explained in \
-https://tinyurl.com/pytask-loops or silence the warning by setting \
-`silence_parametrize_deprecation = true` in your pyproject.toml under \
-[tool.pytask.ini_options] and pin pytask to <0.4.
-"""
-
-
@hookimpl
def pytask_collect_file(
session: Session, path: Path, reports: list[CollectionReport]
@@ -129,26 +121,11 @@ def pytask_collect_file(
if has_mark(obj, "task"):
continue
- if has_mark(obj, "parametrize"):
- if not session.config.get("silence_parametrize_deprecation", False):
- warnings.warn(
- message=_PARAMETRIZE_DEPRECATION_WARNING,
- category=FutureWarning,
- stacklevel=1,
- )
-
- names_and_objects = session.hook.pytask_parametrize_task(
- session=session, name=name, obj=obj
- )
- else:
- names_and_objects = [(name, obj)]
-
- for name_, obj_ in names_and_objects:
- report = session.hook.pytask_collect_task_protocol(
- session=session, reports=reports, path=path, name=name_, obj=obj_
- )
- if report is not None:
- collected_reports.append(report)
+ report = session.hook.pytask_collect_task_protocol(
+ session=session, reports=reports, path=path, name=name, obj=obj
+ )
+ if report is not None:
+ collected_reports.append(report)
return collected_reports
return None
@@ -192,11 +169,11 @@ def pytask_collect_task(
"""
if (name.startswith("task_") or has_mark(obj, "task")) and callable(obj):
- dependencies = parse_nodes(session, path, name, obj, depends_on)
- products = parse_nodes(session, path, name, obj, produces)
+ dependencies = parse_dependencies_from_task_function(session, path, name, obj)
+
+ products = parse_products_from_task_function(session, path, name, obj)
markers = obj.pytask_meta.markers if hasattr(obj, "pytask_meta") else []
- kwargs = obj.pytask_meta.kwargs if hasattr(obj, "pytask_meta") else {}
# Get the underlying function to avoid having different states of the function,
# e.g. due to pytask_meta, in different layers of the wrapping.
@@ -209,7 +186,6 @@ def pytask_collect_task(
depends_on=dependencies,
produces=products,
markers=markers,
- kwargs=kwargs,
)
return None
@@ -228,10 +204,8 @@ def pytask_collect_task(
@hookimpl(trylast=True)
-def pytask_collect_node(
- session: Session, path: Path, node: str | Path
-) -> FilePathNode | None:
- """Collect a node of a task as a :class:`pytask.nodes.FilePathNode`.
+def pytask_collect_node(session: Session, path: Path, node_info: NodeInfo) -> Node:
+ """Collect a node of a task as a :class:`pytask.nodes.PathNode`.
Strings are assumed to be paths. This might be a strict assumption, but since this
hook is executed at last and possible errors will be shown, it seems reasonable and
@@ -240,19 +214,18 @@ def pytask_collect_node(
``trylast=True`` might be necessary if other plugins try to parse strings themselves
like a plugin for downloading files which depends on URLs given as strings.
- Parameters
- ----------
- session : _pytask.session.Session
- The session.
- path : Union[str, pathlib.Path]
- The path to file where the task and node are specified.
- node : Union[str, pathlib.Path]
- The value of the node which can be a str, a path or anything which cannot be
- handled by this function.
-
"""
- if isinstance(node, str):
- node = Path(node)
+ node = node_info.value
+
+ if isinstance(node, PythonNode):
+ if not node.name:
+ suffix = "-" + "-".join(map(str, node_info.path)) if node_info.path else ""
+ node.name = node_info.arg_name + suffix
+ return node
+
+ if isinstance(node, Node):
+ return node
+
if isinstance(node, Path):
if not node.is_absolute():
node = path.parent.joinpath(node)
@@ -270,8 +243,11 @@ def pytask_collect_node(
if str(node) != str(case_sensitive_path):
raise ValueError(_TEMPLATE_ERROR.format(node, case_sensitive_path))
- return FilePathNode.from_path(node)
- return None
+ return PathNode.from_path(node)
+
+ suffix = "-" + "-".join(map(str, node_info.path)) if node_info.path else ""
+ node_name = node_info.arg_name + suffix
+ return PythonNode(value=node, name=node_name)
def _not_ignored_paths(
@@ -283,18 +259,6 @@ def _not_ignored_paths(
directories, all subsequent files and folders are considered, but one level after
another, so that files of ignored folders are not checked.
- Parameters
- ----------
- paths : Iterable[pathlib.Path]
- List of paths from which tasks are collected.
- session : _pytask.session.Session
- The session.
-
- Yields
- ------
- path : pathlib.Path
- A path which is not ignored.
-
"""
for path in paths:
if not session.hook.pytask_ignore_collect(path=path, config=session.config):
@@ -307,11 +271,7 @@ def _not_ignored_paths(
@hookimpl(trylast=True)
def pytask_collect_modify_tasks(tasks: list[Task]) -> None:
- """Given all tasks, assign a short uniquely identifiable name to each task.
-
- The shorter ids are necessary to display
-
- """
+ """Given all tasks, assign a short uniquely identifiable name to each task."""
id_to_short_id = _find_shortest_uniquely_identifiable_name_for_tasks(tasks)
for task in tasks:
short_id = id_to_short_id[task.name]
diff --git a/src/_pytask/collect_command.py b/src/_pytask/collect_command.py
index 9bad3d365..a8b3fcf75 100644
--- a/src/_pytask/collect_command.py
+++ b/src/_pytask/collect_command.py
@@ -20,12 +20,13 @@
from _pytask.exceptions import ResolvingDependenciesError
from _pytask.mark import select_by_keyword
from _pytask.mark import select_by_mark
+from _pytask.node_protocols import PPathNode
from _pytask.outcomes import ExitCode
from _pytask.path import find_common_ancestor
from _pytask.path import relative_to
from _pytask.pluginmanager import get_plugin_manager
from _pytask.session import Session
-from pybaum.tree_util import tree_just_flatten
+from _pytask.tree_util import tree_leaves
from rich.text import Text
from rich.tree import Tree
@@ -123,8 +124,10 @@ def _find_common_ancestor_of_all_nodes(
for task in tasks:
all_paths.append(task.path)
if show_nodes:
- all_paths.extend(x.path for x in tree_just_flatten(task.depends_on))
- all_paths.extend(x.path for x in tree_just_flatten(task.produces))
+ all_paths.extend(
+ x.path for x in tree_leaves(task.depends_on) if isinstance(x, PPathNode)
+ )
+ all_paths.extend(x.path for x in tree_leaves(task.produces))
common_ancestor = find_common_ancestor(*all_paths, *paths)
@@ -145,7 +148,7 @@ def _organize_tasks(tasks: list[Task]) -> dict[Path, list[Task]]:
sorted_dict = {}
for k in sorted(dictionary):
- sorted_dict[k] = sorted(dictionary[k], key=lambda x: x.path)
+ sorted_dict[k] = sorted(dictionary[k], key=lambda x: x.name)
return sorted_dict
@@ -160,14 +163,14 @@ def _print_collected_tasks(
Parameters
----------
- dictionary : Dict[Path, List["Task"]]
+ dictionary
A dictionary with path on the first level, tasks on the second, dependencies and
products on the third.
- show_nodes : bool
+ show_nodes
Indicator for whether dependencies and products should be displayed.
- editor_url_scheme : str
+ editor_url_scheme
The scheme to create an url.
- common_ancestor : Path
+ common_ancestor
The path common to all tasks and nodes.
"""
@@ -197,32 +200,29 @@ def _print_collected_tasks(
)
if show_nodes:
- for node in sorted(
- tree_just_flatten(task.depends_on), key=lambda x: x.path
- ):
- reduced_node_name = relative_to(node.path, common_ancestor)
- url_style = create_url_style_for_path(node.path, editor_url_scheme)
- task_branch.add(
- Text.assemble(
- FILE_ICON,
- "",
+ nodes = list(tree_leaves(task.depends_on))
+ sorted_nodes = sorted(nodes, key=lambda x: x.name)
+ for node in sorted_nodes:
+ if isinstance(node, PPathNode):
+ if node.path.as_posix() in node.name:
+ reduced_node_name = str(
+ relative_to(node.path, common_ancestor)
+ )
+ else:
+ reduced_node_name = node.name
+ url_style = create_url_style_for_path(
+ node.path, editor_url_scheme
)
- )
+ text = Text(reduced_node_name, style=url_style)
+ else:
+ text = node.name
- for node in sorted(
- tree_just_flatten(task.produces), key=lambda x: x.path
- ):
- reduced_node_name = relative_to(node.path, common_ancestor)
+ task_branch.add(Text.assemble(FILE_ICON, ""))
+
+ for node in sorted(tree_leaves(task.produces), key=lambda x: x.path):
+ reduced_node_name = str(relative_to(node.path, common_ancestor))
url_style = create_url_style_for_path(node.path, editor_url_scheme)
- task_branch.add(
- Text.assemble(
- FILE_ICON,
- "",
- )
- )
+ text = Text(reduced_node_name, style=url_style)
+ task_branch.add(Text.assemble(FILE_ICON, ""))
console.print(tree)
diff --git a/src/_pytask/collect_utils.py b/src/_pytask/collect_utils.py
index ca15e624a..fa51bfc5f 100644
--- a/src/_pytask/collect_utils.py
+++ b/src/_pytask/collect_utils.py
@@ -1,8 +1,10 @@
"""This module provides utility functions for :mod:`_pytask.collect`."""
from __future__ import annotations
+import functools
import itertools
import uuid
+import warnings
from pathlib import Path
from typing import Any
from typing import Callable
@@ -10,25 +12,40 @@
from typing import Iterable
from typing import TYPE_CHECKING
+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 Node
+from _pytask.node_protocols import PPathNode
+from _pytask.nodes import ProductType
+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 attrs import define
from attrs import field
-from pybaum.tree_util import tree_map
+from typing_extensions import Annotated
+from typing_extensions import get_origin
if TYPE_CHECKING:
from _pytask.session import Session
- from _pytask.nodes import MetaNode
-__all__ = ["depends_on", "parse_nodes", "produces"]
+__all__ = [
+ "depends_on",
+ "parse_dependencies_from_task_function",
+ "parse_nodes",
+ "produces",
+]
-def depends_on(
- objects: Any | Iterable[Any] | dict[Any, Any]
-) -> Any | Iterable[Any] | dict[Any, Any]:
+def depends_on(objects: PyTree[Any]) -> PyTree[Any]:
"""Specify dependencies for a task.
Parameters
@@ -42,9 +59,7 @@ def depends_on(
return objects
-def produces(
- objects: Any | Iterable[Any] | dict[Any, Any]
-) -> Any | Iterable[Any] | dict[Any, Any]:
+def produces(objects: PyTree[Any]) -> PyTree[Any]:
"""Specify products of a task.
Parameters
@@ -62,9 +77,15 @@ def parse_nodes(
session: Session, path: Path, name: str, 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, parser.__name__)
- nodes = tree_map(lambda x: _collect_node(session, path, name, x), nodes)
+ nodes = _convert_objects_to_node_dictionary(objects, arg_name)
+ nodes = tree_map(
+ lambda x: _collect_decorator_node(
+ session, path, name, NodeInfo(arg_name, (), x)
+ ),
+ nodes,
+ )
return nodes
@@ -194,25 +215,325 @@ def _merge_dictionaries(list_of_dicts: list[dict[Any, Any]]) -> dict[Any, Any]:
return out
-def _collect_node(
- session: Session, path: Path, name: str, node: str | Path
-) -> dict[str, MetaNode]:
+_ERROR_MULTIPLE_DEPENDENCY_DEFINITIONS = """The task uses multiple ways to define \
+dependencies. Dependencies should be defined with either
+
+- '@pytask.mark.depends_on' and a 'depends_on' function argument.
+- as default value for the function argument 'depends_on'.
+
+Use only one of the two ways!
+
+Hint: You do not need to use 'depends_on' since pytask v0.4. Every function argument \
+that is not a product is treated as a dependency. Read more about dependencies in the \
+documentation: https://tinyurl.com/yrezszr4.
+"""
+
+
+def parse_dependencies_from_task_function(
+ session: Session, path: Path, name: str, 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, path, name, 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 products from task decorated with @task and that uses produces.
+ if "depends_on" in kwargs:
+ has_depends_on_argument = True
+ dependencies["depends_on"] = tree_map(
+ lambda x: _collect_decorator_node(
+ session, path, name, NodeInfo(arg_name="depends_on", path=(), value=x)
+ ),
+ kwargs["depends_on"],
+ )
+
+ 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)
+
+ for parameter_name, value in kwargs.items():
+ if parameter_name in parameters_with_product_annot:
+ continue
+
+ if parameter_name == "depends_on":
+ continue
+
+ partialed_evolve = functools.partial(
+ _evolve_instance,
+ instance_from_annot=parameters_with_node_annot.get(parameter_name),
+ )
+
+ nodes = tree_map_with_path(
+ lambda p, x: _collect_dependency(
+ session,
+ path,
+ name,
+ NodeInfo(parameter_name, p, partialed_evolve(x)), # noqa: B023
+ ),
+ value,
+ )
+
+ # If all nodes are python nodes, we simplify the parameter value and store it in
+ # one node.
+ are_all_nodes_python_nodes_without_hash = all(
+ isinstance(x, PythonNode) and not x.hash for x in tree_leaves(nodes)
+ )
+ if not isinstance(nodes, Node) and are_all_nodes_python_nodes_without_hash:
+ dependencies[parameter_name] = PythonNode(value=value, name=parameter_name)
+ else:
+ dependencies[parameter_name] = nodes
+ return dependencies
+
+
+def _find_args_with_node_annotation(func: Callable[..., Any]) -> dict[str, Node]:
+ """Find args with node annotations."""
+ annotations = get_annotations(func, eval_str=True)
+ metas = {
+ name: annotation.__metadata__
+ for name, annotation in annotations.items()
+ if get_origin(annotation) is Annotated
+ }
+
+ args_with_node_annotation = {}
+ for name, meta in metas.items():
+ annot = [i for i in meta if not isinstance(i, ProductType)]
+ if len(annot) >= 2: # noqa: PLR2004
+ raise ValueError(
+ f"Parameter {name!r} has multiple node annotations although only one "
+ f"is allowed. Annotations: {annot}"
+ )
+ if annot:
+ args_with_node_annotation[name] = annot[0]
+
+ return args_with_node_annotation
+
+
+_ERROR_MULTIPLE_PRODUCT_DEFINITIONS = """The task uses multiple ways to define \
+products. Products should be defined with either
+
+- 'typing.Annotated[Path(...), Product]' (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/yrezszr4.
+"""
+
+_WARNING_PRODUCES_AS_KWARG = """Using 'produces' as an argument name to specify \
+products is deprecated and won't be available in pytask v0.5. Instead, use the product \
+annotation, described in this tutorial: https://tinyurl.com/yrezszr4.
+
+ from typing_extensions import Annotated
+ from pytask import Product
+
+ def task_example(produces: Annotated[..., Product]):
+ ...
+
+"""
+
+
+def parse_products_from_task_function(
+ session: Session, path: Path, name: str, obj: Any
+) -> dict[str, Any]:
+ """Parse products from task function.
+
+ Raises
+ ------
+ NodeNotCollectedError
+ 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, path, name, 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)
+ kwargs = {**signature_defaults, **task_kwargs}
+
+ parameters_with_product_annot = _find_args_with_product_annotation(obj)
+ parameters_with_node_annot = _find_args_with_node_annotation(obj)
+
+ # Parse products from task decorated with @task and that uses produces.
+ if "produces" in kwargs:
+ if "produces" not in parameters_with_product_annot:
+ warnings.warn(
+ _WARNING_PRODUCES_AS_KWARG, category=FutureWarning, stacklevel=1
+ )
+ has_produces_argument = True
+ collected_products = tree_map_with_path(
+ lambda p, x: _collect_product(
+ session,
+ path,
+ name,
+ NodeInfo(arg_name="produces", path=p, value=x),
+ is_string_allowed=True,
+ ),
+ kwargs["produces"],
+ )
+ out = {"produces": collected_products}
+
+ if parameters_with_product_annot:
+ has_annotation = True
+ for parameter_name in parameters_with_product_annot:
+ if parameter_name in kwargs:
+ partialed_evolve = functools.partial(
+ _evolve_instance,
+ instance_from_annot=parameters_with_node_annot.get(parameter_name),
+ )
+
+ collected_products = tree_map_with_path(
+ lambda p, x: _collect_product(
+ session,
+ path,
+ name,
+ NodeInfo(parameter_name, p, partialed_evolve(x)), # noqa: B023
+ is_string_allowed=False,
+ ),
+ kwargs[parameter_name],
+ )
+ out = {parameter_name: collected_products}
+
+ if "return" in parameters_with_node_annot:
+ has_return = True
+ collected_products = tree_map_with_path(
+ lambda p, x: _collect_product(
+ session,
+ path,
+ name,
+ NodeInfo("return", p, x),
+ is_string_allowed=False,
+ ),
+ parameters_with_node_annot["return"],
+ )
+ out = {"return": collected_products}
+
+ task_produces = obj.pytask_meta.produces if hasattr(obj, "pytask_meta") else None
+ if task_produces:
+ has_task_decorator = True
+ collected_products = tree_map_with_path(
+ lambda p, x: _collect_product(
+ session,
+ path,
+ name,
+ NodeInfo("return", p, x),
+ is_string_allowed=False,
+ ),
+ task_produces,
+ )
+ out = {"return": collected_products}
+
+ if (
+ sum(
+ (
+ has_produces_decorator,
+ has_produces_argument,
+ has_annotation,
+ has_return,
+ has_task_decorator,
+ )
+ )
+ >= 2 # noqa: PLR2004
+ ):
+ raise NodeNotCollectedError(_ERROR_MULTIPLE_PRODUCT_DEFINITIONS)
+
+ return out
+
+
+def _find_args_with_product_annotation(func: Callable[..., Any]) -> list[str]:
+ """Find args with product annotations."""
+ annotations = get_annotations(func, eval_str=True)
+ metas = {
+ name: annotation.__metadata__
+ for name, annotation in annotations.items()
+ if get_origin(annotation) is Annotated
+ }
+
+ args_with_product_annot = []
+ for name, meta in metas.items():
+ has_product_annot = any(isinstance(i, ProductType) for i in meta)
+ if has_product_annot:
+ args_with_product_annot.append(name)
+
+ 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
+) -> Node:
"""Collect nodes for a task.
- Parameters
- ----------
- session
- The session.
- path
- The path to the task whose nodes are collected.
- name
- The name of the task.
- nodes
- A dictionary of nodes parsed from the ``depends_on`` or ``produces`` markers.
-
- Returns
- -------
- A dictionary of node names and their paths.
+ 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:
+ raise NodeNotCollectedError(
+ f"{node!r} cannot be parsed as a {kind} for task {name!r} in {path!r}."
+ )
+
+ return collected_node
+
+
+def _collect_dependency(
+ session: Session, path: Path, name: str, node_info: NodeInfo
+) -> Node:
+ """Collect nodes for a task.
Raises
------
@@ -220,13 +541,72 @@ def _collect_node(
If the node could not collected.
"""
+ node = node_info.value
+
collected_node = session.hook.pytask_collect_node(
- session=session, path=path, node=node
+ session=session, path=path, node_info=node_info
)
if collected_node is None:
raise NodeNotCollectedError(
- f"{node!r} cannot be parsed as a dependency or product for task "
+ f"{node!r} cannot be parsed as a dependency for task "
f"{name!r} in {path!r}."
)
+ return collected_node
+
+
+def _collect_product(
+ session: Session,
+ path: Path,
+ task_name: str,
+ node_info: NodeInfo,
+ is_string_allowed: bool = False,
+) -> Node:
+ """Collect products for a task.
+
+ Defining products with strings is only allowed when using the decorator. Parameter
+ defaults can only be :class:`pathlib.Path`s.
+
+ Raises
+ ------
+ NodeNotCollectedError
+ If the node could not collected.
+
+ """
+ node = node_info.value
+ # For historical reasons, task.kwargs is like the deco and supports str and Path.
+ if not isinstance(node, (str, Path)) and is_string_allowed:
+ raise ValueError(
+ "`@pytask.mark.task(kwargs={'produces': ...}` can only accept values of "
+ "type 'str' and 'pathlib.Path' or the same values nested in "
+ f"tuples, lists, and dictionaries. Here, {node} has type {type(node)}."
+ )
+ # The parameter defaults only support Path objects.
+ if not isinstance(node, (Path, PPathNode)) and not is_string_allowed:
+ raise ValueError(
+ "If you declare products with 'Annotated[..., Product]', only values of "
+ "type 'pathlib.Path' optionally nested in tuples, lists, and "
+ f"dictionaries are allowed. Here, {node!r} has type {type(node)}."
+ )
+
+ if isinstance(node, str):
+ 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:
+ raise NodeNotCollectedError(
+ f"{node!r} can't be parsed as a product for task {task_name!r} in {path!r}."
+ )
return collected_node
+
+
+def _evolve_instance(x: Any, instance_from_annot: Node | None) -> Any:
+ """Evolve a value to a node if it is given by annotations."""
+ if not instance_from_annot:
+ return x
+
+ instance_from_annot.from_annot(x)
+ return instance_from_annot
diff --git a/src/_pytask/dag.py b/src/_pytask/dag.py
index 102e223d4..050aae214 100644
--- a/src/_pytask/dag.py
+++ b/src/_pytask/dag.py
@@ -1,7 +1,6 @@
"""This module contains code related to resolving dependencies."""
from __future__ import annotations
-import hashlib
import itertools
import sys
@@ -16,13 +15,15 @@
from _pytask.dag_utils import node_and_neighbors
from _pytask.dag_utils import task_and_descending_tasks
from _pytask.dag_utils import TopologicalSorter
+from _pytask.database_utils import DatabaseSession
from _pytask.database_utils import State
from _pytask.exceptions import ResolvingDependenciesError
from _pytask.mark import Mark
from _pytask.mark_utils import get_marks
from _pytask.mark_utils import has_mark
-from _pytask.nodes import FilePathNode
-from _pytask.nodes import MetaNode
+from _pytask.node_protocols import MetaNode
+from _pytask.node_protocols import Node
+from _pytask.node_protocols import PPathNode
from _pytask.nodes import Task
from _pytask.path import find_common_ancestor_of_nodes
from _pytask.report import DagReport
@@ -30,8 +31,7 @@
from _pytask.shared import reduce_names_of_multiple_nodes
from _pytask.shared import reduce_node_name
from _pytask.traceback import render_exc_info
-from pony import orm
-from pybaum import tree_map
+from _pytask.tree_util import tree_map
from rich.text import Text
from rich.tree import Tree
@@ -126,36 +126,36 @@ def _have_task_or_neighbors_changed(
)
-@orm.db_session
@hookimpl(trylast=True)
def pytask_dag_has_node_changed(node: MetaNode, task_name: str) -> bool:
"""Indicate whether a single dependency or product has changed."""
- if isinstance(node, (FilePathNode, Task)):
- # If node does not exist, we receive None.
- file_state = node.state()
- if file_state is None:
- return True
+ # If node does not exist, we receive None.
+ node_state = node.state()
+ if node_state is None:
+ return True
- # If the node is not in the database.
- try:
- name = node.name
- db_state = State[task_name, name] # type: ignore[type-arg, valid-type]
- except orm.ObjectNotFound:
- return True
+ with DatabaseSession() as session:
+ db_state = session.get(State, (task_name, node.name))
+ # If the node is not in the database.
+ if db_state is None:
+ return True
+
+ if isinstance(node, (PPathNode, Task)):
# If the modification times match, the node has not been changed.
- if file_state == db_state.modification_time:
+ if node_state == db_state.modification_time:
return False
# If the modification time changed, quickly return for non-tasks.
- if isinstance(node, FilePathNode):
+ if not isinstance(node, Task):
return True
# When modification times changed, we are still comparing the hash of the file
# to avoid unnecessary and expensive reexecutions of tasks.
- file_hash = hashlib.sha256(node.path.read_bytes()).hexdigest()
- return file_hash != db_state.file_hash
- return node.state()
+ hash_ = node.state(hash=True)
+ return hash_ != db_state.hash_
+
+ return node_state != db_state.hash_
def _check_if_dag_has_cycles(dag: nx.DiGraph) -> None:
@@ -191,7 +191,10 @@ def _format_cycles(cycles: list[tuple[str, ...]]) -> str:
"tree which shows which dependencies are missing for which tasks.\n\n{}"
)
if IS_FILE_SYSTEM_CASE_SENSITIVE:
- _TEMPLATE_ERROR += "\n\n(Hint: Sometimes case sensitivity is at fault.)"
+ _TEMPLATE_ERROR += (
+ "\n\n(Hint: Your file-system is case-sensitive. Check the paths' "
+ "capitalization carefully.)"
+ )
def _check_if_root_nodes_are_available(dag: nx.DiGraph) -> None:
@@ -236,7 +239,7 @@ def _check_if_root_nodes_are_available(dag: nx.DiGraph) -> None:
def _check_if_tasks_are_skipped(
- node: MetaNode, dag: nx.DiGraph, is_task_skipped: dict[str, bool]
+ node: Node, dag: nx.DiGraph, is_task_skipped: dict[str, bool]
) -> tuple[bool, dict[str, bool]]:
"""Check for a given node whether it is only used by skipped tasks."""
are_all_tasks_skipped = []
diff --git a/src/_pytask/database.py b/src/_pytask/database.py
index c229717e4..ea797e81f 100644
--- a/src/_pytask/database.py
+++ b/src/_pytask/database.py
@@ -1,86 +1,43 @@
-"""Implement the database managed with pony."""
+"""Contains hooks related to the database."""
from __future__ import annotations
-import enum
from pathlib import Path
from typing import Any
-import click
-from _pytask.click import EnumChoice
from _pytask.config import hookimpl
from _pytask.database_utils import create_database
-from click import Context
-
-
-class _DatabaseProviders(enum.Enum):
- SQLITE = "sqlite"
- POSTGRES = "postgres"
- MYSQL = "mysql"
- ORACLE = "oracle"
- COCKROACH = "cockroach"
-
-
-def _database_filename_callback(
- ctx: Context, name: str, value: str | None # noqa: ARG001
-) -> str | None:
- if value is None:
- return ctx.params["root"].joinpath(".pytask.sqlite3")
- return value
-
-
-@hookimpl
-def pytask_extend_command_line_interface(cli: click.Group) -> None:
- """Extend command line interface."""
- additional_parameters = [
- click.Option(
- ["--database-provider"],
- type=EnumChoice(_DatabaseProviders),
- help=(
- "Database provider. All providers except sqlite are considered "
- "experimental."
- ),
- default=_DatabaseProviders.SQLITE,
- ),
- click.Option(
- ["--database-filename"],
- type=click.Path(file_okay=True, dir_okay=False, path_type=Path),
- help=("Path to database relative to root."),
- default=Path(".pytask.sqlite3"),
- callback=_database_filename_callback,
- ),
- click.Option(
- ["--database-create-db"],
- type=bool,
- help="Create database if it does not exist.",
- default=True,
- ),
- click.Option(
- ["--database-create-tables"],
- type=bool,
- help="Create tables if they do not exist.",
- default=True,
- ),
- ]
- cli.commands["build"].params.extend(additional_parameters)
+from sqlalchemy.engine import make_url
@hookimpl
def pytask_parse_config(config: dict[str, Any]) -> None:
"""Parse the configuration."""
- if not config["database_filename"].is_absolute():
- config["database_filename"] = config["root"].joinpath(
- config["database_filename"]
+ # Set default.
+ if not config["database_url"]:
+ config["database_url"] = make_url(
+ f"sqlite:///{config['root'].as_posix()}/.pytask.sqlite3"
)
- config["database"] = {
- "provider": config["database_provider"].value,
- "filename": config["database_filename"].as_posix(),
- "create_db": config["database_create_db"],
- "create_tables": config["database_create_tables"],
- }
+ if (
+ config["database_url"].drivername == "sqlite"
+ and config["database_url"].database
+ ) and not Path(config["database_url"].database).is_absolute():
+ if config["config"]:
+ full_path = (
+ config["config"]
+ .parent.joinpath(config["database_url"].database)
+ .resolve()
+ )
+ else:
+ full_path = (
+ config["root"].joinpath(config["database_url"].database).resolve()
+ )
+ config["database_url"] = config["database_url"]._replace(
+ database=full_path.as_posix()
+ )
@hookimpl
def pytask_post_parse(config: dict[str, Any]) -> None:
"""Post-parse the configuration."""
- create_database(**config["database"])
+ create_database(config["database_url"])
diff --git a/src/_pytask/database_utils.py b/src/_pytask/database_utils.py
index 1ad974abe..19f5724b7 100644
--- a/src/_pytask/database_utils.py
+++ b/src/_pytask/database_utils.py
@@ -4,56 +4,72 @@
import hashlib
from _pytask.dag_utils import node_and_neighbors
+from _pytask.node_protocols import PPathNode
from _pytask.nodes import Task
from _pytask.session import Session
-from pony import orm
+from sqlalchemy import Column
+from sqlalchemy import create_engine
+from sqlalchemy import String
+from sqlalchemy.orm import declarative_base
+from sqlalchemy.orm import sessionmaker
-__all__ = ["create_database", "db", "update_states_in_database"]
+__all__ = [
+ "create_database",
+ "update_states_in_database",
+ "BaseTable",
+ "DatabaseSession",
+]
-db = orm.Database()
+DatabaseSession = sessionmaker()
-class State(db.Entity): # type: ignore[name-defined]
+BaseTable = declarative_base()
+
+
+class State(BaseTable): # type: ignore[valid-type, misc]
"""Represent the state of a node in relation to a task."""
- task = orm.Required(str)
- node = orm.Required(str)
- modification_time = orm.Required(str)
- file_hash = orm.Optional(str)
+ __tablename__ = "state"
- orm.PrimaryKey(task, node)
+ task = Column(String, primary_key=True)
+ node = Column(String, primary_key=True)
+ modification_time = Column(String)
+ hash_ = Column(String)
-def create_database(
- provider: str, filename: str, *, create_db: bool, create_tables: bool
-) -> None:
+def create_database(url: str) -> None:
"""Create the database."""
try:
- db.bind(provider=provider, filename=filename, create_db=create_db)
- db.generate_mapping(create_tables=create_tables)
- except orm.BindingError:
- pass
+ engine = create_engine(url)
+ BaseTable.metadata.create_all(bind=engine)
+ DatabaseSession.configure(bind=engine)
+ except Exception:
+ raise
-@orm.db_session
def _create_or_update_state(
- first_key: str, second_key: str, modification_time: str, file_hash: str
+ first_key: str, second_key: str, modification_time: str, hash_: str
) -> None:
"""Create or update a state."""
- try:
- state_in_db = State[first_key, second_key] # type: ignore[type-arg, valid-type]
- except orm.ObjectNotFound:
- State(
- task=first_key,
- node=second_key,
- modification_time=modification_time,
- file_hash=file_hash,
- )
- else:
- state_in_db.modification_time = modification_time
- state_in_db.file_hash = file_hash
+ with DatabaseSession() as session:
+ state_in_db = session.get(State, (first_key, second_key))
+
+ if not state_in_db:
+ session.add(
+ State(
+ task=first_key,
+ node=second_key,
+ modification_time=modification_time,
+ hash_=hash_,
+ )
+ )
+ else:
+ state_in_db.modification_time = modification_time
+ state_in_db.hash_ = hash_
+
+ session.commit()
def update_states_in_database(session: Session, task_name: str) -> None:
@@ -61,11 +77,14 @@ def update_states_in_database(session: Session, task_name: str) -> None:
for name in node_and_neighbors(session.dag, task_name):
node = session.dag.nodes[name].get("task") or session.dag.nodes[name]["node"]
- state = node.state()
-
if isinstance(node, Task):
+ modification_time = node.state()
hash_ = hashlib.sha256(node.path.read_bytes()).hexdigest()
- else:
+ elif isinstance(node, PPathNode):
+ modification_time = node.state()
hash_ = ""
+ else:
+ modification_time = ""
+ hash_ = node.state()
- _create_or_update_state(task_name, node.name, state, hash_)
+ _create_or_update_state(task_name, node.name, modification_time, hash_)
diff --git a/src/_pytask/execute.py b/src/_pytask/execute.py
index 1922e27aa..6603d33e2 100644
--- a/src/_pytask/execute.py
+++ b/src/_pytask/execute.py
@@ -21,7 +21,7 @@
from _pytask.exceptions import NodeNotFoundError
from _pytask.mark import Mark
from _pytask.mark_utils import has_mark
-from _pytask.nodes import FilePathNode
+from _pytask.node_protocols import PPathNode
from _pytask.nodes import Task
from _pytask.outcomes import count_outcomes
from _pytask.outcomes import Exit
@@ -33,7 +33,7 @@
from _pytask.traceback import format_exception_without_traceback
from _pytask.traceback import remove_traceback_from_exc_info
from _pytask.traceback import render_exc_info
-from pybaum.tree_util import tree_map
+from _pytask.tree_util import tree_map
from rich.text import Text
@@ -129,7 +129,7 @@ def pytask_execute_task_setup(session: Session, task: Task) -> None:
# method for the node classes.
for product in session.dag.successors(task.name):
node = session.dag.nodes[product]["node"]
- if isinstance(node, FilePathNode):
+ if isinstance(node, PPathNode):
node.path.parent.mkdir(parents=True, exist_ok=True)
would_be_executed = has_mark(task, "would_be_executed")
@@ -143,13 +143,15 @@ def pytask_execute_task(session: Session, task: Task) -> bool:
if session.config["dry_run"]:
raise WouldBeExecuted
- kwargs = {**task.kwargs}
+ parameters = inspect.signature(task.function).parameters
- func_arg_names = set(inspect.signature(task.function).parameters)
- for arg_name in ("depends_on", "produces"):
- if arg_name in func_arg_names:
- attribute = getattr(task, arg_name)
- kwargs[arg_name] = tree_map(lambda x: x.value, attribute)
+ kwargs = {}
+ for name, value in task.depends_on.items():
+ kwargs[name] = tree_map(lambda x: x.load(), value)
+
+ for name, value in task.produces.items():
+ if name in parameters:
+ kwargs[name] = tree_map(lambda x: x.load(), value)
task.execute(**kwargs)
return True
@@ -157,7 +159,7 @@ def pytask_execute_task(session: Session, task: Task) -> bool:
@hookimpl
def pytask_execute_task_teardown(session: Session, task: Task) -> None:
- """Check if :class:`_pytask.nodes.FilePathNode` are produced by a task."""
+ """Check if :class:`_pytask.nodes.PathNode` are produced by a task."""
missing_nodes = []
for product in session.dag.successors(task.name):
node = session.dag.nodes[product]["node"]
diff --git a/src/_pytask/hookspecs.py b/src/_pytask/hookspecs.py
index 9a49206f2..dfa83f92f 100644
--- a/src/_pytask/hookspecs.py
+++ b/src/_pytask/hookspecs.py
@@ -8,17 +8,18 @@
import pathlib
from typing import Any
-from typing import Callable
from typing import TYPE_CHECKING
import click
import networkx
import pluggy
+from _pytask.models import NodeInfo
+from _pytask.node_protocols import MetaNode
+from _pytask.node_protocols import Node
if TYPE_CHECKING:
from _pytask.session import Session
- from _pytask.nodes import MetaNode
from _pytask.nodes import Task
from _pytask.outcomes import CollectionOutcome
from _pytask.outcomes import TaskOutcome
@@ -195,8 +196,8 @@ def pytask_collect_task_teardown(session: Session, task: Task) -> None:
@hookspec(firstresult=True)
def pytask_collect_node(
- session: Session, path: pathlib.Path, node: MetaNode
-) -> MetaNode | None:
+ session: Session, path: pathlib.Path, node_info: NodeInfo
+) -> Node | None:
"""Collect a node which is a dependency or a product of a task."""
@@ -211,27 +212,6 @@ def pytask_collect_log(
"""
-# Hooks to parametrize tasks.
-
-
-@hookspec(firstresult=True)
-def pytask_parametrize_task(
- session: Session, name: str, obj: Any
-) -> list[tuple[str, Callable[..., Any]]]:
- """Generate multiple tasks from name and object with parametrization."""
-
-
-@hookspec
-def pytask_parametrize_kwarg_to_marker(obj: Any, kwargs: dict[Any, Any]) -> None:
- """Add some keyword arguments as markers to object.
-
- This hook moves arguments defined in the parametrization to marks of the same
- function. This allows an argument like ``depends_on`` be transformed to the usual
- ``@pytask.mark.depends_on`` marker which receives special treatment.
-
- """
-
-
# Hooks for resolving dependencies.
diff --git a/src/_pytask/mark/__init__.py b/src/_pytask/mark/__init__.py
index 25db802b4..83a603291 100644
--- a/src/_pytask/mark/__init__.py
+++ b/src/_pytask/mark/__init__.py
@@ -39,6 +39,8 @@
"MarkDecorator",
"MarkGenerator",
"ParseError",
+ "select_by_keyword",
+ "select_by_mark",
]
diff --git a/src/_pytask/mark/__init__.pyi b/src/_pytask/mark/__init__.pyi
new file mode 100644
index 000000000..eca5e6e3b
--- /dev/null
+++ b/src/_pytask/mark/__init__.pyi
@@ -0,0 +1,41 @@
+from pathlib import Path
+from typing_extensions import deprecated
+from _pytask.mark.expression import Expression
+from _pytask.mark.expression import ParseError
+from _pytask.mark.structures import Mark
+from _pytask.mark.structures import MarkDecorator
+from _pytask.mark.structures import MarkGenerator
+from _pytask.tree_util import PyTree
+
+from _pytask.session import Session
+import networkx as nx
+
+def select_by_keyword(session: Session, dag: nx.DiGraph) -> set[str]: ...
+def select_by_mark(session: Session, dag: nx.DiGraph) -> set[str]: ...
+
+class MARK_GEN: # noqa: N801
+ @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/yrezszr4.", # noqa: E501
+ category=DeprecationWarning,
+ 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/yrezszr4.", # noqa: E501
+ category=DeprecationWarning,
+ stacklevel=1,
+ )
+ @staticmethod
+ def depends_on(objects: PyTree[str | Path]) -> None: ...
+
+__all__ = [
+ "Expression",
+ "MARK_GEN",
+ "Mark",
+ "MarkDecorator",
+ "MarkGenerator",
+ "ParseError",
+ "select_by_keyword",
+ "select_by_mark",
+]
diff --git a/src/_pytask/mark/structures.py b/src/_pytask/mark/structures.py
index d4a770474..62109f495 100644
--- a/src/_pytask/mark/structures.py
+++ b/src/_pytask/mark/structures.py
@@ -167,6 +167,12 @@ 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/yrezszr4.
+"""
+
+
class MarkGenerator:
"""Factory for :class:`MarkDecorator` objects.
@@ -191,16 +197,25 @@ def __getattr__(self, name: str) -> MarkDecorator | Any:
if name[0] == "_":
raise AttributeError("Marker name must NOT start with underscore")
+ if name in ("depends_on", "produces"):
+ warnings.warn(
+ _DEPRECATION_DECORATOR.format(name),
+ category=DeprecationWarning,
+ stacklevel=1,
+ )
+
# 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.
if self.config is not None and name not in self.config["markers"]:
if self.config["strict_markers"]:
raise ValueError(f"Unknown pytask.mark.{name}.")
- # Raise a specific error for common misspellings of "parametrize".
- if name in ("parameterize", "parametrise", "parameterise"):
- warnings.warn(
- f"Unknown {name!r} mark, did you mean 'parametrize'?", stacklevel=1
- )
+
+ if name in ("parametrize", "parameterize", "parametrise", "parameterise"):
+ raise NotImplementedError(
+ "@pytask.mark.parametrize has been removed since pytask v0.4. "
+ "Upgrade your parametrized tasks to the new syntax defined in"
+ "https://tinyurl.com/pytask-loops or revert to v0.3."
+ ) from None
warnings.warn(
f"Unknown pytask.mark.{name} - is this a typo? You can register "
diff --git a/src/_pytask/models.py b/src/_pytask/models.py
index 84a0d1d2e..d0080c3ff 100644
--- a/src/_pytask/models.py
+++ b/src/_pytask/models.py
@@ -2,8 +2,10 @@
from __future__ import annotations
from typing import Any
+from typing import NamedTuple
from typing import TYPE_CHECKING
+from _pytask.tree_util import PyTree
from attrs import define
from attrs import field
@@ -23,3 +25,11 @@ class CollectionMetadata:
"""Contains the markers of the function."""
name: str | None = None
"""The name of the task function."""
+ produces: PyTree[Any] = None
+ """Definition of products to handle returns."""
+
+
+class NodeInfo(NamedTuple):
+ arg_name: str
+ path: tuple[str | int, ...]
+ value: Any
diff --git a/src/_pytask/node_protocols.py b/src/_pytask/node_protocols.py
new file mode 100644
index 000000000..f6dc2cf09
--- /dev/null
+++ b/src/_pytask/node_protocols.py
@@ -0,0 +1,66 @@
+from __future__ import annotations
+
+from abc import abstractmethod
+from pathlib import Path
+from typing import Any
+from typing import Protocol
+from typing import runtime_checkable
+
+
+@runtime_checkable
+class MetaNode(Protocol):
+ """Protocol for an intersection between nodes and tasks."""
+
+ name: str | None
+ """The name of node that must be unique."""
+
+ @abstractmethod
+ def state(self) -> str | None:
+ """Return the state of the node.
+
+ The state can be something like a hash or a last modified timestamp. If the node
+ does not exist, you can also return ``None``.
+
+ """
+ ...
+
+
+@runtime_checkable
+class Node(MetaNode, Protocol):
+ """Protocol for nodes."""
+
+ value: Any
+
+ def load(self) -> Any:
+ """Return the value of the node that will be injected into the task."""
+ ...
+
+ def save(self, value: Any) -> Any:
+ """Save the value that was returned from a task."""
+ ...
+
+ def from_annot(self, value: Any) -> Any:
+ """Complete the node by setting the value from an default argument.
+
+ Use it, if you want to add information on how a node handles an argument while
+ keeping the type of the value unrelated to pytask.
+
+ .. codeblock: python
+
+ def task_example(value: Annotated[Any, PythonNode(hash=True)], produces):
+ ...
+
+
+ """
+ ...
+
+
+@runtime_checkable
+class PPathNode(Node, Protocol):
+ """Nodes with paths.
+
+ Nodes with paths receive special handling when it comes to printing their names.
+
+ """
+
+ path: Path
diff --git a/src/_pytask/nodes.py b/src/_pytask/nodes.py
index 53bb5e745..2d8088fd2 100644
--- a/src/_pytask/nodes.py
+++ b/src/_pytask/nodes.py
@@ -2,13 +2,18 @@
from __future__ import annotations
import functools
-from abc import ABCMeta
-from abc import abstractmethod
+import hashlib
from pathlib import Path
from typing import Any
from typing import Callable
+from typing import NoReturn
from typing import TYPE_CHECKING
+from _pytask.node_protocols import MetaNode
+from _pytask.node_protocols import Node
+from _pytask.tree_util import PyTree
+from _pytask.tree_util import tree_leaves
+from _pytask.tree_util import tree_structure
from attrs import define
from attrs import field
@@ -17,18 +22,16 @@
from _pytask.mark import Mark
-__all__ = ["FilePathNode", "MetaNode", "Task"]
+__all__ = ["PathNode", "Product", "Task"]
-class MetaNode(metaclass=ABCMeta):
- """Meta class for nodes."""
+@define(frozen=True)
+class ProductType:
+ """A class to mark products."""
- name: str
- """str: The name of node that must be unique."""
- @abstractmethod
- def state(self) -> Any:
- ...
+Product = ProductType()
+"""ProductType: A singleton to mark products in annotations."""
@define(kw_only=True)
@@ -45,14 +48,12 @@ class Task(MetaNode):
"""The name of the task."""
short_name: str | None = field(default=None, init=False)
"""The shortest uniquely identifiable name for task for display."""
- depends_on: dict[str, MetaNode] = field(factory=dict)
+ depends_on: PyTree[Node] = field(factory=dict)
"""A list of dependencies of task."""
- produces: dict[str, MetaNode] = field(factory=dict)
+ produces: PyTree[Node] = field(factory=dict)
"""A list of products of task."""
markers: list[Mark] = field(factory=list)
"""A list of markers attached to the task function."""
- kwargs: dict[str, Any] = field(factory=dict)
- """A dictionary with keyword arguments supplied to the task."""
_report_sections: list[tuple[str, str, str]] = field(factory=list)
"""Reports with entries for when, what, and content."""
attributes: dict[Any, Any] = field(factory=dict)
@@ -66,14 +67,32 @@ def __attrs_post_init__(self: Task) -> None:
if self.short_name is None:
self.short_name = self.name
- def state(self) -> str | None:
- if self.path.exists():
+ def state(self, hash: bool = False) -> str | None: # noqa: A002
+ """Return the state of the node."""
+ if hash and self.path.exists():
+ return hashlib.sha256(self.path.read_bytes()).hexdigest()
+ if not hash and self.path.exists():
return str(self.path.stat().st_mtime)
return None
def execute(self, **kwargs: Any) -> None:
"""Execute the task."""
- self.function(**kwargs)
+ out = self.function(**kwargs)
+
+ if "return" in self.produces:
+ structure_out = tree_structure(out)
+ structure_return = tree_structure(self.produces["return"])
+ if not structure_out == structure_return:
+ raise ValueError(
+ "The structure of the function return does not match the structure "
+ f"of the return annotation.\n\nFunction return: {structure_out}\n\n"
+ f"Return annotation: {structure_return}"
+ )
+
+ for out_, return_ in zip(
+ tree_leaves(out), tree_leaves(self.produces["return"])
+ ):
+ return_.save(out_)
def add_report_section(self, when: str, key: str, content: str) -> None:
"""Add sections which will be displayed in report like stdout or stderr."""
@@ -82,29 +101,102 @@ def add_report_section(self, when: str, key: str, content: str) -> None:
@define(kw_only=True)
-class FilePathNode(MetaNode):
+class PathNode(Node):
"""The class for a node which is a path."""
- name: str
- """str: Name of the node which makes it identifiable in the DAG."""
- value: Path
- """Any: Value passed to the decorator which can be requested inside the function."""
- path: Path
- """pathlib.Path: Path to the FilePathNode."""
+ name: str = ""
+ """Name of the node which makes it identifiable in the DAG."""
+ value: Path | None = None
+ """Value passed to the decorator which can be requested inside the function."""
- def state(self) -> str | None:
- if self.path.exists():
- return str(self.path.stat().st_mtime)
- return None
+ @property
+ def path(self) -> Path:
+ return self.value
+
+ def from_annot(self, value: Path) -> None:
+ """Set path and if other attributes are not set, set sensible defaults."""
+ if not isinstance(value, Path):
+ raise TypeError("'value' must be a 'pathlib.Path'.")
+ if not self.name:
+ self.name = value.as_posix()
+ self.value = value
@classmethod
- @functools.lru_cache()
- def from_path(cls, path: Path) -> FilePathNode:
+ @functools.lru_cache
+ def from_path(cls, path: Path) -> PathNode:
"""Instantiate class from path to file.
The `lru_cache` decorator ensures that the same object is not collected twice.
"""
if not path.is_absolute():
- raise ValueError("FilePathNode must be instantiated from absolute path.")
- return cls(name=path.as_posix(), value=path, path=path)
+ raise ValueError("Node must be instantiated from absolute path.")
+ return cls(name=path.as_posix(), value=path)
+
+ def state(self) -> str | None:
+ """Calculate the state of the node.
+
+ The state is given by the modification timestamp.
+
+ """
+ if self.path.exists():
+ return str(self.path.stat().st_mtime)
+ return None
+
+ def load(self) -> Path:
+ """Load the value."""
+ return self.value
+
+ def save(self, value: bytes | str) -> None:
+ """Save strings or bytes to file."""
+ if isinstance(value, str):
+ self.path.write_text(value)
+ elif isinstance(value, bytes):
+ self.path.write_bytes(value)
+ else:
+ raise TypeError(
+ f"'PathNode' can only save 'str' and 'bytes', not {type(value)}"
+ )
+
+
+@define(kw_only=True)
+class PythonNode(Node):
+ """The class for a node which is a Python object."""
+
+ name: str = ""
+ """Name of the node."""
+ value: Any = None
+ """Value of the node."""
+ hash: bool = False # noqa: A003
+ """Whether the value should be hashed to determine the state."""
+
+ def load(self) -> Any:
+ """Load the value."""
+ return self.value
+
+ def save(self, value: Any) -> NoReturn:
+ """Save the value."""
+ raise NotImplementedError
+
+ def from_annot(self, value: Any) -> None:
+ """Set the value from a function annotation."""
+ self.value = value
+
+ def state(self) -> str | None:
+ """Calculate state of the node.
+
+ If ``hash = False``, the function returns ``"0"``, a constant hash value, so the
+ :class:`PythonNode` is ignored when checking for a changed state of the task.
+
+ If ``hash = True``, :func:`hash` is used for all types except strings.
+
+ The hash for strings is calculated using hashlib because ``hash("asd")`` returns
+ a different value every invocation since the hash of strings is salted with a
+ random integer and it would confuse users.
+
+ """
+ if self.hash:
+ if isinstance(self.value, str):
+ return str(hashlib.sha256(self.value.encode()).hexdigest())
+ return str(hash(self.value))
+ return "0"
diff --git a/src/_pytask/parameters.py b/src/_pytask/parameters.py
index 3690b014e..2ec31f53b 100644
--- a/src/_pytask/parameters.py
+++ b/src/_pytask/parameters.py
@@ -6,6 +6,10 @@
import click
from _pytask.config import hookimpl
from _pytask.config_utils import set_defaults_from_config
+from click import Context
+from sqlalchemy.engine import make_url
+from sqlalchemy.engine import URL
+from sqlalchemy.exc import ArgumentError
_CONFIG_OPTION = click.Option(
@@ -67,11 +71,39 @@
"""click.Option: An option to embed URLs in task ids."""
+def _database_url_callback(
+ ctx: Context, name: str, value: str | None # noqa: ARG001
+) -> URL:
+ """Check the url for the database."""
+ # Since sqlalchemy v2.0.19, we need to shortcircuit here.
+ if value is None:
+ return None
+
+ try:
+ return make_url(value)
+ except ArgumentError:
+ raise click.BadParameter(
+ "The 'database_url' must conform to sqlalchemy's url standard: "
+ "https://docs.sqlalchemy.org/en/latest/core/engines.html"
+ "#backend-specific-urls."
+ ) from None
+
+
+_DATABASE_URL_OPTION = click.Option(
+ ["--database-url"],
+ type=str,
+ help=("Url to the database."),
+ default=None,
+ show_default="sqlite:///.../.pytask.sqlite3",
+ callback=_database_url_callback,
+)
+
+
@hookimpl(trylast=True)
def pytask_extend_command_line_interface(cli: click.Group) -> None:
"""Register general markers."""
for command in ("build", "clean", "collect", "dag", "profile"):
- cli.commands[command].params.append(_PATH_ARGUMENT)
+ cli.commands[command].params.extend([_PATH_ARGUMENT, _DATABASE_URL_OPTION])
for command in ("build", "clean", "collect", "dag", "markers", "profile"):
cli.commands[command].params.append(_CONFIG_OPTION)
for command in ("build", "clean", "collect", "profile"):
diff --git a/src/_pytask/parametrize.py b/src/_pytask/parametrize.py
deleted file mode 100644
index f3a9c2ca1..000000000
--- a/src/_pytask/parametrize.py
+++ /dev/null
@@ -1,390 +0,0 @@
-"""This module contains the code for the parametrize plugin."""
-from __future__ import annotations
-
-import copy
-import functools
-import itertools
-import pprint
-import types
-from typing import Any
-from typing import Callable
-from typing import Iterable
-from typing import Sequence
-
-from _pytask.config import hookimpl
-from _pytask.console import format_strings_as_flat_tree
-from _pytask.console import TASK_ICON
-from _pytask.mark import Mark
-from _pytask.mark import MARK_GEN as mark # noqa: N811
-from _pytask.mark_utils import remove_marks
-from _pytask.parametrize_utils import arg_value_to_id_component
-from _pytask.session import Session
-from _pytask.shared import find_duplicates
-
-
-def parametrize(
- arg_names: str | list[str] | tuple[str, ...],
- arg_values: Iterable[Sequence[Any] | Any],
- *,
- ids: None | (Iterable[None | str | float | int | bool] | Callable[..., Any]) = None,
-) -> tuple[
- str | list[str] | tuple[str, ...],
- Iterable[Sequence[Any] | Any],
- Iterable[None | str | float | int | bool] | Callable[..., Any] | None,
-]:
- """Parametrize a task function.
-
- Parametrizing a task allows to execute the same task with different arguments.
-
- Parameters
- ----------
- arg_names : str | list[str] | tuple[str, ...]
- The names of the arguments which can either be given as a comma-separated
- string, a tuple of strings, or a list of strings.
- arg_values : Iterable[Sequence[Any] | Any]
- The values which correspond to names in ``arg_names``. For one argument, it is a
- single iterable. For multiple argument names it is an iterable of iterables.
- ids
- This argument can either be a list with ids or a function which is called with
- every value passed to the parametrized function.
-
- If you pass an iterable with ids, make sure to only use :obj:`bool`,
- :obj:`float`, :obj:`int`, or :obj:`str` as values which are used to create task
- ids like ``"task_dummpy.py::task_dummy[first_task_id]"``.
-
- If you pass a function, the function receives each value of the parametrization
- and may return a boolean, number, string or None. For the latter, the
- auto-generated value is used.
-
- """
- return arg_names, arg_values, ids
-
-
-@hookimpl
-def pytask_parse_config(config: dict[str, Any]) -> None:
- """Add the marker to the config."""
- config["markers"]["parametrize"] = (
- "The marker for pytest's way of repeating tasks which is explained in this "
- "tutorial: [link https://bit.ly/3uqZqkk]https://bit.ly/3uqZqkk[/]."
- )
-
-
-@hookimpl
-def pytask_parametrize_task(
- session: Session, name: str, obj: Callable[..., Any]
-) -> list[tuple[str, Callable[..., Any]]]:
- """Parametrize a task.
-
- This function takes a single Python function and all parametrize decorators and
- generates multiple instances of the same task with different arguments.
-
- Note that, while a single ``@pytask.mark.parametrize`` is handled like a loop or a
- :func:`zip`, multiple ``@pytask.mark.parametrize`` decorators form a Cartesian
- product.
-
- We cannot raise an error if the function does not use parametrized arguments since
- some plugins will replace functions with their own implementation like pytask-r.
-
- """
- if callable(obj):
- obj, markers = remove_marks(obj, "parametrize") # type: ignore[assignment]
-
- if len(markers) > 1:
- raise NotImplementedError(
- "You cannot apply @pytask.mark.parametrize multiple times to a task. "
- "Use multiple for-loops, itertools.product or a different strategy to "
- "create all combinations of inputs and pass it to a single "
- "@pytask.mark.parametrize.\n\nFor improved readability, consider to "
- "move the creation of inputs into its own function as shown in the "
- "best-practices guide on parametrizations: https://pytask-dev.rtfd.io/"
- "en/stable/how_to_guides/bp_scalable_repetitions_of_tasks.html."
- )
-
- base_arg_names, arg_names, arg_values = _parse_parametrize_markers(
- markers, name
- )
-
- product_arg_names = list(itertools.product(*arg_names))
- product_arg_values = list(itertools.product(*arg_values))
-
- names_and_functions: list[tuple[str, Callable[..., Any]]] = []
- for names, values in zip(product_arg_names, product_arg_values):
- kwargs = dict(
- zip(
- itertools.chain.from_iterable(base_arg_names),
- itertools.chain.from_iterable(values),
- )
- )
-
- # Copy function and attributes to allow in-place changes.
- func = _copy_func(obj) # type: ignore[arg-type]
- func.pytask_meta = copy.deepcopy( # type: ignore[attr-defined]
- obj.pytask_meta # type: ignore[attr-defined]
- )
- # Convert parametrized dependencies and products to decorator.
- session.hook.pytask_parametrize_kwarg_to_marker(obj=func, kwargs=kwargs)
-
- func.pytask_meta.kwargs = { # type: ignore[attr-defined]
- **func.pytask_meta.kwargs, # type: ignore[attr-defined]
- **kwargs,
- }
-
- name_ = f"{name}[{'-'.join(itertools.chain.from_iterable(names))}]"
- names_and_functions.append((name_, func))
-
- all_names = [i[0] for i in names_and_functions]
- duplicates = find_duplicates(all_names)
-
- if duplicates:
- text = format_strings_as_flat_tree(
- duplicates, "Duplicated task ids", TASK_ICON
- )
- raise ValueError(
- "The following ids are duplicated while parametrizing task "
- f"{name!r}.\n\n{text}\n\nIt might be caused by "
- "parametrizing the task with the same combination of arguments "
- "multiple times. Change the arguments or change the ids generated by "
- "the parametrization."
- )
-
- return names_and_functions
- return None
-
-
-def _parse_parametrize_marker(
- marker: Mark, name: str
-) -> tuple[tuple[str, ...], list[tuple[str, ...]], list[tuple[Any, ...]]]:
- """Parse parametrize marker.
-
- Parameters
- ----------
- marker : Mark
- A parametrize mark.
- name : str
- The name of the task function which is parametrized.
-
- Returns
- -------
- base_arg_names : Tuple[str, ...]
- Contains the names of the arguments.
- processed_arg_names : List[Tuple[str, ...]]
- Each tuple in the list represents the processed names of the arguments suffixed
- with a number indicating the iteration.
- processed_arg_values : List[Tuple[Any, ...]]
- Each tuple in the list represents the values of the arguments for each
- iteration.
-
- """
- arg_names, arg_values, ids = parametrize(*marker.args, **marker.kwargs)
-
- parsed_arg_names = _parse_arg_names(arg_names)
- has_single_arg = len(parsed_arg_names) == 1
- parsed_arg_values = _parse_arg_values(arg_values, has_single_arg)
-
- _check_if_n_arg_names_matches_n_arg_values(
- parsed_arg_names, parsed_arg_values, name
- )
-
- expanded_arg_names = _create_parametrize_ids_components(
- parsed_arg_names, parsed_arg_values, ids
- )
-
- return parsed_arg_names, expanded_arg_names, parsed_arg_values
-
-
-def _parse_parametrize_markers(
- markers: list[Mark], name: str
-) -> tuple[
- list[tuple[str, ...]],
- list[list[tuple[str, ...]]],
- list[list[tuple[Any, ...]]],
-]:
- """Parse parametrize markers."""
- parsed_markers = [_parse_parametrize_marker(marker, name) for marker in markers]
- base_arg_names = [i[0] for i in parsed_markers]
- processed_arg_names = [i[1] for i in parsed_markers]
- processed_arg_values = [i[2] for i in parsed_markers]
-
- return base_arg_names, processed_arg_names, processed_arg_values
-
-
-def _parse_arg_names(arg_names: str | list[str] | tuple[str, ...]) -> tuple[str, ...]:
- """Parse arg_names argument of parametrize decorator.
-
- There are three allowed formats:
-
- 1. comma-separated string representation.
- 2. a tuple of strings.
- 3. a list of strings.
-
- All formats are converted to a tuple of strings.
-
- Parameters
- ----------
- arg_names : Union[str, List[str], Tuple[str, ...]]
- The names of the arguments which are parametrized.
-
- Returns
- -------
- out : Tuple[str, ...]
- The parsed arg_names.
-
- Example
- -------
- >>> _parse_arg_names("i")
- ('i',)
- >>> _parse_arg_names("i, j")
- ('i', 'j')
-
- """
- if isinstance(arg_names, str):
- out = tuple(i.strip() for i in arg_names.split(","))
- elif isinstance(arg_names, (tuple, list)):
- out = tuple(arg_names)
- else:
- raise TypeError(
- "The argument 'arg_names' accepts comma-separated strings, tuples and lists"
- f" of strings. It cannot accept {arg_names} with type {type(arg_names)}."
- )
-
- return out
-
-
-def _parse_arg_values(
- arg_values: Iterable[Sequence[Any] | Any], has_single_arg: bool
-) -> list[tuple[Any, ...]]:
- """Parse the values provided for each argument name.
-
- After processing the values, the return is a list where each value is an iteration
- of the parametrization. Each iteration is a tuple of all parametrized arguments.
-
- Example
- -------
- >>> _parse_arg_values(["a", "b", "c"], has_single_arg=True)
- [('a',), ('b',), ('c',)]
- >>> _parse_arg_values([(0, 0), (0, 1), (1, 0)], has_single_arg=False)
- [(0, 0), (0, 1), (1, 0)]
-
- """
- return [
- tuple(i)
- if isinstance(i, Iterable)
- and not isinstance(i, str)
- and not (isinstance(i, dict) and has_single_arg)
- else (i,)
- for i in arg_values
- ]
-
-
-def _check_if_n_arg_names_matches_n_arg_values(
- arg_names: tuple[str, ...], arg_values: list[tuple[Any, ...]], name: str
-) -> None:
- """Check if the number of argument names matches the number of arguments."""
- n_names = len(arg_names)
- n_values = [len(i) for i in arg_values]
- unique_n_values = tuple(set(n_values))
-
- if not all(i == n_names for i in unique_n_values):
- pretty_arg_values = (
- f"{unique_n_values[0]}"
- if len(unique_n_values) == 1
- else " or ".join(map(str, unique_n_values))
- )
- idx_example = [i == n_names for i in n_values].index(False)
- formatted_example = pprint.pformat(arg_values[idx_example])
- raise ValueError(
- f"Task {name!r} is parametrized with {n_names} 'arg_names', {arg_names}, "
- f"but the number of provided 'arg_values' is {pretty_arg_values}. For "
- f"example, here are the values of parametrization no. {idx_example}:"
- f"\n\n{formatted_example}"
- )
-
-
-def _create_parametrize_ids_components(
- arg_names: tuple[str, ...],
- arg_values: list[tuple[Any, ...]],
- ids: None | (Iterable[None | str | float | int | bool] | Callable[..., Any]),
-) -> list[tuple[str, ...]]:
- """Create the ids for each parametrization.
-
- Parameters
- ----------
- arg_names : Tuple[str, ...]
- The names of the arguments of the parametrized function.
- arg_values : List[Tuple[Any, ...]]
- A list of tuples where each tuple is for one run.
- ids
- The ids associated with one parametrization.
-
- Examples
- --------
- >>> _create_parametrize_ids_components(["i"], [(0,), (1,)], None)
- [('0',), ('1',)]
-
- >>> _create_parametrize_ids_components(["i", "j"], [(0, (0,)), (1, (1,))], None)
- [('0', 'j0'), ('1', 'j1')]
-
- """
- if isinstance(ids, Iterable):
- raw_ids = [(id_,) for id_ in ids]
-
- if len(raw_ids) != len(arg_values):
- raise ValueError("The number of ids must match the number of runs.")
-
- if not all(
- isinstance(id_, (bool, int, float, str)) or id_ is None
- for id_ in itertools.chain.from_iterable(raw_ids)
- ):
- raise ValueError(
- "Ids for parametrization can only be of type bool, float, int, str or "
- "None."
- )
-
- parsed_ids: list[tuple[str, ...]] = [
- (str(id_),) for id_ in itertools.chain.from_iterable(raw_ids)
- ]
-
- else:
- parsed_ids = []
- for i, _arg_values in enumerate(arg_values):
- id_components = tuple(
- arg_value_to_id_component(arg_names[j], arg_value, i, ids)
- for j, arg_value in enumerate(_arg_values)
- )
- parsed_ids.append(id_components)
-
- return parsed_ids
-
-
-@hookimpl
-def pytask_parametrize_kwarg_to_marker(obj: Any, kwargs: dict[str, str]) -> None:
- """Add some parametrized keyword arguments as decorator."""
- if callable(obj):
- for marker_name in ("depends_on", "produces"):
- if marker_name in kwargs:
- mark.__getattr__(marker_name)(kwargs.pop(marker_name))(obj)
-
-
-def _copy_func(func: types.FunctionType) -> types.FunctionType:
- """Create a copy of a function.
-
- Based on https://stackoverflow.com/a/13503277/7523785.
-
- Example
- -------
- >>> def _func(): pass
- >>> copied_func = _copy_func(_func)
- >>> _func is copied_func
- False
-
- """
- new_func = types.FunctionType(
- func.__code__,
- func.__globals__,
- name=func.__name__,
- argdefs=func.__defaults__,
- closure=func.__closure__,
- )
- new_func = functools.update_wrapper(new_func, func)
- new_func.__kwdefaults__ = func.__kwdefaults__
- return new_func
diff --git a/src/_pytask/parametrize_utils.py b/src/_pytask/parametrize_utils.py
deleted file mode 100644
index f259c7b31..000000000
--- a/src/_pytask/parametrize_utils.py
+++ /dev/null
@@ -1,43 +0,0 @@
-from __future__ import annotations
-
-from typing import Any
-from typing import Callable
-
-
-def arg_value_to_id_component(
- arg_name: str, arg_value: Any, i: int, id_func: Callable[..., Any] | None
-) -> str:
- """Create id component from the name and value of the argument.
-
- First, transform the value of the argument with a user-defined function if given.
- Otherwise, take the original value. Then, if the value is a :obj:`bool`,
- :obj:`float`, :obj:`int`, or :obj:`str`, cast it to a string. Otherwise, define a
- placeholder value from the name of the argument and the iteration.
-
- Parameters
- ----------
- arg_name : str
- Name of the parametrized function argument.
- arg_value : Any
- Value of the argument.
- i : int
- The ith iteration of the parametrization.
- id_func : Union[Callable[..., Any], None]
- A callable which maps argument values to :obj:`bool`, :obj:`float`, :obj:`int`,
- or :obj:`str` or anything else. Any object with a different dtype than the first
- will be mapped to an auto-generated id component.
-
- Returns
- -------
- id_component : str
- A part of the final parametrized id.
-
- """
- id_component = id_func(arg_value) if id_func is not None else None
- if isinstance(id_component, (bool, float, int, str)):
- id_component = str(id_component)
- elif isinstance(arg_value, (bool, float, int, str)):
- id_component = str(arg_value)
- else:
- id_component = arg_name + str(i)
- return id_component
diff --git a/src/_pytask/path.py b/src/_pytask/path.py
index 3e7864c93..576f2f798 100644
--- a/src/_pytask/path.py
+++ b/src/_pytask/path.py
@@ -100,7 +100,7 @@ def find_common_ancestor(*paths: str | Path) -> Path:
return common_ancestor
-@functools.lru_cache()
+@functools.lru_cache
def find_case_sensitive_path(path: Path, platform: str) -> Path:
"""Find the case-sensitive path.
diff --git a/src/_pytask/profile.py b/src/_pytask/profile.py
index cd8e71102..5c8a33e57 100644
--- a/src/_pytask/profile.py
+++ b/src/_pytask/profile.py
@@ -19,10 +19,11 @@
from _pytask.config import hookimpl
from _pytask.console import console
from _pytask.console import format_task_id
-from _pytask.database_utils import db
+from _pytask.database_utils import BaseTable
+from _pytask.database_utils import DatabaseSession
from _pytask.exceptions import CollectionError
from _pytask.exceptions import ConfigurationError
-from _pytask.nodes import FilePathNode
+from _pytask.node_protocols import PPathNode
from _pytask.nodes import Task
from _pytask.outcomes import ExitCode
from _pytask.outcomes import TaskOutcome
@@ -30,8 +31,10 @@
from _pytask.report import ExecutionReport
from _pytask.session import Session
from _pytask.traceback import render_exc_info
-from pony import orm
from rich.table import Table
+from sqlalchemy import Column
+from sqlalchemy import Float
+from sqlalchemy import String
if TYPE_CHECKING:
@@ -44,12 +47,14 @@ class _ExportFormats(enum.Enum):
CSV = "csv"
-class Runtime(db.Entity): # type: ignore[name-defined]
+class Runtime(BaseTable):
"""Record of runtimes of tasks."""
- task = orm.PrimaryKey(str)
- date = orm.Required(float)
- duration = orm.Required(float)
+ __tablename__ = "runtime"
+
+ task = Column(String, primary_key=True)
+ date = Column(Float)
+ duration = Column(Float)
@hookimpl(tryfirst=True)
@@ -84,16 +89,18 @@ def pytask_execute_task_process_report(report: ExecutionReport) -> None:
_create_or_update_runtime(task.name, *duration)
-@orm.db_session
def _create_or_update_runtime(task_name: str, start: float, end: float) -> None:
"""Create or update a runtime entry."""
- try:
- runtime = Runtime[task_name] # type: ignore[type-arg, valid-type]
- except orm.ObjectNotFound:
- Runtime(task=task_name, date=start, duration=end - start)
- else:
- for attr, val in (("date", start), ("duration", end - start)):
- setattr(runtime, attr, val)
+ with DatabaseSession() as session:
+ runtime = session.get(Runtime, task_name)
+
+ if not runtime:
+ session.add(Runtime(task=task_name, date=start, duration=end - start))
+ else:
+ for attr, val in (("date", start), ("duration", end - start)):
+ setattr(runtime, attr, val)
+
+ session.commit()
@click.command(cls=ColoredCommand)
@@ -198,10 +205,10 @@ def pytask_profile_add_info_on_task(
profile[name]["Duration (in s)"] = round(duration, 2)
-@orm.db_session
def _collect_runtimes(task_names: list[str]) -> dict[str, float]:
"""Collect runtimes."""
- runtimes = [Runtime.get(task=task_name) for task_name in task_names]
+ with DatabaseSession() as session:
+ runtimes = [session.get(Runtime, task_name) for task_name in task_names]
runtimes = [r for r in runtimes if r is not None]
return {r.task: r.duration for r in runtimes}
@@ -221,7 +228,7 @@ def pytask_profile_add_info_on_task(
sum_bytes = 0
for successor in successors:
node = session.dag.nodes[successor]["node"]
- if isinstance(node, FilePathNode):
+ if isinstance(node, PPathNode):
with suppress(FileNotFoundError):
sum_bytes += node.path.stat().st_size
diff --git a/src/_pytask/report.py b/src/_pytask/report.py
index 0bc0cf03d..7df151fc3 100644
--- a/src/_pytask/report.py
+++ b/src/_pytask/report.py
@@ -15,7 +15,7 @@
if TYPE_CHECKING:
- from _pytask.nodes import MetaNode
+ from _pytask.node_protocols import MetaNode
from _pytask.nodes import Task
diff --git a/src/_pytask/shared.py b/src/_pytask/shared.py
index 4747813d7..6059bd1b2 100644
--- a/src/_pytask/shared.py
+++ b/src/_pytask/shared.py
@@ -10,8 +10,8 @@
import click
import networkx as nx
from _pytask.console import format_task_id
-from _pytask.nodes import FilePathNode
-from _pytask.nodes import MetaNode
+from _pytask.node_protocols import MetaNode
+from _pytask.node_protocols import PPathNode
from _pytask.nodes import Task
from _pytask.path import find_closest_ancestor
from _pytask.path import find_common_ancestor
@@ -67,7 +67,7 @@ def reduce_node_name(node: MetaNode, paths: Sequence[str | Path]) -> str:
path from one path in ``session.config["paths"]`` to the node.
"""
- if isinstance(node, (Task, FilePathNode)):
+ if isinstance(node, (PPathNode, Task)):
ancestor = find_closest_ancestor(node.path, paths)
if ancestor is None:
try:
@@ -75,10 +75,7 @@ def reduce_node_name(node: MetaNode, paths: Sequence[str | Path]) -> str:
except ValueError:
ancestor = node.path.parents[-1]
- if isinstance(node, MetaNode):
- name = relative_to(node.path, ancestor).as_posix()
- else:
- raise TypeError(f"Unknown node {node} with type {type(node)!r}.")
+ name = relative_to(node.path, ancestor).as_posix()
return name
return node.name
diff --git a/src/_pytask/task.py b/src/_pytask/task.py
index d71cb6689..7b806af71 100644
--- a/src/_pytask/task.py
+++ b/src/_pytask/task.py
@@ -5,7 +5,6 @@
from typing import Any
from _pytask.config import hookimpl
-from _pytask.mark_utils import has_mark
from _pytask.report import CollectionReport
from _pytask.session import Session
from _pytask.task_utils import COLLECTED_TASKS
@@ -39,24 +38,11 @@ def pytask_collect_file(
collected_reports = []
for name, function in name_to_function.items():
- session.hook.pytask_parametrize_kwarg_to_marker(
- obj=function,
- kwargs=function.pytask_meta.kwargs, # type: ignore[attr-defined]
+ report = session.hook.pytask_collect_task_protocol(
+ session=session, reports=reports, path=path, name=name, obj=function
)
-
- if has_mark(function, "parametrize"):
- names_and_objects = session.hook.pytask_parametrize_task(
- session=session, name=name, obj=function
- )
- else:
- names_and_objects = [(name, function)]
-
- for name_, obj_ in names_and_objects:
- report = session.hook.pytask_collect_task_protocol(
- session=session, reports=reports, path=path, name=name_, obj=obj_
- )
- if report is not None:
- collected_reports.append(report)
+ if report is not None:
+ collected_reports.append(report)
return collected_reports
return None
diff --git a/src/_pytask/task_utils.py b/src/_pytask/task_utils.py
index e8e6bd4e8..32acd15de 100644
--- a/src/_pytask/task_utils.py
+++ b/src/_pytask/task_utils.py
@@ -7,10 +7,14 @@
from typing import Any
from typing import Callable
+import attrs
from _pytask.mark import Mark
from _pytask.models import CollectionMetadata
-from _pytask.parametrize_utils import arg_value_to_id_component
from _pytask.shared import find_duplicates
+from _pytask.tree_util import PyTree
+
+
+__all__ = ["parse_keyword_arguments_from_signature_defaults"]
COLLECTED_TASKS: dict[Path, list[Callable[..., Any]]] = defaultdict(list)
@@ -28,6 +32,7 @@ def task(
*,
id: str | None = None, # noqa: A002
kwargs: dict[Any, Any] | None = None,
+ produces: PyTree[Any] = None,
) -> Callable[..., None]:
"""Parse inputs of the ``@pytask.mark.task`` decorator.
@@ -40,17 +45,26 @@ def task(
Parameters
----------
- name : str | None
+ name
The name of the task.
- id : str | None
+ id
An id for the task if it is part of a parametrization.
- kwargs : dict[Any, Any] | None
+ kwargs
A dictionary containing keyword arguments which are passed to the task when it
is executed.
+ produces
+ Definition of products to handle returns.
"""
def wrapper(func: Callable[..., Any]) -> None:
+ for arg, arg_name in ((name, "name"), (id, "id")):
+ if not (isinstance(arg, str) or arg is None):
+ raise ValueError(
+ f"Argument {arg_name!r} of @pytask.mark.task must be a str, but it "
+ f"is {arg!r}."
+ )
+
unwrapped = inspect.unwrap(func)
raw_path = inspect.getfile(unwrapped)
@@ -67,12 +81,14 @@ def wrapper(func: Callable[..., Any]) -> None:
unwrapped.pytask_meta.kwargs = parsed_kwargs
unwrapped.pytask_meta.markers.append(Mark("task", (), {}))
unwrapped.pytask_meta.id_ = id
+ unwrapped.pytask_meta.produces = produces
else:
unwrapped.pytask_meta = CollectionMetadata(
name=parsed_name,
kwargs=parsed_kwargs,
markers=[Mark("task", (), {})],
id_=id,
+ produces=produces,
)
COLLECTED_TASKS[path].append(unwrapped)
@@ -125,25 +141,39 @@ def _parse_tasks_with_preliminary_names(
def _parse_task(task: Callable[..., Any]) -> tuple[str, Callable[..., Any]]:
"""Parse a single task."""
- name = task.pytask_meta.name # type: ignore[attr-defined]
- if name is None and task.__name__ == "_":
+ meta = task.pytask_meta # type: ignore[attr-defined]
+
+ if meta.name is None and task.__name__ == "_":
raise ValueError(
"A task function either needs 'name' passed by the ``@pytask.mark.task`` "
"decorator or the function name of the task function must not be '_'."
)
- parsed_name = task.__name__ if name is None else name
+ parsed_name = task.__name__ if meta.name is None else meta.name
+ parsed_kwargs = _parse_task_kwargs(meta.kwargs)
- signature_kwargs = _parse_keyword_arguments_from_signature_defaults(task)
- task.pytask_meta.kwargs = { # type: ignore[attr-defined]
- **task.pytask_meta.kwargs, # type: ignore[attr-defined]
- **signature_kwargs,
- }
+ signature_kwargs = parse_keyword_arguments_from_signature_defaults(task)
+ meta.kwargs = {**signature_kwargs, **parsed_kwargs}
return parsed_name, task
-def _parse_keyword_arguments_from_signature_defaults(
+def _parse_task_kwargs(kwargs: Any) -> dict[str, Any]:
+ """Parse task kwargs."""
+ if isinstance(kwargs, dict):
+ return kwargs
+ # Handle namedtuples.
+ if callable(getattr(kwargs, "_asdict", None)):
+ return kwargs._asdict()
+ if attrs.has(type(kwargs)):
+ return attrs.asdict(kwargs)
+ raise ValueError(
+ "'@pytask.mark.task(kwargs=...) needs to be a dictionary, namedtuple or an "
+ "instance of an attrs class."
+ )
+
+
+def parse_keyword_arguments_from_signature_defaults(
task: Callable[..., Any]
) -> dict[str, Any]:
"""Parse keyword arguments from signature defaults."""
@@ -169,7 +199,7 @@ def _generate_ids_for_tasks(
id_ = f"{name}[{i}]"
else:
stringified_args = [
- arg_value_to_id_component(
+ _arg_value_to_id_component(
arg_name=parameter,
arg_value=task.pytask_meta.kwargs.get( # type: ignore[attr-defined]
parameter
@@ -183,3 +213,42 @@ def _generate_ids_for_tasks(
id_ = f"{name}[{id_}]"
out[id_] = task
return out
+
+
+def _arg_value_to_id_component(
+ arg_name: str, arg_value: Any, i: int, id_func: Callable[..., Any] | None
+) -> str:
+ """Create id component from the name and value of the argument.
+
+ First, transform the value of the argument with a user-defined function if given.
+ Otherwise, take the original value. Then, if the value is a :obj:`bool`,
+ :obj:`float`, :obj:`int`, or :obj:`str`, cast it to a string. Otherwise, define a
+ placeholder value from the name of the argument and the iteration.
+
+ Parameters
+ ----------
+ arg_name : str
+ Name of the parametrized function argument.
+ arg_value : Any
+ Value of the argument.
+ i : int
+ The ith iteration of the parametrization.
+ id_func : Union[Callable[..., Any], None]
+ A callable which maps argument values to :obj:`bool`, :obj:`float`, :obj:`int`,
+ or :obj:`str` or anything else. Any object with a different dtype than the first
+ will be mapped to an auto-generated id component.
+
+ Returns
+ -------
+ id_component : str
+ A part of the final parametrized id.
+
+ """
+ id_component = id_func(arg_value) if id_func is not None else None
+ if isinstance(id_component, (bool, float, int, str)):
+ id_component = str(id_component)
+ elif isinstance(arg_value, (bool, float, int, str)):
+ id_component = str(arg_value)
+ else:
+ id_component = arg_name + str(i)
+ return id_component
diff --git a/src/_pytask/traceback.py b/src/_pytask/traceback.py
index 38fdeb88a..88c23aef8 100644
--- a/src/_pytask/traceback.py
+++ b/src/_pytask/traceback.py
@@ -10,6 +10,7 @@
import _pytask
import pluggy
+from _pytask.tree_util import TREE_UTIL_LIB_DIRECTORY
from rich.traceback import Traceback
@@ -89,7 +90,10 @@ def _is_internal_or_hidden_traceback_frame(
return True
path = Path(frame.tb_frame.f_code.co_filename)
- return any(root in path.parents for root in (_PLUGGY_DIRECTORY, _PYTASK_DIRECTORY))
+ return any(
+ root in path.parents
+ for root in (_PLUGGY_DIRECTORY, TREE_UTIL_LIB_DIRECTORY, _PYTASK_DIRECTORY)
+ )
def _filter_internal_traceback_frames(
diff --git a/src/_pytask/tree_util.py b/src/_pytask/tree_util.py
new file mode 100644
index 000000000..80edcc71a
--- /dev/null
+++ b/src/_pytask/tree_util.py
@@ -0,0 +1,36 @@
+"""Contains code for tree utilities."""
+from __future__ import annotations
+
+import functools
+from pathlib import Path
+
+import optree
+from optree import PyTree
+from optree import tree_leaves as _optree_tree_leaves
+from optree import tree_map as _optree_tree_map
+from optree import tree_map_with_path as _optree_tree_map_with_path
+from optree import tree_structure as _optree_tree_structure
+
+
+__all__ = [
+ "tree_leaves",
+ "tree_map",
+ "tree_map_with_path",
+ "tree_structure",
+ "PyTree",
+ "TREE_UTIL_LIB_DIRECTORY",
+]
+
+TREE_UTIL_LIB_DIRECTORY = Path(optree.__file__).parent
+
+
+tree_leaves = functools.partial(
+ _optree_tree_leaves, none_is_leaf=True, namespace="pytask"
+)
+tree_map = functools.partial(_optree_tree_map, none_is_leaf=True, namespace="pytask")
+tree_map_with_path = functools.partial(
+ _optree_tree_map_with_path, none_is_leaf=True, namespace="pytask"
+)
+tree_structure = functools.partial(
+ _optree_tree_structure, none_is_leaf=True, namespace="pytask"
+)
diff --git a/src/pytask/__init__.py b/src/pytask/__init__.py
index 7f1407f3d..d626d4dd3 100644
--- a/src/pytask/__init__.py
+++ b/src/pytask/__init__.py
@@ -13,7 +13,10 @@
from _pytask.compat import import_optional_dependency
from _pytask.config import hookimpl
from _pytask.console import console
-from _pytask.database_utils import db
+from _pytask.database_utils import BaseTable
+from _pytask.database_utils import create_database
+from _pytask.database_utils import DatabaseSession
+from _pytask.database_utils import State
from _pytask.exceptions import CollectionError
from _pytask.exceptions import ConfigurationError
from _pytask.exceptions import ExecutionError
@@ -32,8 +35,11 @@
from _pytask.mark_utils import remove_marks
from _pytask.mark_utils import set_marks
from _pytask.models import CollectionMetadata
-from _pytask.nodes import FilePathNode
-from _pytask.nodes import MetaNode
+from _pytask.models import NodeInfo
+from _pytask.node_protocols import MetaNode
+from _pytask.nodes import PathNode
+from _pytask.nodes import Product
+from _pytask.nodes import PythonNode
from _pytask.nodes import Task
from _pytask.outcomes import CollectionOutcome
from _pytask.outcomes import count_outcomes
@@ -44,6 +50,7 @@
from _pytask.outcomes import SkippedAncestorFailed
from _pytask.outcomes import SkippedUnchanged
from _pytask.outcomes import TaskOutcome
+from _pytask.profile import Runtime
from _pytask.report import CollectionReport
from _pytask.report import DagReport
from _pytask.report import ExecutionReport
@@ -62,6 +69,7 @@
__all__ = [
+ "BaseTable",
"CollectionError",
"CollectionMetadata",
"CollectionOutcome",
@@ -69,26 +77,32 @@
"ColoredCommand",
"ColoredGroup",
"ConfigurationError",
+ "DagReport",
+ "DatabaseSession",
"EnumChoice",
"ExecutionError",
"ExecutionReport",
"Exit",
"ExitCode",
- "FilePathNode",
+ "PathNode",
"Mark",
"MarkDecorator",
"MarkGenerator",
"MetaNode",
+ "NodeInfo",
"NodeNotCollectedError",
"NodeNotFoundError",
"Persisted",
+ "Product",
"PytaskError",
+ "PythonNode",
"ResolvingDependenciesError",
- "DagReport",
+ "Runtime",
"Session",
"Skipped",
"SkippedAncestorFailed",
"SkippedUnchanged",
+ "State",
"Task",
"TaskOutcome",
"WarningReport",
@@ -98,7 +112,7 @@
"cli",
"console",
"count_outcomes",
- "db",
+ "create_database",
"depends_on",
"format_exception_without_traceback",
"get_all_marks",
diff --git a/tests/test_clean.py b/tests/test_clean.py
index c29a2a074..62a7e6220 100644
--- a/tests/test_clean.py
+++ b/tests/test_clean.py
@@ -11,18 +11,29 @@
from pytask import ExitCode
-@pytest.fixture()
-def project(tmp_path):
- """Create a sample project to be cleaned."""
- source = """
- import pytask
+_PROJECT_TASK = """
+import pytask
- @pytask.mark.depends_on("in.txt")
- @pytask.mark.produces("out.txt")
- def task_write_text(produces):
- produces.write_text("a")
- """
- tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source))
+@pytask.mark.depends_on("in.txt")
+@pytask.mark.produces("out.txt")
+def task_write_text(depends_on, produces):
+ produces.write_text("a")
+"""
+
+
+_PROJECT_TASK_NEW_INTERFACE = """
+import pytask
+from pathlib import Path
+
+def task_write_text(in_path=Path("in.txt"), produces=Path("out.txt")):
+ produces.write_text("a")
+"""
+
+
+@pytest.fixture(params=[_PROJECT_TASK, _PROJECT_TASK_NEW_INTERFACE])
+def project(request, tmp_path):
+ """Create a sample project to be cleaned."""
+ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(request.param))
tmp_path.joinpath("in.txt").touch()
tmp_path.joinpath("to_be_deleted_file_1.txt").touch()
@@ -32,18 +43,29 @@ def task_write_text(produces):
return tmp_path
-@pytest.fixture()
-def git_project(tmp_path):
- """Create a sample project to be cleaned."""
- source = """
- import pytask
+_GIT_PROJECT_TASK = """
+import pytask
- @pytask.mark.depends_on("in_tracked.txt")
- @pytask.mark.produces("out.txt")
- def task_write_text(produces):
- produces.write_text("a")
- """
- tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source))
+@pytask.mark.depends_on("in_tracked.txt")
+@pytask.mark.produces("out.txt")
+def task_write_text(depends_on, produces):
+ produces.write_text("a")
+"""
+
+
+_GIT_PROJECT_TASK_NEW_INTERFACE = """
+import pytask
+from pathlib import Path
+
+def task_write_text(in_path=Path("in_tracked.txt"), produces=Path("out.txt")):
+ produces.write_text("a")
+"""
+
+
+@pytest.fixture(params=[_GIT_PROJECT_TASK, _GIT_PROJECT_TASK_NEW_INTERFACE])
+def git_project(request, tmp_path):
+ """Create a sample project to be cleaned."""
+ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(request.param))
tmp_path.joinpath("in_tracked.txt").touch()
tmp_path.joinpath("tracked.txt").touch()
@@ -56,11 +78,29 @@ def task_write_text(produces):
return tmp_path
+@pytest.mark.end_to_end()
+def test_clean_database_ignored(project, runner):
+ cwd = Path.cwd()
+ os.chdir(project)
+ result = runner.invoke(cli, ["build"])
+ assert result.exit_code == ExitCode.OK
+ result = runner.invoke(cli, ["clean"])
+ assert result.exit_code == ExitCode.OK
+ os.chdir(cwd)
+
+ assert result.exit_code == ExitCode.OK
+ text_without_linebreaks = result.output.replace("\n", "")
+ assert "to_be_deleted_file_1.txt" in text_without_linebreaks
+ assert "to_be_deleted_file_2.txt" in text_without_linebreaks
+ assert ".pytask.sqlite3" not in text_without_linebreaks
+
+
@pytest.mark.end_to_end()
def test_clean_with_auto_collect(project, runner):
cwd = Path.cwd()
os.chdir(project)
result = runner.invoke(cli, ["clean"])
+ assert result.exit_code == ExitCode.OK
os.chdir(cwd)
assert result.exit_code == ExitCode.OK
diff --git a/tests/test_click.py b/tests/test_click.py
index 05b6504c4..2f0055242 100644
--- a/tests/test_click.py
+++ b/tests/test_click.py
@@ -12,20 +12,13 @@
def test_choices_are_displayed_in_help_page(runner):
result = runner.invoke(cli, ["build", "--help"])
assert "[no|stdout|stderr|all]" in result.output
- # Test that a long meta var is folded.
- assert "[sqlite|postgres|mysql|oracle|cockroach]" not in result.output
- assert "sqlite" in result.output
- assert "postgres" in result.output
- assert "mysql" in result.output
- assert "oracle" in result.output
- assert "cockroach" not in result.output
assert "[fd|no|sys|tee-sys]" in result.output
@pytest.mark.end_to_end()
def test_defaults_are_displayed(runner):
result = runner.invoke(cli, ["build", "--help"])
- assert "[default: True]" in result.output
+ assert "[default: all]" in result.output
@pytest.mark.unit()
diff --git a/tests/test_collect.py b/tests/test_collect.py
index 9a10831a5..198328586 100644
--- a/tests/test_collect.py
+++ b/tests/test_collect.py
@@ -3,13 +3,13 @@
import sys
import textwrap
import warnings
-from contextlib import ExitStack as does_not_raise # noqa: N813
from pathlib import Path
import pytest
from _pytask.collect import _find_shortest_uniquely_identifiable_name_for_tasks
from _pytask.collect import pytask_collect_node
from _pytask.exceptions import NodeNotCollectedError
+from _pytask.models import NodeInfo
from pytask import cli
from pytask import CollectionOutcome
from pytask import ExitCode
@@ -38,52 +38,33 @@ def task_write_text(depends_on, produces):
@pytest.mark.end_to_end()
-def test_collect_tasks_from_modules_with_the_same_name(tmp_path):
- """We need to check that task modules can have the same name. See #373 and #374."""
- tmp_path.joinpath("a").mkdir()
- tmp_path.joinpath("b").mkdir()
- tmp_path.joinpath("a", "task_module.py").write_text("def task_a(): pass")
- tmp_path.joinpath("b", "task_module.py").write_text("def task_a(): pass")
- session = main({"paths": tmp_path})
- assert len(session.collection_reports) == 2
- assert all(
- report.outcome == CollectionOutcome.SUCCESS
- for report in session.collection_reports
- )
- assert {
- report.node.function.__module__ for report in session.collection_reports
- } == {"a.task_module", "b.task_module"}
-
-
-@pytest.mark.end_to_end()
-def test_collect_module_name(tmp_path):
- """We need to add a task module to the sys.modules. See #373 and #374."""
+def test_collect_depends_on_that_is_not_str_or_path(tmp_path):
+ """If a node cannot be parsed because unknown type, raise an error."""
source = """
- # without this import, everything works fine
- from __future__ import annotations
-
- import dataclasses
-
- @dataclasses.dataclass
- class Data:
- x: int
+ import pytask
- def task_my_task():
+ @pytask.mark.depends_on(True)
+ def task_with_non_path_dependency():
pass
"""
tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source))
+
session = main({"paths": tmp_path})
- outcome = session.collection_reports[0].outcome
- assert outcome == CollectionOutcome.SUCCESS
+
+ 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)
+ assert "'@pytask.mark.depends_on'" in str(exc_info[1])
@pytest.mark.end_to_end()
-def test_collect_filepathnode_with_unknown_type(tmp_path):
+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.depends_on(True)
+ @pytask.mark.produces(True)
def task_with_non_path_dependency():
pass
"""
@@ -95,6 +76,7 @@ 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)
+ assert "'@pytask.mark.depends_on'" in str(exc_info[1])
@pytest.mark.end_to_end()
@@ -129,7 +111,7 @@ def task_1(depends_on, produces):
@pytest.mark.end_to_end()
@pytest.mark.parametrize("path_extension", ["", "task_module.py"])
-def test_collect_same_test_different_ways(tmp_path, path_extension):
+def test_collect_same_task_different_ways(tmp_path, path_extension):
tmp_path.joinpath("task_module.py").write_text("def task_passes(): pass")
session = main({"paths": tmp_path.joinpath(path_extension)})
@@ -167,33 +149,30 @@ def test_collect_files_w_custom_file_name_pattern(
@pytest.mark.unit()
@pytest.mark.parametrize(
- ("session", "path", "node", "expectation", "expected"),
+ ("session", "path", "node_info", "expected"),
[
pytest.param(
Session({"check_casing_of_paths": False}, None),
Path(),
- Path.cwd() / "text.txt",
- does_not_raise(),
+ NodeInfo("", (), Path.cwd() / "text.txt"),
Path.cwd() / "text.txt",
id="test with absolute string path",
),
pytest.param(
Session({"check_casing_of_paths": False}, None),
Path(),
- 1,
- does_not_raise(),
- None,
- id="test cannot collect node",
+ NodeInfo("", (), 1),
+ "1",
+ id="test with python node",
),
],
)
-def test_pytask_collect_node(session, path, node, expectation, expected):
- with expectation:
- result = pytask_collect_node(session, path, node)
- if result is None:
- assert result is expected
- else:
- assert str(result.path) == str(expected)
+def test_pytask_collect_node(session, path, node_info, expected):
+ result = pytask_collect_node(session, path, node_info)
+ if result is None:
+ assert result is expected
+ else:
+ assert str(result.load()) == str(expected)
@pytest.mark.unit()
@@ -208,7 +187,7 @@ def test_pytask_collect_node_raises_error_if_path_is_not_correctly_cased(tmp_pat
collected_node = tmp_path / "TeXt.TxT"
with pytest.raises(Exception, match="The provided path of"):
- pytask_collect_node(session, task_path, collected_node)
+ pytask_collect_node(session, task_path, NodeInfo("", (), collected_node))
@pytest.mark.unit()
@@ -220,12 +199,14 @@ def test_pytask_collect_node_does_not_raise_error_if_path_is_not_normalized(
task_path = tmp_path / "task_example.py"
real_node = tmp_path / "text.txt"
- collected_node = f"../{tmp_path.name}/text.txt"
+ collected_node = Path("..", tmp_path.name, "text.txt")
if is_absolute:
collected_node = tmp_path / collected_node
with warnings.catch_warnings(record=True) as record:
- result = pytask_collect_node(session, task_path, collected_node)
+ result = pytask_collect_node(
+ session, task_path, NodeInfo("", (), collected_node)
+ )
assert not record
assert str(result.path) == str(real_node)
@@ -270,3 +251,195 @@ def test_find_shortest_uniquely_identifiable_names_for_tasks(tmp_path):
result = _find_shortest_uniquely_identifiable_name_for_tasks(tasks)
assert result == expected
+
+
+@pytest.mark.end_to_end()
+def test_collect_dependencies_from_args_if_depends_on_is_missing(tmp_path):
+ source = """
+ from pathlib import Path
+
+ def task_example(path_in = Path("in.txt"), produces = Path("out.txt")):
+ produces.write_text(path_in.read_text())
+ """
+ tmp_path.joinpath("task_example.py").write_text(textwrap.dedent(source))
+ tmp_path.joinpath("in.txt").write_text("hello")
+
+ session = main({"paths": tmp_path})
+
+ assert session.exit_code == ExitCode.OK
+ assert len(session.tasks) == 1
+ assert session.tasks[0].depends_on["path_in"].path == tmp_path.joinpath("in.txt")
+
+
+@pytest.mark.end_to_end()
+def test_collect_tasks_from_modules_with_the_same_name(tmp_path):
+ """We need to check that task modules can have the same name. See #373 and #374."""
+ tmp_path.joinpath("a").mkdir()
+ tmp_path.joinpath("b").mkdir()
+ tmp_path.joinpath("a", "task_module.py").write_text("def task_a(): pass")
+ tmp_path.joinpath("b", "task_module.py").write_text("def task_a(): pass")
+ session = main({"paths": tmp_path})
+ assert len(session.collection_reports) == 2
+ assert all(
+ report.outcome == CollectionOutcome.SUCCESS
+ for report in session.collection_reports
+ )
+ assert {
+ report.node.function.__module__ for report in session.collection_reports
+ } == {"a.task_module", "b.task_module"}
+
+
+@pytest.mark.end_to_end()
+def test_collect_module_name(tmp_path):
+ """We need to add a task module to the sys.modules. See #373 and #374."""
+ source = """
+ # without this import, everything works fine
+ from __future__ import annotations
+
+ import dataclasses
+
+ @dataclasses.dataclass
+ class Data:
+ x: int
+
+ def task_my_task():
+ pass
+ """
+ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source))
+ session = main({"paths": tmp_path})
+ outcome = session.collection_reports[0].outcome
+ assert outcome == CollectionOutcome.SUCCESS
+
+
+@pytest.mark.end_to_end()
+def test_collect_string_product_with_task_decorator(runner, tmp_path):
+ source = """
+ import pytask
+
+ @pytask.mark.task
+ 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()
+
+
+@pytest.mark.end_to_end()
+def test_collect_string_product_as_function_default(runner, tmp_path):
+ source = """
+ 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()
+
+
+@pytest.mark.end_to_end()
+def test_collect_string_product_raises_error_with_annotation(runner, tmp_path):
+ source = """
+ from pytask import Product
+ from typing_extensions import Annotated
+
+ def task_write_text(out: Annotated[str, Product] = "out.txt") -> None:
+ out.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.COLLECTION_FAILED
+ assert "If you declare products with 'Annotated[..., Product]'" in result.output
+
+
+@pytest.mark.end_to_end()
+def test_product_cannot_mix_different_product_types(tmp_path):
+ 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 = main({"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
+ assert "The task uses multiple ways" in str(report.exc_info[1])
+
+
+@pytest.mark.end_to_end()
+def test_depends_on_cannot_mix_different_definitions(tmp_path):
+ 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 = main({"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
+ assert "The task uses multiple" in str(report.exc_info[1])
+
+
+@pytest.mark.end_to_end()
+def test_deprecation_warning_for_strings_in_depends_on(runner, tmp_path):
+ source = """
+ import pytask
+ from pathlib import Path
+
+ @pytask.mark.depends_on("in.txt")
+ @pytask.mark.produces("out.txt")
+ 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
+ assert "Using strings to specify a dependency" in result.output
+ assert "Using strings to specify a product" in result.output
+
+
+@pytest.mark.end_to_end()
+def test_setting_name_for_path_node_via_annotation(tmp_path):
+ source = """
+ from pathlib import Path
+ from typing_extensions import Annotated
+ from pytask import Product, PathNode
+ from typing import Any
+
+ def task_example(
+ path: Annotated[Path, Product, PathNode(name="product")] = Path("out.txt"),
+ ) -> None:
+ path.write_text("text")
+ """
+ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source))
+
+ session = main({"paths": [tmp_path]})
+ assert session.exit_code == ExitCode.OK
+ product = session.tasks[0].produces["path"]
+ assert product.name == "product"
diff --git a/tests/test_collect_command.py b/tests/test_collect_command.py
index 609594f98..41e228499 100644
--- a/tests/test_collect_command.py
+++ b/tests/test_collect_command.py
@@ -1,16 +1,17 @@
from __future__ import annotations
import os
+import pickle
import textwrap
from pathlib import Path
import pytest
from _pytask.collect_command import _find_common_ancestor_of_all_nodes
from _pytask.collect_command import _print_collected_tasks
+from _pytask.nodes import PathNode
from attrs import define
from pytask import cli
from pytask import ExitCode
-from pytask import MetaNode
from pytask import Task
@@ -50,6 +51,42 @@ def task_example():
assert "out.txt>" in captured
+@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
+ """
+ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source))
+ tmp_path.joinpath("in.txt").touch()
+
+ result = runner.invoke(cli, ["collect", tmp_path.as_posix()])
+
+ assert result.exit_code == ExitCode.OK
+ captured = result.output.replace("\n", "").replace(" ", "")
+ assert "" in captured
+ assert "" in captured
+
+ result = runner.invoke(cli, ["collect", tmp_path.as_posix(), "--nodes"])
+
+ assert result.exit_code == ExitCode.OK
+ captured = result.output.replace("\n", "").replace(" ", "")
+ assert "" in captured
+ assert "" in captured
+ assert "" in captured
+ assert "" in captured
+ assert "arg" in captured
+
+
@pytest.mark.end_to_end()
def test_collect_task_in_root_dir(runner, tmp_path):
source = """
@@ -81,10 +118,12 @@ def test_collect_parametrized_tasks(runner, tmp_path):
source = """
import pytask
- @pytask.mark.depends_on("in.txt")
- @pytask.mark.parametrize("arg, produces", [(0, "out_0.txt"), (1, "out_1.txt")])
- def task_example(arg):
- pass
+ 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):
+ pass
"""
tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source))
tmp_path.joinpath("in.txt").touch()
@@ -304,14 +343,14 @@ def task_example_2():
@define
-class MetaNode(MetaNode):
+class Node:
path: Path
def state(self):
...
-def function():
+def function(depends_on, produces): # noqa: ARG001
...
@@ -323,8 +362,8 @@ def test_print_collected_tasks_without_nodes(capsys):
base_name="function",
path=Path("task_path.py"),
function=function,
- depends_on={0: MetaNode("in.txt")},
- produces={0: MetaNode("out.txt")},
+ depends_on={0: Node("in.txt")},
+ produces={0: Node("out.txt")},
)
]
}
@@ -346,8 +385,10 @@ def test_print_collected_tasks_with_nodes(capsys):
base_name="function",
path=Path("task_path.py"),
function=function,
- depends_on={0: MetaNode("in.txt")},
- produces={0: MetaNode("out.txt")},
+ depends_on={
+ "depends_on": PathNode(name="in.txt", value=Path("in.txt"))
+ },
+ produces={0: PathNode(name="out.txt", value=Path("out.txt"))},
)
]
}
@@ -370,9 +411,13 @@ def test_find_common_ancestor_of_all_nodes(show_nodes, expected_add):
base_name="function",
path=Path.cwd() / "src" / "task_path.py",
function=function,
- depends_on={0: MetaNode(Path.cwd() / "src" / "in.txt")},
+ depends_on={
+ "depends_on": PathNode.from_path(Path.cwd() / "src" / "in.txt")
+ },
produces={
- 0: MetaNode(Path.cwd().joinpath("..", "bld", "out.txt").resolve())
+ 0: PathNode.from_path(
+ Path.cwd().joinpath("..", "bld", "out.txt").resolve()
+ )
},
)
]
@@ -391,3 +436,177 @@ def test_task_name_is_shortened(runner, tmp_path):
assert result.exit_code == ExitCode.OK
assert "task_example.py::task_example" in result.output
assert "a/b/task_example.py::task_example" not in result.output
+
+
+@pytest.mark.end_to_end()
+def test_python_node_is_collected(runner, tmp_path):
+ source = """
+ from pytask import Product
+ from typing_extensions import Annotated
+ from pathlib import Path
+
+ def task_example(
+ dependency: int = 1, path: Annotated[Path, Product] = Path("out.txt")
+ ):
+ ...
+ """
+ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source))
+ result = runner.invoke(cli, ["collect", tmp_path.as_posix(), "--nodes"])
+
+ assert result.exit_code == ExitCode.OK
+ captured = result.output.replace("\n", "").replace(" ", "")
+ assert "" in captured
+ assert "" in captured
+ assert "" in result.output
+ assert "Product" in captured
+
+
+@pytest.mark.end_to_end()
+def test_none_is_a_python_node(runner, tmp_path):
+ source = """
+ from pytask import Product
+ from typing_extensions import Annotated
+ from pathlib import Path
+
+ def task_example(
+ dependency = None, path: Annotated[Path, Product] = Path("out.txt")
+ ):
+ ...
+ """
+ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source))
+ result = runner.invoke(cli, ["collect", tmp_path.as_posix(), "--nodes"])
+
+ assert result.exit_code == ExitCode.OK
+ captured = result.output.replace("\n", "").replace(" ", "")
+ assert "" in captured
+ assert "" in captured
+ assert "" in result.output
+ assert "Product" in captured
+
+
+@pytest.mark.end_to_end()
+def test_python_nodes_are_aggregated_into_one(runner, tmp_path):
+ source = """
+ from pytask import Product
+ from typing_extensions import Annotated
+ from pathlib import Path
+
+ def task_example(
+ nested = {"a": (1, 2), 2: {"b": None}},
+ path: Annotated[Path, Product] = Path("out.txt")
+ ):
+ ...
+ """
+ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source))
+ result = runner.invoke(cli, ["collect", tmp_path.as_posix(), "--nodes"])
+
+ assert result.exit_code == ExitCode.OK
+ captured = result.output.replace("\n", "").replace(" ", "")
+ assert "" in captured
+ assert "" in captured
+ assert "" in result.output
+ assert "Product" in captured
+
+
+def test_node_protocol_for_custom_nodes(runner, tmp_path):
+ source = """
+ from typing_extensions import Annotated
+ from pytask import Product
+ from attrs import define
+ from pathlib import Path
+
+ @define
+ class CustomNode:
+ name: str
+ value: str
+
+ def state(self):
+ return self.value
+
+ def load(self): ...
+ def save(self, value): ...
+ def from_annot(self, value): ...
+
+ def task_example(
+ data = CustomNode("custom", "text"),
+ out: Annotated[Path, Product] = Path("out.txt"),
+ ) -> None:
+ out.write_text(data)
+ """
+ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source))
+
+ result = runner.invoke(cli, ["collect", "--nodes", tmp_path.as_posix()])
+ assert result.exit_code == ExitCode.OK
+ assert "" in result.output
+
+
+def test_node_protocol_for_custom_nodes_with_paths(runner, tmp_path):
+ source = """
+ from typing_extensions import Annotated
+ from pytask import Product
+ from pathlib import Path
+ from attrs import define
+ import pickle
+
+ @define
+ class PickleFile:
+ name: str
+ path: Path
+ value: Path
+
+ def state(self):
+ return str(self.path.stat().st_mtime)
+
+ def load(self):
+ with self.path.open("rb") as f:
+ out = pickle.load(f)
+ return out
+
+ def save(self, value):
+ with self.path.open("wb") as f:
+ pickle.dump(value, f)
+
+ def from_annot(self, value): ...
+
+
+ _PATH = Path(__file__).parent.joinpath("in.pkl")
+
+ def task_example(
+ data = PickleFile(_PATH.as_posix(), _PATH, _PATH),
+ out: Annotated[Path, Product] = Path("out.txt"),
+ ) -> None:
+ out.write_text(data)
+ """
+ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source))
+ tmp_path.joinpath("in.pkl").write_bytes(pickle.dumps("text"))
+
+ result = runner.invoke(cli, ["collect", "--nodes", tmp_path.as_posix()])
+ assert result.exit_code == ExitCode.OK
+ assert "in.pkl" in result.output
+
+
+@pytest.mark.end_to_end()
+def test_setting_name_for_python_node_via_annotation(runner, tmp_path):
+ source = """
+ from pathlib import Path
+ from typing_extensions import Annotated
+ from pytask import Product, PythonNode
+ from typing import Any
+
+ def task_example(
+ input: Annotated[str, PythonNode(name="node-name")] = "text",
+ path: Annotated[Path, Product] = Path("out.txt"),
+ ) -> None:
+ path.write_text(input)
+ """
+ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source))
+
+ result = runner.invoke(cli, ["collect", "--nodes", tmp_path.as_posix()])
+ assert result.exit_code == ExitCode.OK
+ assert "node-name" in result.output
diff --git a/tests/test_collect_utils.py b/tests/test_collect_utils.py
index 89b995e0e..e4dbc4c10 100644
--- a/tests/test_collect_utils.py
+++ b/tests/test_collect_utils.py
@@ -9,10 +9,13 @@
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:"
@@ -159,3 +162,12 @@ def task_example(): # pragma: no cover
parser = depends_on if decorator.name == "depends_on" else produces
with pytest.raises(TypeError):
list(_extract_nodes_from_function_markers(task_example, parser))
+
+
+@pytest.mark.unit()
+def test_find_args_with_product_annotation():
+ def func(a: Annotated[int, Product], b: float, c, d: Annotated[int, float]):
+ return a, b, c, d
+
+ result = _find_args_with_product_annotation(func)
+ assert result == ["a"]
diff --git a/tests/test_compat.py b/tests/test_compat.py
index 394c67980..323087f0b 100644
--- a/tests/test_compat.py
+++ b/tests/test_compat.py
@@ -91,9 +91,9 @@ def test_import_optional():
@pytest.mark.unit()
-def test_pony_version_fallback():
- pytest.importorskip("pony")
- import_optional_dependency("pony")
+def test_sqlalchemy_version_fallback():
+ pytest.importorskip("sqlalchemy")
+ import_optional_dependency("sqlalchemy")
@pytest.mark.unit()
diff --git a/tests/test_console.py b/tests/test_console.py
index 92cb4fc99..bd0b5eca6 100644
--- a/tests/test_console.py
+++ b/tests/test_console.py
@@ -127,14 +127,7 @@ def test_render_to_string(color_system, text, strip_styles, expected):
None,
Text(
_THIS_FILE.as_posix() + "::task_a",
- spans=[
- Span(0, len(_THIS_FILE.as_posix()) + 2, "dim"),
- Span(
- len(_THIS_FILE.as_posix()) + 2,
- len(_THIS_FILE.as_posix()) + 2 + 6,
- Style(),
- ),
- ],
+ spans=[Span(0, len(_THIS_FILE.as_posix()) + 2, "dim")],
),
id="format full id",
),
@@ -146,7 +139,7 @@ def test_render_to_string(color_system, text, strip_styles, expected):
None,
Text(
"test_console.py::task_a",
- spans=[Span(0, 17, "dim"), Span(17, 23, Style())],
+ spans=[Span(0, 17, "dim")],
),
id="format short id",
),
@@ -158,7 +151,7 @@ def test_render_to_string(color_system, text, strip_styles, expected):
_THIS_FILE.parent,
Text(
"tests/test_console.py::task_a",
- spans=[Span(0, 23, "dim"), Span(23, 29, Style())],
+ spans=[Span(0, 23, "dim")],
),
id="format relative to id",
),
diff --git a/tests/test_dag.py b/tests/test_dag.py
index 017374eba..09259abc8 100644
--- a/tests/test_dag.py
+++ b/tests/test_dag.py
@@ -3,7 +3,6 @@
import textwrap
from contextlib import ExitStack as does_not_raise # noqa: N813
from pathlib import Path
-from typing import Any
import networkx as nx
import pytest
@@ -14,18 +13,14 @@
from attrs import define
from pytask import cli
from pytask import ExitCode
-from pytask import FilePathNode
+from pytask import PathNode
from pytask import Task
@define
-class Node(FilePathNode):
+class Node(PathNode):
"""See https://github.com/python-attrs/attrs/issues/293 for property hack."""
- name: str
- value: Any
- path: Path
-
def state(self):
if "missing" in self.name:
raise NodeNotFoundError
diff --git a/tests/test_database.py b/tests/test_database.py
index a11852551..db46e0e4a 100644
--- a/tests/test_database.py
+++ b/tests/test_database.py
@@ -4,10 +4,11 @@
import pytest
from _pytask.database_utils import create_database
+from _pytask.database_utils import DatabaseSession
from _pytask.database_utils import State
-from pony import orm
from pytask import cli
from pytask import ExitCode
+from sqlalchemy.engine import make_url
@pytest.mark.end_to_end()
@@ -18,7 +19,7 @@ def test_existence_of_hashes_in_db(tmp_path, runner):
@pytask.mark.depends_on("in.txt")
@pytask.mark.produces("out.txt")
- def task_write(produces):
+ def task_write(depends_on, produces):
produces.touch()
"""
task_path = tmp_path.joinpath("task_module.py")
@@ -30,14 +31,11 @@ def task_write(produces):
assert result.exit_code == ExitCode.OK
- with orm.db_session:
- create_database(
- "sqlite",
- tmp_path.joinpath(".pytask.sqlite3").as_posix(),
- create_db=True,
- create_tables=False,
- )
+ create_database(
+ make_url("sqlite:///" + tmp_path.joinpath(".pytask.sqlite3").as_posix())
+ )
+ with DatabaseSession() as session:
task_id = task_path.as_posix() + "::task_write"
out_path = tmp_path.joinpath("out.txt")
@@ -46,26 +44,29 @@ def task_write(produces):
(in_path.as_posix(), in_path),
(out_path.as_posix(), out_path),
):
- modification_time = State[task_id, id_].modification_time
+ modification_time = session.get(State, (task_id, id_)).modification_time
assert float(modification_time) == path.stat().st_mtime
@pytest.mark.end_to_end()
def test_rename_database_w_config(tmp_path, runner):
"""Modification dates of input and output files are stored in database."""
+ path_to_db = tmp_path.joinpath(".db.sqlite")
tmp_path.joinpath("pyproject.toml").write_text(
- "[tool.pytask.ini_options]\ndatabase_filename='.db.sqlite3'"
+ "[tool.pytask.ini_options]\ndatabase_url='sqlite:///.db.sqlite'"
)
result = runner.invoke(cli, [tmp_path.as_posix()])
assert result.exit_code == ExitCode.OK
- tmp_path.joinpath(".db.sqlite3").exists()
+ assert path_to_db.exists()
@pytest.mark.end_to_end()
def test_rename_database_w_cli(tmp_path, runner):
"""Modification dates of input and output files are stored in database."""
+ path_to_db = tmp_path.joinpath(".db.sqlite")
result = runner.invoke(
- cli, ["--database-filename", ".db.sqlite3", tmp_path.as_posix()]
+ cli,
+ ["--database-url", "sqlite:///.db.sqlite", tmp_path.as_posix()],
)
assert result.exit_code == ExitCode.OK
- tmp_path.joinpath(".db.sqlite3").exists()
+ assert path_to_db.exists()
diff --git a/tests/test_debugging.py b/tests/test_debugging.py
index 41a15f840..6687bd0d4 100644
--- a/tests/test_debugging.py
+++ b/tests/test_debugging.py
@@ -133,7 +133,6 @@ def task_example(depends_on):
@pytest.mark.end_to_end()
@pytest.mark.skipif(not IS_PEXPECT_INSTALLED, reason="pexpect is not installed.")
@pytest.mark.skipif(sys.platform == "win32", reason="pexpect cannot spawn on Windows.")
-@pytest.mark.skipif(sys.version_info < (3, 7), reason="breakpoint is Python 3.7+ only.")
def test_breakpoint(tmp_path):
source = """
def task_example():
diff --git a/tests/test_dry_run.py b/tests/test_dry_run.py
index d73a5ab69..13114c05a 100644
--- a/tests/test_dry_run.py
+++ b/tests/test_dry_run.py
@@ -33,7 +33,7 @@ def test_dry_run_w_subsequent_task(runner, tmp_path):
@pytask.mark.depends_on("out.txt")
@pytask.mark.produces("out_2.txt")
- def task_example(produces):
+ def task_example(depends_on, produces):
produces.touch()
"""
tmp_path.joinpath("task_example_second.py").write_text(textwrap.dedent(source))
@@ -80,7 +80,7 @@ def task_example(produces):
@pytask.mark.depends_on("out.txt")
@pytask.mark.produces("out_2.txt")
- def task_example(produces):
+ def task_example(depends_on, produces):
produces.touch()
"""
tmp_path.joinpath("task_example_second.py").write_text(textwrap.dedent(source_2))
diff --git a/tests/test_execute.py b/tests/test_execute.py
index 9e9e5faa8..7bddaa416 100644
--- a/tests/test_execute.py
+++ b/tests/test_execute.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import os
+import pickle
import re
import subprocess
import sys
@@ -8,6 +9,7 @@
from pathlib import Path
import pytest
+from _pytask.capture import _CaptureMethod
from _pytask.exceptions import NodeNotFoundError
from pytask import cli
from pytask import ExitCode
@@ -385,6 +387,7 @@ def task_example():
assert "Collected 1 task" in result.output
+@pytest.mark.end_to_end()
def test_task_executed_with_force_although_unchanged(tmp_path):
tmp_path.joinpath("task_module.py").write_text("def task_example(): pass")
session = main({"paths": tmp_path})
@@ -419,3 +422,247 @@ def test_task_is_not_reexecuted_when_modification_changed_file_not(runner, tmp_p
result = runner.invoke(cli, [tmp_path.as_posix()])
assert result.exit_code == ExitCode.OK
assert "1 Skipped" in result.output
+
+
+@pytest.mark.end_to_end()
+def test_task_with_product_annotation(tmp_path):
+ source = """
+ from pathlib import Path
+ from typing_extensions import Annotated
+ from pytask import Product
+
+ def task_example(path_to_file: Annotated[Path, Product] = Path("out.txt")) -> None:
+ path_to_file.touch()
+ """
+ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source))
+
+ session = main({"paths": tmp_path, "capture": _CaptureMethod.NO})
+
+ assert session.exit_code == ExitCode.OK
+ assert len(session.tasks) == 1
+ task = session.tasks[0]
+ assert "path_to_file" in task.produces
+
+
+@pytest.mark.end_to_end()
+@pytest.mark.xfail(reason="Nested annotations are not parsed.", raises=AssertionError)
+def test_task_with_nested_product_annotation(tmp_path):
+ source = """
+ from pathlib import Path
+ from typing_extensions import Annotated
+ from pytask import Product
+
+ def task_example(
+ paths_to_file: dict[str, Annotated[Path, Product]] = {"a": Path("out.txt")}
+ ) -> None:
+ pass
+ """
+ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source))
+
+ session = main({"paths": tmp_path, "capture": _CaptureMethod.NO})
+
+ assert session.exit_code == ExitCode.OK
+ assert len(session.tasks) == 1
+ task = session.tasks[0]
+ assert "paths_to_file" in task.produces
+
+
+@pytest.mark.end_to_end()
+@pytest.mark.parametrize(
+ "definition",
+ [
+ " = PythonNode(value=data['dependency'], hash=True)",
+ ": Annotated[Any, PythonNode(hash=True)] = data['dependency']",
+ ],
+)
+def test_task_with_hashed_python_node(runner, tmp_path, definition):
+ source = f"""
+ import json
+ from pathlib import Path
+ from pytask import Product, PythonNode
+ from typing import Any
+ from typing_extensions import Annotated
+
+ data = json.loads(Path(__file__).parent.joinpath("data.json").read_text())
+
+ def task_example(
+ dependency{definition},
+ path: Annotated[Path, Product] = Path("out.txt")
+ ) -> None:
+ path.write_text(dependency)
+ """
+ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source))
+ tmp_path.joinpath("data.json").write_text('{"dependency": "hello"}')
+
+ result = runner.invoke(cli, [tmp_path.as_posix()])
+ assert result.exit_code == ExitCode.OK
+ assert tmp_path.joinpath("out.txt").read_text() == "hello"
+
+ tmp_path.joinpath("data.json").write_text('{"dependency": "world"}')
+
+ result = runner.invoke(cli, [tmp_path.as_posix()])
+ assert result.exit_code == ExitCode.OK
+ assert tmp_path.joinpath("out.txt").read_text() == "world"
+
+
+@pytest.mark.end_to_end()
+def test_error_with_multiple_dep_annotations(runner, tmp_path):
+ source = """
+ from pathlib import Path
+ from typing_extensions import Annotated
+ from pytask import Product, PythonNode
+ from typing import Any
+
+ def task_example(
+ dependency: Annotated[Any, PythonNode(), PythonNode()] = "hello",
+ path: Annotated[Path, Product] = Path("out.txt")
+ ) -> None:
+ path.write_text(dependency)
+ """
+ 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 "Parameter 'dependency'" in result.output
+
+
+@pytest.mark.end_to_end()
+def test_error_with_multiple_different_dep_annotations(runner, tmp_path):
+ source = """
+ from pathlib import Path
+ from typing_extensions import Annotated
+ from pytask import Product, PythonNode, PathNode
+ from typing import Any
+
+ def task_example(
+ dependency: Annotated[Any, PythonNode(), PathNode()] = "hello",
+ path: Annotated[Path, Product] = Path("out.txt")
+ ) -> None:
+ path.write_text(dependency)
+ """
+ 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 "Parameter 'dependency'" in result.output
+
+
+@pytest.mark.end_to_end()
+def test_return_with_path_annotation_as_return(runner, tmp_path):
+ source = """
+ from pathlib import Path
+ from typing import Any
+ from typing_extensions import Annotated
+ from pytask import PathNode
+
+ def task_example() -> Annotated[str, Path("file.txt")]:
+ return "Hello, World!"
+ """
+ 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("file.txt").read_text() == "Hello, World!"
+
+
+@pytest.mark.end_to_end()
+def test_return_with_pathnode_annotation_as_return(runner, tmp_path):
+ source = """
+ from pathlib import Path
+ from typing import Any
+ from typing_extensions import Annotated
+ from pytask import PathNode
+
+ node = PathNode.from_path(Path(__file__).parent.joinpath("file.txt"))
+
+ def task_example() -> Annotated[str, node]:
+ return "Hello, World!"
+ """
+ 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("file.txt").read_text() == "Hello, World!"
+
+
+@pytest.mark.end_to_end()
+def test_return_with_tuple_pathnode_annotation_as_return(runner, tmp_path):
+ source = """
+ from pathlib import Path
+ from typing import Any
+ from typing_extensions import Annotated
+ from pytask import PathNode
+
+ node1 = PathNode.from_path(Path(__file__).parent.joinpath("file1.txt"))
+ node2 = PathNode.from_path(Path(__file__).parent.joinpath("file2.txt"))
+
+ def task_example() -> Annotated[str, (node1, node2)]:
+ return "Hello,", "World!"
+ """
+ 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("file1.txt").read_text() == "Hello,"
+ assert tmp_path.joinpath("file2.txt").read_text() == "World!"
+
+
+@pytest.mark.end_to_end()
+def test_return_with_custom_type_annotation_as_return(runner, tmp_path):
+ source = """
+ from __future__ import annotations
+
+ from pathlib import Path
+ import pickle
+ from typing import Any
+ from typing_extensions import Annotated
+ import attrs
+
+ @attrs.define
+ class PickleNode:
+ name: str = ""
+ path: Path | None = None
+ value: None = None
+
+ def state(self) -> str | None:
+ if self.path.exists():
+ return str(self.path.stat().st_mtime)
+ return None
+
+ def load(self) -> Any:
+ return pickle.loads(self.path.read_bytes())
+
+ def save(self, value: Any) -> None:
+ self.path.write_bytes(pickle.dumps(value))
+
+ def from_annot(self, value: Any) -> None: ...
+
+ node = PickleNode("pickled_data", Path(__file__).parent.joinpath("data.pkl"))
+
+ def task_example() -> Annotated[int, node]:
+ return 1
+ """
+ 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
+
+ data = pickle.loads(tmp_path.joinpath("data.pkl").read_bytes()) # noqa: S301
+ assert data == 1
+
+
+@pytest.mark.end_to_end()
+def test_error_when_return_pytree_mismatch(runner, tmp_path):
+ source = """
+ from pathlib import Path
+ from typing import Any
+ from typing_extensions import Annotated
+ from pytask import PathNode
+
+ node1 = PathNode.from_path(Path(__file__).parent.joinpath("file1.txt"))
+ node2 = PathNode.from_path(Path(__file__).parent.joinpath("file2.txt"))
+
+ def task_example() -> Annotated[str, (node1, node2)]:
+ return "Hello,"
+ """
+ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source))
+ result = runner.invoke(cli, [tmp_path.as_posix()])
+ assert result.exit_code == ExitCode.FAILED
+ assert "Function return: PyTreeSpec(*, NoneIsLeaf)" in result.output
+ assert "Return annotation: PyTreeSpec((*, *), NoneIsLeaf)" in result.output
diff --git a/tests/test_graph.py b/tests/test_graph.py
index 38f6158ca..c7aecc590 100644
--- a/tests/test_graph.py
+++ b/tests/test_graph.py
@@ -86,7 +86,7 @@ def test_create_graph_via_task(tmp_path, runner, format_, layout, rankdir):
import networkx as nx
@pytask.mark.depends_on("input.txt")
- def task_example(): pass
+ def task_example(depends_on): pass
def task_create_graph():
dag = pytask.build_dag({{"paths": Path(__file__).parent}})
diff --git a/tests/test_live.py b/tests/test_live.py
index 851a11a45..4fe76273c 100644
--- a/tests/test_live.py
+++ b/tests/test_live.py
@@ -1,7 +1,6 @@
from __future__ import annotations
import re
-import sys
import textwrap
import pytest
@@ -188,7 +187,7 @@ def test_live_execution_displays_subset_of_table(capsys, tmp_path, n_entries_in_
@pytest.mark.unit()
-@pytest.mark.xfail(sys.platform == "darwin", reason="See #377.")
+@pytest.mark.xfail(reason="See #377.")
def test_live_execution_skips_do_not_crowd_out_displayed_tasks(capsys, tmp_path):
path = tmp_path.joinpath("task_module.py")
task = Task(base_name="task_example", path=path, function=lambda x: x)
@@ -262,9 +261,11 @@ def test_full_execution_table_is_displayed_at_the_end_of_execution(tmp_path, run
source = """
import pytask
- @pytask.mark.parametrize("produces", [f"{i}.txt" for i in range(4)])
- def task_create_file(produces):
- produces.touch()
+ for produces in [f"{i}.txt" for i in range(4)]:
+
+ @pytask.mark.task
+ def task_create_file(produces=produces):
+ produces.touch()
"""
# Subfolder to reduce task id and be able to check the output later.
tmp_path.joinpath("d").mkdir()
diff --git a/tests/test_mark.py b/tests/test_mark.py
index 2b79367bc..422b7bf09 100644
--- a/tests/test_mark.py
+++ b/tests/test_mark.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import subprocess
import sys
import textwrap
@@ -172,16 +173,16 @@ def task_no_2():
],
)
def test_keyword_option_parametrize(tmp_path, expr: str, expected_passed: str) -> None:
- tmp_path.joinpath("task_module.py").write_text(
- textwrap.dedent(
- """
- import pytask
- @pytask.mark.parametrize("arg", [None, 1.3, "2-3"])
- def task_func(arg):
- pass
- """
- )
- )
+ source = """
+ import pytask
+
+ for arg in [None, 1.3, "2-3"]:
+
+ @pytask.mark.task
+ def task_func(arg=arg):
+ pass
+ """
+ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source))
session = main({"paths": tmp_path, "expression": expr})
assert session.exit_code == ExitCode.OK
@@ -354,3 +355,23 @@ def task_example():
assert result.exit_code == ExitCode.OK
assert "1 Succeeded" in result.output
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
+ )
+ assert b"DeprecationWarning: '@pytask.mark.depends_on'" in result.stdout
+ assert b"DeprecationWarning: '@pytask.mark.produces'" in result.stdout
diff --git a/tests/test_node_protocols.py b/tests/test_node_protocols.py
new file mode 100644
index 000000000..02ce9867d
--- /dev/null
+++ b/tests/test_node_protocols.py
@@ -0,0 +1,89 @@
+from __future__ import annotations
+
+import pickle
+import textwrap
+
+from pytask import cli
+from pytask import ExitCode
+
+
+def test_node_protocol_for_custom_nodes(runner, tmp_path):
+ source = """
+ from typing_extensions import Annotated
+ from pytask import Product
+ from attrs import define
+ from pathlib import Path
+
+ @define
+ class CustomNode:
+ name: str
+ value: str
+
+ def state(self):
+ return self.value
+
+ def load(self):
+ return self.value
+
+ def save(self, value):
+ self.value = value
+
+ def from_annot(self, value): ...
+
+
+ def task_example(
+ data = CustomNode("custom", "text"),
+ out: Annotated[Path, Product] = Path("out.txt"),
+ ) -> None:
+ out.write_text(data)
+ """
+ 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").read_text() == "text"
+
+
+def test_node_protocol_for_custom_nodes_with_paths(runner, tmp_path):
+ source = """
+ from typing_extensions import Annotated
+ from pytask import Product
+ from pathlib import Path
+ from attrs import define
+ import pickle
+
+ @define
+ class PickleFile:
+ name: str
+ path: Path
+ value: Path
+
+ def state(self):
+ return str(self.path.stat().st_mtime)
+
+ def load(self):
+ with self.path.open("rb") as f:
+ out = pickle.load(f)
+ return out
+
+ def save(self, value):
+ with self.path.open("wb") as f:
+ pickle.dump(value, f)
+
+ def from_annot(self, value): ...
+
+
+ _PATH = Path(__file__).parent.joinpath("in.pkl")
+
+ def task_example(
+ data = PickleFile(_PATH.as_posix(), _PATH, _PATH),
+ out: Annotated[Path, Product] = Path("out.txt"),
+ ) -> None:
+ out.write_text(data)
+ """
+ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source))
+ tmp_path.joinpath("in.pkl").write_bytes(pickle.dumps("text"))
+
+ result = runner.invoke(cli, [tmp_path.as_posix()])
+ assert result.exit_code == ExitCode.OK
+ assert tmp_path.joinpath("out.txt").read_text() == "text"
diff --git a/tests/test_nodes.py b/tests/test_nodes.py
index 9165a2f91..a58d7e501 100644
--- a/tests/test_nodes.py
+++ b/tests/test_nodes.py
@@ -5,7 +5,7 @@
import pytest
from _pytask.shared import reduce_node_name
-from pytask import FilePathNode
+from pytask import PathNode
_ROOT = Path.cwd()
@@ -16,14 +16,14 @@
("node", "paths", "expectation", "expected"),
[
pytest.param(
- FilePathNode.from_path(_ROOT.joinpath("src/module.py")),
+ PathNode.from_path(_ROOT.joinpath("src/module.py")),
[_ROOT.joinpath("alternative_src")],
does_not_raise(),
"pytask/src/module.py",
- id="Common path found for FilePathNode not in 'paths' and 'paths'",
+ id="Common path found for PathNode not in 'paths' and 'paths'",
),
pytest.param(
- FilePathNode.from_path(_ROOT.joinpath("top/src/module.py")),
+ PathNode.from_path(_ROOT.joinpath("top/src/module.py")),
[_ROOT.joinpath("top/src")],
does_not_raise(),
"src/module.py",
diff --git a/tests/test_parametrize.py b/tests/test_parametrize.py
deleted file mode 100644
index 37994a8b5..000000000
--- a/tests/test_parametrize.py
+++ /dev/null
@@ -1,531 +0,0 @@
-from __future__ import annotations
-
-import itertools
-import textwrap
-from contextlib import ExitStack as does_not_raise # noqa: N813
-from typing import NamedTuple
-
-import _pytask.parametrize
-import pytask
-import pytest
-from _pytask.parametrize import _check_if_n_arg_names_matches_n_arg_values
-from _pytask.parametrize import _parse_arg_names
-from _pytask.parametrize import _parse_arg_values
-from _pytask.parametrize import _parse_parametrize_markers
-from _pytask.parametrize import pytask_parametrize_task
-from _pytask.pluginmanager import get_plugin_manager
-from pytask import cli
-from pytask import ExitCode
-from pytask import main
-from pytask import Mark
-from pytask import Session
-
-
-@pytest.fixture(scope="module")
-def session():
- pm = get_plugin_manager()
- pm.register(_pytask.parametrize)
- session = Session(hook=pm.hook)
- return session
-
-
-@pytest.mark.integration()
-def test_pytask_generate_tasks_0(session):
- @pytask.mark.parametrize("i", range(2))
- def func(i): # noqa: ARG001, pragma: no cover
- pass
-
- names_and_objs = pytask_parametrize_task(session, "func", func)
-
- assert [i[0] for i in names_and_objs] == ["func[0]", "func[1]"]
- assert names_and_objs[0][1].pytask_meta.kwargs["i"] == 0
- assert names_and_objs[1][1].pytask_meta.kwargs["i"] == 1
-
-
-@pytest.mark.integration()
-@pytest.mark.xfail(strict=True, reason="Cartesian task product is disabled.")
-def test_pytask_generate_tasks_1(session):
- @pytask.mark.parametrize("j", range(2))
- @pytask.mark.parametrize("i", range(2))
- def func(i, j): # noqa: ARG001, pragma: no cover
- pass
-
- pytask_parametrize_task(session, "func", func)
-
-
-@pytest.mark.integration()
-@pytest.mark.xfail(strict=True, reason="Cartesian task product is disabled.")
-def test_pytask_generate_tasks_2(session):
- @pytask.mark.parametrize("j, k", itertools.product(range(2), range(2)))
- @pytask.mark.parametrize("i", range(2))
- def func(i, j, k): # noqa: ARG001, pragma: no cover
- pass
-
- pytask_parametrize_task(session, "func", func)
-
-
-@pytest.mark.unit()
-@pytest.mark.parametrize(
- ("arg_names", "expected"),
- [
- ("i", ("i",)),
- ("i,j", ("i", "j")),
- ("i, j", ("i", "j")),
- (("i", "j"), ("i", "j")),
- (["i", "j"], ("i", "j")),
- ],
-)
-def test_parse_arg_names(arg_names, expected):
- parsed_arg_names = _parse_arg_names(arg_names)
- assert parsed_arg_names == expected
-
-
-class TaskArguments(NamedTuple):
- a: int
- b: int
-
-
-@pytest.mark.unit()
-@pytest.mark.parametrize(
- ("arg_values", "has_single_arg", "expected"),
- [
- (["a", "b", "c"], True, [("a",), ("b",), ("c",)]),
- ([(0, 0), (0, 1), (1, 0)], False, [(0, 0), (0, 1), (1, 0)]),
- ([[0, 0], [0, 1], [1, 0]], False, [(0, 0), (0, 1), (1, 0)]),
- ({"a": 0, "b": 1}, False, [("a",), ("b",)]),
- ([{"a": 0, "b": 1}], True, [({"a": 0, "b": 1},)]),
- ([TaskArguments(1, 2)], False, [(1, 2)]),
- ([TaskArguments(a=1, b=2)], False, [(1, 2)]),
- ([TaskArguments(b=2, a=1)], False, [(1, 2)]),
- ],
-)
-def test_parse_arg_values(arg_values, has_single_arg, expected):
- parsed_arg_values = _parse_arg_values(arg_values, has_single_arg)
- assert parsed_arg_values == expected
-
-
-@pytest.mark.unit()
-@pytest.mark.parametrize(
- ("arg_names", "expectation"),
- [
- ("i", does_not_raise()),
- ("i, j", does_not_raise()),
- (("i", "j"), does_not_raise()),
- (["i", "j"], does_not_raise()),
- (range(1, 2), pytest.raises(TypeError)),
- ({"i": None, "j": None}, pytest.raises(TypeError)),
- ({"i", "j"}, pytest.raises(TypeError)),
- ],
-)
-def test_parse_argnames_raise_error(arg_names, expectation):
- with expectation:
- _parse_arg_names(arg_names)
-
-
-@pytest.mark.integration()
-@pytest.mark.parametrize(
- ("markers", "exp_base_arg_names", "exp_arg_names", "exp_arg_values"),
- [
- (
- [
- Mark("parametrize", ("i", range(2)), {}),
- Mark("parametrize", ("j", range(2)), {}),
- ],
- [("i",), ("j",)],
- [[("0",), ("1",)], [("0",), ("1",)]],
- [[(0,), (1,)], [(0,), (1,)]],
- ),
- (
- [Mark("parametrize", ("i", range(3)), {})],
- [("i",)],
- [[("0",), ("1",), ("2",)]],
- [[(0,), (1,), (2,)]],
- ),
- ],
-)
-def test_parse_parametrize_markers(
- markers, exp_base_arg_names, exp_arg_names, exp_arg_values
-):
- base_arg_names, arg_names, arg_values = _parse_parametrize_markers(markers, "task_")
-
- assert base_arg_names == exp_base_arg_names
- assert arg_names == exp_arg_names
- assert arg_values == exp_arg_values
-
-
-@pytest.mark.end_to_end()
-def test_parametrizing_tasks(tmp_path):
- source = """
- import pytask
-
- @pytask.mark.parametrize('i, produces', [(1, "1.txt"), (2, "2.txt")])
- def task_write_numbers_to_file(produces, i):
- produces.write_text(str(i))
- """
- tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source))
-
- session = main({"paths": tmp_path})
-
- assert session.exit_code == ExitCode.OK
- for i in range(1, 3):
- assert tmp_path.joinpath(f"{i}.txt").read_text() == str(i)
-
-
-@pytest.mark.end_to_end()
-def test_parametrizing_dependencies_and_targets(tmp_path):
- source = """
- import pytask
-
- @pytask.mark.parametrize('i, produces', [(1, "1.txt"), (2, "2.txt")])
- def task_save_numbers(i, produces):
- produces.write_text(str(i))
-
- @pytask.mark.parametrize("depends_on, produces", [
- ("1.txt", "1_out.txt"), ("2.txt", "2_out.txt")
- ])
- def task_save_numbers_again(depends_on, produces):
- produces.write_text(depends_on.read_text())
- """
- tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source))
-
- session = main({"paths": tmp_path})
-
- assert session.exit_code == ExitCode.OK
-
-
-@pytest.mark.end_to_end()
-def test_parametrize_iterator(tmp_path):
- """`parametrize` should work with generators."""
- source = """
- import pytask
- def gen():
- yield 1
- yield 2
- yield 3
- @pytask.mark.parametrize('a', gen())
- def task_func(a):
- pass
- """
- tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source))
- session = main({"paths": tmp_path})
- assert session.exit_code == ExitCode.OK
- assert len(session.execution_reports) == 3
-
-
-@pytest.mark.end_to_end()
-def test_raise_error_if_function_does_not_use_parametrized_arguments(tmp_path):
- source = """
- import pytask
-
- @pytask.mark.parametrize('i', range(2))
- def task_func():
- pass
- """
- tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source))
- session = main({"paths": tmp_path})
-
- assert session.exit_code == ExitCode.FAILED
- assert isinstance(session.execution_reports[0].exc_info[1], TypeError)
- assert isinstance(session.execution_reports[1].exc_info[1], TypeError)
-
-
-@pytest.mark.end_to_end()
-@pytest.mark.parametrize(
- ("arg_values", "ids"),
- [
- (range(2), ["first_trial", "second_trial"]),
- ([True, False], ["first_trial", "second_trial"]),
- ],
-)
-def test_parametrize_w_ids(tmp_path, arg_values, ids):
- tmp_path.joinpath("task_module.py").write_text(
- textwrap.dedent(
- f"""
- import pytask
-
- @pytask.mark.parametrize('i', {arg_values}, ids={ids})
- def task_func(i):
- pass
- """
- )
- )
- session = main({"paths": tmp_path})
-
- assert session.exit_code == ExitCode.OK
- for task, id_ in zip(session.tasks, ids):
- assert id_ in task.name
-
-
-@pytest.mark.end_to_end()
-def test_two_parametrize_w_ids(runner, tmp_path):
- source = """
- import pytask
-
- @pytask.mark.parametrize('i', range(2), ids=["2.1", "2.2"])
- @pytask.mark.parametrize('j', range(2), ids=["1.1", "1.2"])
- def task_func(i, j):
- pass
- """
- tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source))
- result = runner.invoke(cli, [tmp_path.as_posix()])
-
- assert result.exit_code == ExitCode.COLLECTION_FAILED
- assert "You cannot apply @pytask.mark.parametrize multiple" in result.output
-
-
-@pytest.mark.end_to_end()
-@pytest.mark.parametrize("ids", [["a"], list("abc"), ((1,), (2,)), ({0}, {1})])
-def test_raise_error_for_irregular_ids(tmp_path, ids):
- tmp_path.joinpath("task_module.py").write_text(
- textwrap.dedent(
- f"""
- import pytask
-
- @pytask.mark.parametrize('i', range(2), ids={ids})
- def task_func():
- pass
- """
- )
- )
- session = main({"paths": tmp_path})
-
- assert session.exit_code == ExitCode.COLLECTION_FAILED
- assert isinstance(session.collection_reports[0].exc_info[1], ValueError)
-
-
-@pytest.mark.end_to_end()
-def test_raise_error_if_parametrization_produces_non_unique_tasks(tmp_path):
- tmp_path.joinpath("task_module.py").write_text(
- textwrap.dedent(
- """
- import pytask
-
- @pytask.mark.parametrize('i', [0, 0])
- def task_func(i):
- pass
- """
- )
- )
- session = main({"paths": tmp_path})
-
- assert session.exit_code == ExitCode.COLLECTION_FAILED
- assert isinstance(session.collection_reports[0].exc_info[1], ValueError)
-
-
-@pytest.mark.end_to_end()
-@pytest.mark.parametrize(
- ("arg_names", "arg_values", "content"),
- [
- (
- ("i", "j"),
- [1, 2, 3],
- [
- "ValueError",
- "with 2 'arg_names', ('i', 'j'),",
- "'arg_values' is 1.",
- "parametrization no. 0:",
- "(1,)",
- ],
- ),
- (
- ("i", "j"),
- [(1, 2, 3)],
- [
- "ValueError",
- "with 2 'arg_names', ('i', 'j'),",
- "'arg_values' is 3.",
- "parametrization no. 0:",
- "(1, 2, 3)",
- ],
- ),
- (
- ("i", "j"),
- [(1, 2), (1, 2, 3)],
- [
- "ValueError",
- "with 2 'arg_names', ('i', 'j'),",
- "'arg_values' is 2 or 3.",
- "parametrization no. 1:",
- "(1, 2, 3)",
- ],
- ),
- ],
-)
-def test_wrong_number_of_names_and_wrong_number_of_arguments(
- tmp_path, runner, arg_names, arg_values, content
-):
- source = f"""
- import pytask
-
- @pytask.mark.parametrize({arg_names}, {arg_values})
- def task_func():
- pass
- """
- tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source))
-
- result = runner.invoke(cli, [tmp_path.as_posix()])
-
- assert result.exit_code == ExitCode.COLLECTION_FAILED
- for c in content:
- assert c in result.output
-
-
-@pytest.mark.end_to_end()
-def test_generators_are_removed_from_depends_on_produces(tmp_path):
- source = """
- from pathlib import Path
- import pytask
-
- @pytask.mark.parametrize("produces", [
- ((x for x in ["out.txt", "out_2.txt"]),),
- ["in.txt"],
- ])
- def task_example(produces):
- produces = {0: produces} if isinstance(produces, Path) else produces
- for p in produces.values():
- p.write_text("hihi")
- """
- tmp_path.joinpath("task_dummy.py").write_text(textwrap.dedent(source))
-
- session = main({"paths": tmp_path})
- assert session.exit_code == ExitCode.OK
- assert session.tasks[0].function.pytask_meta.markers == []
-
-
-@pytest.mark.end_to_end()
-def test_parametrizing_tasks_with_namedtuples(runner, tmp_path):
- source = """
- from typing import NamedTuple
- import pytask
- from pathlib import Path
-
-
- class Task(NamedTuple):
- i: int
- produces: Path
-
-
- @pytask.mark.parametrize('i, produces', [
- Task(i=1, produces="1.txt"), Task(produces="2.txt", i=2),
- ])
- def task_write_numbers_to_file(produces, i):
- produces.write_text(str(i))
- """
- 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
- for i in range(1, 3):
- assert tmp_path.joinpath(f"{i}.txt").read_text() == str(i)
-
-
-@pytest.mark.end_to_end()
-def test_parametrization_with_different_n_of_arg_names_and_arg_values(runner, tmp_path):
- source = """
- import pytask
-
- @pytask.mark.parametrize('i, produces', [(1, "1.txt"), (2, 3, "2.txt")])
- def task_write_numbers_to_file(produces, i):
- produces.write_text(str(i))
- """
- 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 "Task 'task_write_numbers_to_file' is parametrized with 2" in result.output
-
-
-@pytest.mark.unit()
-@pytest.mark.parametrize(
- ("arg_names", "arg_values", "name", "expectation"),
- [
- pytest.param(
- ("a",),
- [(1,), (2,)],
- "task_name",
- does_not_raise(),
- id="normal one argument parametrization",
- ),
- pytest.param(
- ("a", "b"),
- [(1, 2), (3, 4)],
- "task_name",
- does_not_raise(),
- id="normal two argument argument parametrization",
- ),
- pytest.param(
- ("a",),
- [(1, 2), (2,)],
- "task_name",
- pytest.raises(ValueError, match="Task 'task_name' is parametrized with 1"),
- id="error with one argument parametrization",
- ),
- pytest.param(
- ("a", "b"),
- [(1, 2), (3, 4, 5)],
- "task_name",
- pytest.raises(ValueError, match="Task 'task_name' is parametrized with 2"),
- id="error with two argument argument parametrization",
- ),
- ],
-)
-def test_check_if_n_arg_names_matches_n_arg_values(
- arg_names, arg_values, name, expectation
-):
- with expectation:
- _check_if_n_arg_names_matches_n_arg_values(arg_names, arg_values, name)
-
-
-@pytest.mark.end_to_end()
-def test_parametrize_with_single_dict(tmp_path):
- source = """
- import pytask
-
- @pytask.mark.parametrize('i', [{"a": 1}, {"a": 1.0}])
- def task_write_numbers_to_file(i):
- assert i["a"] == 1
- """
- tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source))
-
- session = main({"paths": tmp_path})
-
- assert session.exit_code == ExitCode.OK
-
-
-@pytest.mark.end_to_end()
-def test_deprecation_warning_for_parametrizing_tasks(runner, tmp_path):
- source = """
- import pytask
-
- @pytask.mark.parametrize('i, produces', [(1, "1.txt"), (2, "2.txt")])
- def task_write_numbers_to_file(produces, i):
- produces.write_text(str(i))
- """
- 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 "FutureWarning" in result.output
-
-
-@pytest.mark.end_to_end()
-def test_silence_deprecation_warning_for_parametrizing_tasks(runner, tmp_path):
- source = """
- import pytask
-
- @pytask.mark.parametrize('i, produces', [(1, "1.txt"), (2, "2.txt")])
- def task_write_numbers_to_file(produces, i):
- produces.write_text(str(i))
- """
- tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source))
- tmp_path.joinpath("pyproject.toml").write_text(
- "[tool.pytask.ini_options]\nsilence_parametrize_deprecation = true"
- )
-
- result = runner.invoke(cli, [tmp_path.as_posix()])
-
- assert result.exit_code == ExitCode.OK
- assert "FutureWarning" not in result.output
diff --git a/tests/test_parametrize_utils.py b/tests/test_parametrize_utils.py
deleted file mode 100644
index 8b4832081..000000000
--- a/tests/test_parametrize_utils.py
+++ /dev/null
@@ -1,26 +0,0 @@
-from __future__ import annotations
-
-import pytest
-from _pytask.parametrize_utils import arg_value_to_id_component
-
-
-@pytest.mark.unit()
-@pytest.mark.parametrize(
- ("arg_name", "arg_value", "i", "id_func", "expected"),
- [
- ("arg", 1, 0, None, "1"),
- ("arg", True, 0, None, "True"),
- ("arg", False, 0, None, "False"),
- ("arg", 1.0, 0, None, "1.0"),
- ("arg", None, 0, None, "arg0"),
- ("arg", (1,), 0, None, "arg0"),
- ("arg", [1], 0, None, "arg0"),
- ("arg", {1, 2}, 0, None, "arg0"),
- ("arg", 1, 0, bool, "True"),
- ("arg", 1, 1, lambda x: None, "1"), # noqa: ARG005
- ("arg", [1], 2, lambda x: None, "arg2"), # noqa: ARG005
- ],
-)
-def test_arg_value_to_id_component(arg_name, arg_value, i, id_func, expected):
- result = arg_value_to_id_component(arg_name, arg_value, i, id_func)
- assert result == expected
diff --git a/tests/test_path.py b/tests/test_path.py
index 58737a47b..e55be2f79 100644
--- a/tests/test_path.py
+++ b/tests/test_path.py
@@ -133,6 +133,7 @@ def simple_module(tmp_path: Path) -> Path:
return fn
+@pytest.mark.unit()
def test_importmode_importlib(simple_module: Path, tmp_path: Path) -> None:
"""`importlib` mode does not change sys.path."""
module = import_path(simple_module, root=tmp_path)
@@ -144,6 +145,7 @@ def test_importmode_importlib(simple_module: Path, tmp_path: Path) -> None:
assert "_src.project" in sys.modules
+@pytest.mark.unit()
def test_importmode_twice_is_different_module(
simple_module: Path, tmp_path: Path
) -> None:
@@ -153,6 +155,7 @@ def test_importmode_twice_is_different_module(
assert module1 is not module2
+@pytest.mark.unit()
def test_no_meta_path_found(
simple_module: Path, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
@@ -171,6 +174,7 @@ def test_no_meta_path_found(
import_path(simple_module, root=tmp_path)
+@pytest.mark.unit()
def test_importmode_importlib_with_dataclass(tmp_path: Path) -> None:
"""
Ensure that importlib mode works with a module containing dataclasses (#373,
@@ -197,6 +201,7 @@ class Data:
assert data.__module__ == "_src.project.task_dataclass"
+@pytest.mark.unit()
def test_importmode_importlib_with_pickle(tmp_path: Path) -> None:
"""Ensure that importlib mode works with pickle (#373, pytest#7859)."""
fn = tmp_path.joinpath("_src/project/task_pickle.py")
@@ -222,6 +227,7 @@ def round_trip():
assert action() == 42
+@pytest.mark.unit()
def test_importmode_importlib_with_pickle_separate_modules(tmp_path: Path) -> None:
"""
Ensure that importlib mode works can load pickles that look similar but are
@@ -275,6 +281,7 @@ def round_trip(obj):
assert Data2.__module__ == "_src.m2.project.task"
+@pytest.mark.unit()
def test_module_name_from_path(tmp_path: Path) -> None:
result = _module_name_from_path(tmp_path / "src/project/task_foo.py", tmp_path)
assert result == "src.project.task_foo"
@@ -284,6 +291,7 @@ def test_module_name_from_path(tmp_path: Path) -> None:
assert result == "home.foo.task_foo"
+@pytest.mark.unit()
def test_insert_missing_modules(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
diff --git a/tests/test_persist.py b/tests/test_persist.py
index 0b6bf8bb1..071155841 100644
--- a/tests/test_persist.py
+++ b/tests/test_persist.py
@@ -3,10 +3,10 @@
import textwrap
import pytest
-from _pytask.database_utils import create_database
from _pytask.database_utils import State
from _pytask.persist import pytask_execute_task_process_report
-from pony import orm
+from pytask import create_database
+from pytask import DatabaseSession
from pytask import ExitCode
from pytask import main
from pytask import Persisted
@@ -63,17 +63,13 @@ def task_dummy(depends_on, produces):
assert session.execution_reports[0].outcome == TaskOutcome.PERSISTENCE
assert isinstance(session.execution_reports[0].exc_info[1], Persisted)
- with orm.db_session:
- create_database(
- "sqlite",
- tmp_path.joinpath(".pytask.sqlite3").as_posix(),
- create_db=True,
- create_tables=False,
- )
+ create_database("sqlite:///" + tmp_path.joinpath(".pytask.sqlite3").as_posix())
+
+ with DatabaseSession() as session:
task_id = tmp_path.joinpath("task_module.py").as_posix() + "::task_dummy"
node_id = tmp_path.joinpath("out.txt").as_posix()
- modification_time = State[task_id, node_id].modification_time
+ modification_time = session.get(State, (task_id, node_id)).modification_time
assert float(modification_time) == tmp_path.joinpath("out.txt").stat().st_mtime
session = main({"paths": tmp_path})
diff --git a/tests/test_profile.py b/tests/test_profile.py
index 03c5ee41e..fe241aa54 100644
--- a/tests/test_profile.py
+++ b/tests/test_profile.py
@@ -6,10 +6,10 @@
import pytest
from _pytask.cli import cli
-from _pytask.database_utils import create_database
from _pytask.profile import _to_human_readable_size
from _pytask.profile import Runtime
-from pony import orm
+from pytask import create_database
+from pytask import DatabaseSession
from pytask import ExitCode
from pytask import main
@@ -30,17 +30,12 @@ def task_example(): time.sleep(2)
duration = task.attributes["duration"]
assert duration[1] - duration[0] > 2
- with orm.db_session:
- create_database(
- "sqlite",
- tmp_path.joinpath(".pytask.sqlite3").as_posix(),
- create_db=True,
- create_tables=False,
- )
+ create_database("sqlite:///" + tmp_path.joinpath(".pytask.sqlite3").as_posix())
+ with DatabaseSession() as session:
task_name = tmp_path.joinpath("task_example.py").as_posix() + "::task_example"
- runtime = Runtime[task_name]
+ runtime = session.get(Runtime, task_name)
assert runtime.duration > 2
diff --git a/tests/test_skipping.py b/tests/test_skipping.py
index f7c1ad7c2..bb18d0fae 100644
--- a/tests/test_skipping.py
+++ b/tests/test_skipping.py
@@ -195,11 +195,10 @@ def test_if_skipif_decorator_is_applied_execute(tmp_path):
@pytask.mark.skipif(False, reason="bla")
@pytask.mark.produces("out.txt")
def task_first(produces):
- with open(produces, "w") as f:
- f.write("hello world.")
+ produces.touch()
@pytask.mark.depends_on("out.txt")
- def task_second():
+ def task_second(depends_on):
pass
"""
tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source))
diff --git a/tests/test_task.py b/tests/test_task.py
index 33905c26a..7bf9157b1 100644
--- a/tests/test_task.py
+++ b/tests/test_task.py
@@ -33,35 +33,6 @@ def {func_name}(produces):
assert session.tasks[0].name.endswith(f"task_module.py::{func_name}")
-@pytest.mark.end_to_end()
-@pytest.mark.parametrize("func_name", ["task_example", "func"])
-@pytest.mark.parametrize("task_name", ["the_only_task", None])
-def test_task_with_task_decorator_with_parametrize(tmp_path, func_name, task_name):
- task_decorator_input = f"{task_name!r}" if task_name else task_name
- source = f"""
- import pytask
-
- @pytask.mark.task({task_decorator_input})
- @pytask.mark.parametrize("produces", ["out_1.txt", "out_2.txt"])
- def {func_name}(produces):
- produces.write_text("Hello. It's me.")
- """
- path_to_module = tmp_path.joinpath("task_module.py")
- path_to_module.write_text(textwrap.dedent(source))
-
- session = main({"paths": tmp_path})
-
- assert session.exit_code == ExitCode.OK
-
- file_name = path_to_module.name
- if task_name:
- assert session.tasks[0].name.endswith(f"{file_name}::{task_name}[out_1.txt]")
- assert session.tasks[1].name.endswith(f"{file_name}::{task_name}[out_2.txt]")
- else:
- assert session.tasks[0].name.endswith(f"{file_name}::{func_name}[out_1.txt]")
- assert session.tasks[1].name.endswith(f"{file_name}::{func_name}[out_2.txt]")
-
-
@pytest.mark.end_to_end()
def test_parametrization_in_for_loop(tmp_path, runner):
source = """
@@ -178,7 +149,7 @@ def test_parametrization_in_for_loop_with_ids(tmp_path, runner):
for i in range(2):
@pytask.mark.task(
- "deco_task", id=i, kwargs={"i": i, "produces": f"out_{i}.txt"}
+ "deco_task", id=str(i), kwargs={"i": i, "produces": f"out_{i}.txt"}
)
def example(produces, i):
produces.write_text(str(i))
@@ -429,3 +400,158 @@ def task_example():
assert "task_example[0]" in result.output
assert "task_example[1]" in result.output
assert "Collected 2 tasks" in result.output
+
+
+@pytest.mark.end_to_end()
+@pytest.mark.parametrize(
+ "irregular_id", [1, (1,), [1], {1}, ["a"], list("abc"), ((1,), (2,)), ({0}, {1})]
+)
+def test_raise_errors_for_irregular_ids(runner, tmp_path, irregular_id):
+ source = f"""
+ import pytask
+
+ @pytask.mark.task(id={irregular_id})
+ def task_example():
+ pass
+ """
+ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source))
+
+ result = runner.invoke(cli, [tmp_path.as_posix()])
+
+ assert result.exit_code == ExitCode.COLLECTION_FAILED
+ assert "Argument 'id' of @pytask.mark.task" in result.output
+
+
+@pytest.mark.end_to_end()
+@pytest.mark.xfail(reason="Should fail. Mandatory products will fix the issue.")
+def test_raise_error_if_parametrization_produces_non_unique_tasks(tmp_path):
+ source = """
+ import pytask
+
+ for i in [0, 0]:
+ @pytask.mark.task(id=str(i))
+ def task_func(i=i):
+ pass
+ """
+ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source))
+ session = main({"paths": tmp_path})
+
+ assert session.exit_code == ExitCode.COLLECTION_FAILED
+ assert isinstance(session.collection_reports[0].exc_info[1], ValueError)
+
+
+@pytest.mark.end_to_end()
+def test_task_receives_unknown_kwarg(runner, tmp_path):
+ source = """
+ import pytask
+
+ @pytask.mark.task(kwargs={"i": 1})
+ def task_example(): pass
+ """
+ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source))
+ result = runner.invoke(cli, [tmp_path.as_posix()])
+ assert result.exit_code == ExitCode.FAILED
+
+
+@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
+
+ 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)
+ def task_example(
+ path_in: Path,
+ arg: Annotated[str, PythonNode(hash=True)],
+ path_out: Annotated[Path, Product]
+ ) -> None:
+ path_out.write_text(path_in.read_text() + " " + arg)
+ """
+ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source))
+ tmp_path.joinpath("input.txt").write_text("Hello")
+
+ result = runner.invoke(cli, [tmp_path.as_posix()])
+ assert result.exit_code == ExitCode.OK
+ assert tmp_path.joinpath("output.txt").read_text() == "Hello world!"
+
+
+@pytest.mark.end_to_end()
+def test_task_kwargs_overwrite_default_arguments(runner, tmp_path):
+ source = """
+ import pytask
+ from pytask import Product
+ from pathlib import Path
+ from typing_extensions import Annotated
+
+ @pytask.mark.task(kwargs={
+ "in_path": Path("in.txt"), "addition": "world!", "out_path": Path("out.txt")
+ })
+ def task_example(
+ in_path: Path = Path("not_used_in.txt"),
+ addition: str = "planet!",
+ out_path: Annotated[Path, Product] = Path("not_used_out.txt"),
+ ) -> None:
+ out_path.write_text(in_path.read_text() + addition)
+ """
+ tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source))
+ tmp_path.joinpath("in.txt").write_text("Hello ")
+
+ result = runner.invoke(cli, [tmp_path.as_posix()])
+
+ assert result.exit_code == ExitCode.OK
+ assert tmp_path.joinpath("out.txt").read_text() == "Hello world!"
+ assert not tmp_path.joinpath("not_used_out.txt").exists()
+
+
+@pytest.mark.end_to_end()
+def test_return_with_task_decorator(runner, tmp_path):
+ source = """
+ from pathlib import Path
+ from typing import Any
+ from typing_extensions import Annotated
+ from pytask import PathNode
+ import pytask
+
+ node = PathNode.from_path(Path(__file__).parent.joinpath("file.txt"))
+
+ @pytask.mark.task(produces=node)
+ def task_example():
+ return "Hello, World!"
+ """
+ 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("file.txt").read_text() == "Hello, World!"
+
+
+@pytest.mark.end_to_end()
+def test_return_with_tuple_and_task_decorator(runner, tmp_path):
+ source = """
+ from pathlib import Path
+ from typing import Any
+ from typing_extensions import Annotated
+ from pytask import PathNode
+ import pytask
+
+ node1 = PathNode.from_path(Path(__file__).parent.joinpath("file1.txt"))
+ node2 = PathNode.from_path(Path(__file__).parent.joinpath("file2.txt"))
+
+ @pytask.mark.task(produces=(node1, node2))
+ def task_example():
+ return "Hello,", "World!"
+ """
+ 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("file1.txt").read_text() == "Hello,"
+ assert tmp_path.joinpath("file2.txt").read_text() == "World!"
diff --git a/tests/test_task_utils.py b/tests/test_task_utils.py
new file mode 100644
index 000000000..36b2b2086
--- /dev/null
+++ b/tests/test_task_utils.py
@@ -0,0 +1,58 @@
+from __future__ import annotations
+
+from contextlib import ExitStack as does_not_raise # noqa: N813
+from typing import NamedTuple
+
+import pytest
+from _pytask.task_utils import _arg_value_to_id_component
+from _pytask.task_utils import _parse_task_kwargs
+from attrs import define
+
+
+@pytest.mark.unit()
+@pytest.mark.parametrize(
+ ("arg_name", "arg_value", "i", "id_func", "expected"),
+ [
+ ("arg", 1, 0, None, "1"),
+ ("arg", True, 0, None, "True"),
+ ("arg", False, 0, None, "False"),
+ ("arg", 1.0, 0, None, "1.0"),
+ ("arg", None, 0, None, "arg0"),
+ ("arg", (1,), 0, None, "arg0"),
+ ("arg", [1], 0, None, "arg0"),
+ ("arg", {1, 2}, 0, None, "arg0"),
+ ("arg", 1, 0, bool, "True"),
+ ("arg", 1, 1, lambda x: None, "1"), # noqa: ARG005
+ ("arg", [1], 2, lambda x: None, "arg2"), # noqa: ARG005
+ ],
+)
+def test_arg_value_to_id_component(arg_name, arg_value, i, id_func, expected):
+ result = _arg_value_to_id_component(arg_name, arg_value, i, id_func)
+ assert result == expected
+
+
+class ExampleNT(NamedTuple):
+ a: int = 1
+
+
+@define
+class ExampleAttrs:
+ b: str = "wonderful"
+
+
+@pytest.mark.unit()
+@pytest.mark.parametrize(
+ ("kwargs", "expectation", "expected"),
+ [
+ ({"hello": 1}, does_not_raise(), {"hello": 1}),
+ (ExampleNT(), does_not_raise(), {"a": 1}),
+ (ExampleNT, pytest.raises(TypeError, match=r"(_asdict\(\) missing 1)"), None),
+ (ExampleAttrs(), does_not_raise(), {"b": "wonderful"}),
+ (ExampleAttrs, pytest.raises(ValueError, match="@pytask.mark.task"), None),
+ (1, pytest.raises(ValueError, match="@pytask.mark.task"), None),
+ ],
+)
+def test_parse_task_kwargs(kwargs, expectation, expected):
+ with expectation:
+ result = _parse_task_kwargs(kwargs)
+ assert result == expected
diff --git a/tests/test_pybaum.py b/tests/test_tree_util.py
similarity index 83%
rename from tests/test_pybaum.py
rename to tests/test_tree_util.py
index 69ceee349..34341e9bd 100644
--- a/tests/test_pybaum.py
+++ b/tests/test_tree_util.py
@@ -1,11 +1,11 @@
-"""This module contains tests for pybaum and flexible dependencies and products."""
+"""This module contains tests for tree_util and flexible dependencies and products."""
from __future__ import annotations
import textwrap
import pytest
from _pytask.outcomes import ExitCode
-from pybaum import tree_map
+from _pytask.tree_util import tree_map
from pytask import cli
from pytask import main
@@ -43,27 +43,28 @@ def task_example():
assert session.exit_code == exit_code
- products = tree_map(lambda x: x.value, getattr(session.tasks[0], decorator_name))
+ 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}
assert products == expected
@pytest.mark.end_to_end()
-def test_profile_with_pybaum(tmp_path, runner):
+def test_profile_with_pytree(tmp_path, runner):
source = """
import time
import pytask
- from pybaum.tree_util import tree_just_flatten
+ from _pytask.tree_util import tree_leaves
@pytask.mark.produces([{"out_1": "out_1.txt"}, {"out_2": "out_2.txt"}])
def task_example(produces):
time.sleep(2)
- for p in tree_just_flatten(produces):
+ for p in tree_leaves(produces):
p.write_text("There are nine billion bicycles in Beijing.")
"""
tmp_path.joinpath("task_example.py").write_text(textwrap.dedent(source))
diff --git a/tox.ini b/tox.ini
index 1bce2eb61..82a9d52bc 100644
--- a/tox.ini
+++ b/tox.ini
@@ -21,8 +21,8 @@ conda_deps =
click-default-group
networkx >=2.4
pluggy
- pony >=0.7.15
- pybaum >=0.1.1
+ sqlalchemy >=1.4.36
+ optree >=0.9
rich
tomli >=1.0.0