Skip to content

Commit d7903e9

Browse files
authored
Implement a conditional skip marker (#62)
1 parent 9f02b8e commit d7903e9

File tree

7 files changed

+217
-4
lines changed

7 files changed

+217
-4
lines changed

.github/workflows/continuous-integration-workflow.yml

-1
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,6 @@ jobs:
7272
- uses: conda-incubator/setup-miniconda@v2
7373
with:
7474
auto-update-conda: true
75-
python-version: 3.8
7675

7776
- name: Install core dependencies.
7877
shell: bash -l {0}

docs/changes.rst

+4-1
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,14 @@ all releases are available on `PyPI <https://pypi.org/project/pytask>`_ and
77
`Anaconda.org <https://anaconda.org/conda-forge/pytask>`_.
88

99

10-
0.0.13 - 2021-xx-xx
10+
0.0.13 - 2021-03-09
1111
-------------------
1212

1313
- :gh:`72` adds conda-forge to the README and highlights importance of specifying
1414
dependencies and products.
15+
- :gh:`62` implements the ``pytask.mark.skipif`` marker to conditionally skip tasks.
16+
Many thanks to :ghuser:`roecla` for implementing this feature and a warm welcome since
17+
she is the first pytask contributor!
1518

1619

1720
0.0.12 - 2021-02-27

docs/reference_guides/marks.rst

+32-1
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,35 @@ pytask.mark.try_first
3232
.. function:: try_first
3333
:noindex:
3434

35-
This
35+
Indicate that the task should be executed as soon as possible.
36+
37+
This indicator is a soft measure to influence the execution order of pytask.
38+
39+
.. important::
40+
41+
This indicator is not intended for general use to influence the build order and
42+
to overcome misspecification of task dependencies and products.
43+
44+
It should only be applied to situations where it is hard to define all
45+
dependencies and products and automatic inference may be incomplete like with
46+
pytask-latex and latex-dependency-scanner.
47+
48+
49+
pytask.mark.try_last
50+
---------------------
51+
52+
.. function:: try_last
53+
:noindex:
54+
55+
Indicate that the task should be executed as late as possible.
56+
57+
This indicator is a soft measure to influence the execution order of pytask.
58+
59+
.. important::
60+
61+
This indicator is not intended for general use to influence the build order and
62+
to overcome misspecification of task dependencies and products.
63+
64+
It should only be applied to situations where it is hard to define all
65+
dependencies and products and automatic inference may be incomplete like with
66+
pytask-latex and latex-dependency-scanner.

docs/tutorials/how_to_skip_tasks.rst

+69
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
How to skip tasks
2+
=================
3+
4+
Tasks are skipped automatically if neither their file nor any of their dependencies have
5+
changed and all products exist.
6+
7+
In addition, you may want pytask to skip tasks either generally or if certain conditions
8+
are fulfilled. Skipping means the task itself and all tasks that depend on it will not
9+
be executed, even if the task file or their dependencies have changed or products are
10+
missing.
11+
12+
This can be useful for example if you are working on a task that creates the dependency
13+
of a long running task and you are not interested in the long running task's product for
14+
the moment. In that case you can simply use ``@pytask.mark.skip`` in front of the long
15+
running task to stop it from running:
16+
17+
.. code-block:: python
18+
19+
# Content of task_create_dependency.py
20+
21+
22+
@pytask.mark.produces("dependency_of_long_running_task.md")
23+
def task_you_are_working_on(produces):
24+
...
25+
26+
.. code-block:: python
27+
28+
# Content of task_long_running.py
29+
30+
31+
@pytask.mark.skip
32+
@pytask.mark.depends_on("dependency_of_long_running_task.md")
33+
def task_that_takes_really_long_to_run(depends_on):
34+
...
35+
36+
37+
In large projects, you may have many long running tasks that you only want to execute
38+
sporadically, e.g. when you are not working locally but running the project on a server.
39+
40+
In that case, we recommend using ``@pytask.mark.skipif`` which lets you supply a
41+
condition and a reason as arguments:
42+
43+
44+
.. code-block:: python
45+
46+
# Content of a config.py
47+
48+
NO_LONG_RUNNING_TASKS = True
49+
50+
.. code-block:: python
51+
52+
# Content of task_create_dependency.py
53+
54+
55+
@pytask.mark.produces("run_always.md")
56+
def task_always(produces):
57+
...
58+
59+
.. code-block:: python
60+
61+
# Content of task_long_running.py
62+
63+
from config import NO_LONG_RUNNING_TASKS
64+
65+
66+
@pytask.mark.skipif(NO_LONG_RUNNING_TASKS, "Skip long-running tasks.")
67+
@pytask.mark.depends_on("dependency_of_long_running_task.md")
68+
def task_that_takes_really_long_to_run(depends_on):
69+
...

docs/tutorials/index.rst

+1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ project. Start here if you are a new user.
1616
how_to_select_tasks
1717
how_to_clean
1818
how_to_collect
19+
how_to_skip_tasks
1920
how_to_make_tasks_persist
2021
how_to_capture
2122
how_to_invoke_pytask

src/_pytask/skipping.py

+15
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ def skip_ancestor_failed(reason: str = "No reason provided.") -> str:
1616
return reason
1717

1818

19+
def skipif(condition: bool, *, reason: str) -> tuple:
20+
"""Function to parse information in ``@pytask.mark.skipif``."""
21+
return condition, reason
22+
23+
1924
@hookimpl
2025
def pytask_parse_config(config):
2126
markers = {
@@ -24,6 +29,8 @@ def pytask_parse_config(config):
2429
"failed.",
2530
"skip_unchanged": "Internal decorator applied to tasks which have already been "
2631
"executed and have not been changed.",
32+
"skipif": "Skip a task and all its subsequent tasks in case a condition is "
33+
"fulfilled.",
2734
}
2835
config["markers"] = {**config["markers"], **markers}
2936

@@ -46,6 +53,14 @@ def pytask_execute_task_setup(task):
4653
if markers:
4754
raise Skipped
4855

56+
markers = get_specific_markers_from_task(task, "skipif")
57+
if markers:
58+
marker_args = [skipif(*marker.args, **marker.kwargs) for marker in markers]
59+
message = "\n".join([arg[1] for arg in marker_args if arg[0]])
60+
should_skip = any(arg[0] for arg in marker_args)
61+
if should_skip:
62+
raise Skipped(message)
63+
4964

5065
@hookimpl
5166
def pytask_execute_task_process_report(session, report):

tests/test_skipping.py

+96-1
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ def task_dummy(depends_on, produces):
5656

5757

5858
@pytest.mark.end_to_end
59-
def test_skip_if_ancestor_failed(tmp_path):
59+
def test_skipif_ancestor_failed(tmp_path):
6060
source = """
6161
import pytask
6262
@@ -102,6 +102,101 @@ def task_second():
102102
assert isinstance(session.execution_reports[1].exc_info[1], Skipped)
103103

104104

105+
@pytest.mark.end_to_end
106+
def test_if_skipif_decorator_is_applied_skipping(tmp_path):
107+
source = """
108+
import pytask
109+
110+
@pytask.mark.skipif(condition=True, reason="bla")
111+
@pytask.mark.produces("out.txt")
112+
def task_first():
113+
assert False
114+
115+
@pytask.mark.depends_on("out.txt")
116+
def task_second():
117+
assert False
118+
"""
119+
tmp_path.joinpath("task_dummy.py").write_text(textwrap.dedent(source))
120+
121+
session = main({"paths": tmp_path})
122+
node = session.collection_reports[0].node
123+
assert len(node.markers) == 1
124+
assert node.markers[0].name == "skipif"
125+
assert node.markers[0].args == ()
126+
assert node.markers[0].kwargs == {"condition": True, "reason": "bla"}
127+
128+
assert session.execution_reports[0].success
129+
assert isinstance(session.execution_reports[0].exc_info[1], Skipped)
130+
assert session.execution_reports[1].success
131+
assert isinstance(session.execution_reports[1].exc_info[1], Skipped)
132+
assert session.execution_reports[0].exc_info[1].args[0] == "bla"
133+
134+
135+
@pytest.mark.end_to_end
136+
def test_if_skipif_decorator_is_applied_execute(tmp_path):
137+
source = """
138+
import pytask
139+
140+
@pytask.mark.skipif(False, reason="bla")
141+
@pytask.mark.produces("out.txt")
142+
def task_first(produces):
143+
with open(produces, "w") as f:
144+
f.write("hello world.")
145+
146+
@pytask.mark.depends_on("out.txt")
147+
def task_second():
148+
pass
149+
"""
150+
tmp_path.joinpath("task_dummy.py").write_text(textwrap.dedent(source))
151+
152+
session = main({"paths": tmp_path})
153+
node = session.collection_reports[0].node
154+
155+
assert len(node.markers) == 1
156+
assert node.markers[0].name == "skipif"
157+
assert node.markers[0].args == (False,)
158+
assert node.markers[0].kwargs == {"reason": "bla"}
159+
assert session.execution_reports[0].success
160+
assert session.execution_reports[0].exc_info is None
161+
assert session.execution_reports[1].success
162+
assert session.execution_reports[1].exc_info is None
163+
164+
165+
@pytest.mark.end_to_end
166+
def test_if_skipif_decorator_is_applied_any_condition_matches(tmp_path):
167+
"""Any condition of skipif has to be True and only their message is shown."""
168+
source = """
169+
import pytask
170+
171+
@pytask.mark.skipif(condition=False, reason="I am fine")
172+
@pytask.mark.skipif(condition=True, reason="No, I am not.")
173+
@pytask.mark.produces("out.txt")
174+
def task_first():
175+
assert False
176+
177+
@pytask.mark.depends_on("out.txt")
178+
def task_second():
179+
assert False
180+
"""
181+
tmp_path.joinpath("task_dummy.py").write_text(textwrap.dedent(source))
182+
183+
session = main({"paths": tmp_path})
184+
node = session.collection_reports[0].node
185+
assert len(node.markers) == 2
186+
assert node.markers[0].name == "skipif"
187+
assert node.markers[0].args == ()
188+
assert node.markers[0].kwargs == {"condition": True, "reason": "No, I am not."}
189+
assert node.markers[1].name == "skipif"
190+
assert node.markers[1].args == ()
191+
assert node.markers[1].kwargs == {"condition": False, "reason": "I am fine"}
192+
193+
assert session.execution_reports[0].success
194+
assert isinstance(session.execution_reports[0].exc_info[1], Skipped)
195+
assert session.execution_reports[1].success
196+
assert isinstance(session.execution_reports[1].exc_info[1], Skipped)
197+
assert session.execution_reports[0].exc_info[1].args[0] == "No, I am not."
198+
199+
105200
@pytest.mark.unit
106201
@pytest.mark.parametrize(
107202
("marker_name", "expectation"),

0 commit comments

Comments
 (0)