Skip to content

Commit ade72f1

Browse files
authored
Mark arbitrary function as tasks with @pytask.mark.task. (#200)
1 parent 104ece6 commit ade72f1

File tree

9 files changed

+196
-21
lines changed

9 files changed

+196
-21
lines changed

docs/source/changes.rst

+4-1
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,12 @@ all releases are available on `PyPI <https://pypi.org/project/pytask>`_ and
1414
- :gh:`193` adds more figures to the documentation.
1515
- :gh:`194` updates the ``README.rst``.
1616
- :gh:`196` references the two new cookiecutters for projects and plugins.
17-
- :gh:`198` fixes the documentation of ``@pytask.mark.skipif``. (Closes :gh:`195`)
17+
- :gh:`198` fixes the documentation of :func:`@pytask.mark.skipif
18+
<_pytask.skipping.skipif>`. (Closes :gh:`195`)
1819
- :gh:`199` extends the error message when paths are ambiguous on case-insensitive file
1920
systems.
21+
- :gh:`200` implements the :func:`@pytask.mark.task <_pytask.task.task>` decorator to
22+
mark functions as tasks regardless whether they are prefixed with ``task_`` or not.
2023

2124

2225
0.1.5 - 2022-01-10

docs/source/tutorials/how_to_write_a_task.rst

+35-7
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@ How to write a task
44
Starting from the project structure in the :doc:`previous tutorial
55
<how_to_set_up_a_project>`, this tutorial teaches you how to write your first task.
66

7-
The task will be defined in ``src/my_project/task_data_preparation.py`` and it will
8-
generate artificial data which will be stored in ``bld/data.pkl``. We will call the
7+
By default, pytask will look for tasks in modules whose name is prefixed with ``task_``.
8+
Tasks are functions in these modules whose name also starts with ``task_``.
9+
10+
Our first task will be defined in ``src/my_project/task_data_preparation.py`` and it
11+
will generate artificial data which will be stored in ``bld/data.pkl``. We will call the
912
function in the module :func:`task_create_random_data`.
1013

1114
.. code-block::
@@ -62,10 +65,35 @@ subsequent directories.
6265

6366
.. image:: /_static/images/how-to-write-a-task.png
6467

65-
.. important::
6668

67-
By default, pytask assumes that tasks are functions and both, the function name and
68-
the module name, must be prefixed with ``task_``.
69+
Customize task names
70+
--------------------
71+
72+
Use the :func:`@pytask.mark.task <_pytask.task_utils.task>` decorator to mark a function
73+
as a task regardless of its function name. You can optionally pass a new name for the
74+
task. Otherwise, the function name is used.
75+
76+
.. code-block:: python
77+
78+
# The id will be '.../task_data_preparation.py::create_random_data'
79+
80+
81+
@pytask.mark.task
82+
def create_random_data():
83+
...
84+
85+
86+
# The id will be '.../task_data_preparation.py::create_data'
87+
88+
89+
@pytask.mark.task(name="create_data")
90+
def create_random_data():
91+
...
92+
93+
94+
Customize task module names
95+
---------------------------
6996

70-
Use the configuration value :confval:`task_files` if you prefer a different naming
71-
scheme for the task modules.
97+
Use the configuration value :confval:`task_files` if you prefer a different naming
98+
scheme for the task modules. By default, it is set to ``task_*.py``. You can specify one
99+
or multiple patterns to collect tasks from other files.

src/_pytask/cli.py

+2
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ def pytask_add_hooks(pm: pluggy.PluginManager) -> None:
6666
from _pytask import profile
6767
from _pytask import resolve_dependencies
6868
from _pytask import skipping
69+
from _pytask import task
6970

7071
pm.register(build)
7172
pm.register(capture)
@@ -86,6 +87,7 @@ def pytask_add_hooks(pm: pluggy.PluginManager) -> None:
8687
pm.register(profile)
8788
pm.register(resolve_dependencies)
8889
pm.register(skipping)
90+
pm.register(task)
8991

9092

9193
@click.group(

src/_pytask/mark/structures.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ def store_mark(obj: Callable[..., Any], mark: Mark) -> None:
182182
assert isinstance(mark, Mark), mark
183183
# Always reassign name to avoid updating pytaskmark in a reference that was only
184184
# borrowed.
185-
obj.pytaskmark = get_unpacked_marks(obj) + [mark] # type: ignore
185+
obj.pytaskmark = get_unpacked_marks(obj) + [mark] # type: ignore[attr-defined]
186186

187187

188188
class MarkGenerator:

src/_pytask/mark_utils.py

+10
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"""
66
from typing import Any
77
from typing import List
8+
from typing import Tuple
89
from typing import TYPE_CHECKING
910

1011

@@ -30,3 +31,12 @@ def get_marks_from_obj(obj: Any, marker_name: str) -> "List[Mark]":
3031
def has_marker(obj: Any, marker_name: str) -> bool:
3132
"""Determine whether a task function has a certain marker."""
3233
return any(marker.name == marker_name for marker in getattr(obj, "pytaskmark", []))
34+
35+
36+
def remove_markers_from_func(obj: Any, marker_name: str) -> Tuple[Any, List["Mark"]]:
37+
"""Remove parametrize markers from the object."""
38+
markers = [i for i in getattr(obj, "pytaskmark", []) if i.name == marker_name]
39+
others = [i for i in getattr(obj, "pytaskmark", []) if i.name != marker_name]
40+
obj.pytaskmark = others
41+
42+
return obj, markers

src/_pytask/parametrize.py

+12-12
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,11 @@
1818
from _pytask.console import TASK_ICON
1919
from _pytask.mark import Mark
2020
from _pytask.mark import MARK_GEN as mark # noqa: N811
21+
from _pytask.mark_utils import has_marker
22+
from _pytask.mark_utils import remove_markers_from_func
2123
from _pytask.nodes import find_duplicates
2224
from _pytask.session import Session
25+
from _pytask.task_utils import parse_task_marker
2326

2427

2528
def parametrize(
@@ -93,14 +96,19 @@ def pytask_parametrize_task(
9396
9497
"""
9598
if callable(obj):
96-
obj, markers = _remove_parametrize_markers_from_func(obj)
99+
obj, markers = remove_markers_from_func(obj, "parametrize")
97100

98101
if len(markers) > 1:
99102
raise NotImplementedError(
100103
"Multiple parametrizations are currently not implemented since it is "
101104
"not possible to define products for tasks from a Cartesian product."
102105
)
103106

107+
if has_marker(obj, "task"):
108+
parsed_name = parse_task_marker(obj)
109+
if parsed_name is not None:
110+
name = parsed_name
111+
104112
base_arg_names, arg_names, arg_values = _parse_parametrize_markers(
105113
markers, name
106114
)
@@ -119,8 +127,9 @@ def pytask_parametrize_task(
119127

120128
# Copy function and attributes to allow in-place changes.
121129
func = _copy_func(obj) # type: ignore
122-
func.pytaskmark = copy.deepcopy(obj.pytaskmark) # type: ignore
123-
130+
func.pytaskmark = copy.deepcopy( # type: ignore[attr-defined]
131+
obj.pytaskmark # type: ignore[attr-defined]
132+
)
124133
# Convert parametrized dependencies and products to decorator.
125134
session.hook.pytask_parametrize_kwarg_to_marker(obj=func, kwargs=kwargs)
126135

@@ -148,15 +157,6 @@ def pytask_parametrize_task(
148157
return names_and_functions
149158

150159

151-
def _remove_parametrize_markers_from_func(obj: Any) -> Tuple[Any, List[Mark]]:
152-
"""Remove parametrize markers from the object."""
153-
parametrize_markers = [i for i in obj.pytaskmark if i.name == "parametrize"]
154-
others = [i for i in obj.pytaskmark if i.name != "parametrize"]
155-
obj.pytaskmark = others
156-
157-
return obj, parametrize_markers
158-
159-
160160
def _parse_parametrize_marker(
161161
marker: Mark, name: str
162162
) -> Tuple[Tuple[str, ...], List[Tuple[str, ...]], List[Tuple[Any, ...]]]:

src/_pytask/task.py

+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
from pathlib import Path
2+
from typing import Any
3+
from typing import Dict
4+
from typing import Optional
5+
6+
from _pytask.config import hookimpl
7+
from _pytask.mark_utils import has_marker
8+
from _pytask.nodes import PythonFunctionTask
9+
from _pytask.session import Session
10+
from _pytask.task_utils import parse_task_marker
11+
12+
13+
@hookimpl
14+
def pytask_parse_config(config: Dict[str, Any]) -> None:
15+
config["markers"]["task"] = "Mark a function as a task regardless of its name."
16+
17+
18+
@hookimpl
19+
def pytask_collect_task(
20+
session: Session, path: Path, name: str, obj: Any
21+
) -> Optional[PythonFunctionTask]:
22+
"""Collect a task which is a function.
23+
24+
There is some discussion on how to detect functions in this `thread
25+
<https://stackoverflow.com/q/624926/7523785>`_. :class:`types.FunctionType` does not
26+
detect built-ins which is not possible anyway.
27+
28+
"""
29+
if has_marker(obj, "task") and callable(obj):
30+
parsed_name = parse_task_marker(obj)
31+
if parsed_name is not None:
32+
name = parsed_name
33+
return PythonFunctionTask.from_path_name_function_session(
34+
path, name, obj, session
35+
)
36+
else:
37+
return None

src/_pytask/task_utils.py

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from typing import Any
2+
from typing import Callable
3+
from typing import Optional
4+
5+
from _pytask.mark import Mark
6+
from _pytask.mark_utils import remove_markers_from_func
7+
8+
9+
def task(name: Optional[str] = None) -> str:
10+
return name
11+
12+
13+
def parse_task_marker(obj: Callable[..., Any]) -> str:
14+
obj, task_markers = remove_markers_from_func(obj, "task")
15+
16+
if len(task_markers) != 1:
17+
raise ValueError(
18+
"The @pytask.mark.task decorator cannot be applied more than once to a "
19+
"single task."
20+
)
21+
task_marker = task_markers[0]
22+
23+
name = task(*task_marker.args, **task_marker.kwargs)
24+
25+
obj.pytaskmark.append(Mark("task", (), {})) # type: ignore[attr-defined]
26+
27+
return name

tests/test_task.py

+68
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import textwrap
2+
3+
import pytest
4+
from _pytask.nodes import create_task_name
5+
from _pytask.outcomes import ExitCode
6+
from pytask import main
7+
8+
9+
@pytest.mark.parametrize("func_name", ["task_example", "func"])
10+
@pytest.mark.parametrize("task_name", ["the_only_task", None])
11+
def test_task_with_task_decorator(tmp_path, func_name, task_name):
12+
task_decorator_input = f"{task_name!r}" if task_name else task_name
13+
source = f"""
14+
import pytask
15+
16+
@pytask.mark.task({task_decorator_input})
17+
@pytask.mark.produces("out.txt")
18+
def {func_name}(produces):
19+
produces.write_text("Hello. It's me.")
20+
"""
21+
tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source))
22+
23+
session = main({"paths": tmp_path})
24+
25+
assert session.exit_code == ExitCode.OK
26+
27+
if task_name:
28+
assert session.tasks[0].name == create_task_name(
29+
tmp_path.joinpath("task_module.py"), task_name
30+
)
31+
else:
32+
assert session.tasks[0].name == create_task_name(
33+
tmp_path.joinpath("task_module.py"), func_name
34+
)
35+
36+
37+
@pytest.mark.parametrize("func_name", ["task_example", "func"])
38+
@pytest.mark.parametrize("task_name", ["the_only_task", None])
39+
def test_task_with_task_decorator_with_parametrize(tmp_path, func_name, task_name):
40+
task_decorator_input = f"{task_name!r}" if task_name else task_name
41+
source = f"""
42+
import pytask
43+
44+
@pytask.mark.task({task_decorator_input})
45+
@pytask.mark.parametrize("produces", ["out_1.txt", "out_2.txt"])
46+
def {func_name}(produces):
47+
produces.write_text("Hello. It's me.")
48+
"""
49+
tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source))
50+
51+
session = main({"paths": tmp_path})
52+
53+
assert session.exit_code == ExitCode.OK
54+
55+
if task_name:
56+
assert session.tasks[0].name == create_task_name(
57+
tmp_path.joinpath("task_module.py"), f"{task_name}[out_1.txt]"
58+
)
59+
assert session.tasks[1].name == create_task_name(
60+
tmp_path.joinpath("task_module.py"), f"{task_name}[out_2.txt]"
61+
)
62+
else:
63+
assert session.tasks[0].name == create_task_name(
64+
tmp_path.joinpath("task_module.py"), f"{func_name}[out_1.txt]"
65+
)
66+
assert session.tasks[1].name == create_task_name(
67+
tmp_path.joinpath("task_module.py"), f"{func_name}[out_2.txt]"
68+
)

0 commit comments

Comments
 (0)