Skip to content

Commit 1e66f75

Browse files
authored
Deprecate decorators for depends_on and produces. (#398)
1 parent 8f875ed commit 1e66f75

9 files changed

Lines changed: 157 additions & 23 deletions

File tree

MANIFEST.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
include CITATION
22
include LICENSE
33

4+
recursive-include src *.pyi
45
recursive-include src py.typed
56

67
exclude .coveragerc

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,3 +92,4 @@ markers = [
9292
"end_to_end: Flag for tests that cover the whole program.",
9393
]
9494
norecursedirs = [".idea", ".tox"]
95+
filterwarnings = ["ignore:'@pytask.mark.*. is deprecated:DeprecationWarning"]

src/_pytask/collect_utils.py

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import itertools
55
import uuid
6+
import warnings
67
from pathlib import Path
78
from typing import Any
89
from typing import Callable
@@ -21,6 +22,7 @@
2122
from _pytask.nodes import PythonNode
2223
from _pytask.shared import find_duplicates
2324
from _pytask.task_utils import parse_keyword_arguments_from_signature_defaults
25+
from _pytask.tree_util import PyTree
2426
from _pytask.tree_util import tree_leaves
2527
from _pytask.tree_util import tree_map
2628
from _pytask.tree_util import tree_map_with_path
@@ -42,9 +44,7 @@
4244
]
4345

4446

45-
def depends_on(
46-
objects: Any | Iterable[Any] | dict[Any, Any]
47-
) -> Any | Iterable[Any] | dict[Any, Any]:
47+
def depends_on(objects: PyTree[Any]) -> PyTree[Any]:
4848
"""Specify dependencies for a task.
4949
5050
Parameters
@@ -58,9 +58,7 @@ def depends_on(
5858
return objects
5959

6060

61-
def produces(
62-
objects: Any | Iterable[Any] | dict[Any, Any]
63-
) -> Any | Iterable[Any] | dict[Any, Any]:
61+
def produces(objects: PyTree[Any]) -> PyTree[Any]:
6462
"""Specify products of a task.
6563
6664
Parameters
@@ -342,6 +340,18 @@ def _find_args_with_node_annotation(func: Callable[..., Any]) -> dict[str, MetaN
342340
Read more about products in the documentation: https://tinyurl.com/yrezszr4.
343341
"""
344342

343+
_WARNING_PRODUCES_AS_KWARG = """Using 'produces' as an argument name to specify \
344+
products is deprecated and won't be available in pytask v0.5. Instead, use the product \
345+
annotation, described in this tutorial: https://tinyurl.com/yrezszr4.
346+
347+
from typing_extensions import Annotated
348+
from pytask import Product
349+
350+
def task_example(produces: Annotated[..., Product]):
351+
...
352+
353+
"""
354+
345355

346356
def parse_products_from_task_function(
347357
session: Session, path: Path, name: str, obj: Any
@@ -369,8 +379,14 @@ def parse_products_from_task_function(
369379
signature_defaults = parse_keyword_arguments_from_signature_defaults(obj)
370380
kwargs = {**signature_defaults, **task_kwargs}
371381

382+
parameters_with_product_annot = _find_args_with_product_annotation(obj)
383+
372384
# Parse products from task decorated with @task and that uses produces.
373385
if "produces" in kwargs:
386+
if "produces" not in parameters_with_product_annot:
387+
warnings.warn(
388+
_WARNING_PRODUCES_AS_KWARG, category=FutureWarning, stacklevel=1
389+
)
374390
has_produces_argument = True
375391
collected_products = tree_map_with_path(
376392
lambda p, x: _collect_product(
@@ -384,7 +400,6 @@ def parse_products_from_task_function(
384400
)
385401
out = {"produces": collected_products}
386402

387-
parameters_with_product_annot = _find_args_with_product_annotation(obj)
388403
if parameters_with_product_annot:
389404
has_annotation = True
390405
for parameter_name in parameters_with_product_annot:
@@ -436,6 +451,11 @@ def _find_args_with_product_annotation(func: Callable[..., Any]) -> list[str]:
436451
"""
437452

438453

454+
_WARNING_STRING_DEPRECATED = """Using strings to specify a {kind} is deprecated. Pass \
455+
a 'pathlib.Path' instead with 'Path("{node}")'.
456+
"""
457+
458+
439459
def _collect_decorator_nodes(
440460
session: Session, path: Path, name: str, node_info: NodeInfo
441461
) -> dict[str, MetaNode]:
@@ -448,23 +468,26 @@ def _collect_decorator_nodes(
448468
449469
"""
450470
node = node_info.value
471+
kind = {"depends_on": "dependency", "produces": "product"}.get(node_info.arg_name)
451472

452473
if not isinstance(node, (str, Path)):
453474
raise NodeNotCollectedError(
454475
_ERROR_WRONG_TYPE_DECORATOR.format(node=node, node_type=type(node))
455476
)
456477

457478
if isinstance(node, str):
479+
warnings.warn(
480+
_WARNING_STRING_DEPRECATED.format(kind=kind, node=node),
481+
category=FutureWarning,
482+
stacklevel=1,
483+
)
458484
node = Path(node)
459485
node_info = node_info._replace(value=node)
460486

461487
collected_node = session.hook.pytask_collect_node(
462488
session=session, path=path, node_info=node_info
463489
)
464490
if collected_node is None:
465-
kind = {"depends_on": "dependency", "produces": "product"}.get(
466-
node_info.arg_name
467-
)
468491
raise NodeNotCollectedError(
469492
f"{node!r} cannot be parsed as a {kind} for task {name!r} in {path!r}."
470493
)
@@ -525,10 +548,9 @@ def _collect_product(
525548
# The parameter defaults only support Path objects.
526549
if not isinstance(node, Path) and not is_string_allowed:
527550
raise ValueError(
528-
"If you use 'produces' as a function argument of a task and pass values as "
529-
"function defaults, it can only accept values of type 'pathlib.Path' or "
530-
"the same value nested in tuples, lists, and dictionaries. Here, "
531-
f"{node!r} has type {type(node)}."
551+
"If you declare products with 'Annotated[..., Product]', only values of "
552+
"type 'pathlib.Path' optionally nested in tuples, lists, and "
553+
f"dictionaries are allowed. Here, {node!r} has type {type(node)}."
532554
)
533555

534556
if isinstance(node, str):

src/_pytask/mark/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@
3939
"MarkDecorator",
4040
"MarkGenerator",
4141
"ParseError",
42+
"select_by_keyword",
43+
"select_by_mark",
4244
]
4345

4446

src/_pytask/mark/__init__.pyi

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
from pathlib import Path
2+
from typing_extensions import deprecated
3+
from _pytask.mark.expression import Expression
4+
from _pytask.mark.expression import ParseError
5+
from _pytask.mark.structures import Mark
6+
from _pytask.mark.structures import MarkDecorator
7+
from _pytask.mark.structures import MarkGenerator
8+
from _pytask.tree_util import PyTree
9+
10+
from _pytask.session import Session
11+
import networkx as nx
12+
13+
def select_by_keyword(session: Session, dag: nx.DiGraph) -> set[str]: ...
14+
def select_by_mark(session: Session, dag: nx.DiGraph) -> set[str]: ...
15+
16+
class MARK_GEN: # noqa: N801
17+
@deprecated(
18+
"'@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
19+
category=DeprecationWarning,
20+
stacklevel=1,
21+
)
22+
@staticmethod
23+
def produces(objects: PyTree[str | Path]) -> None: ...
24+
@deprecated(
25+
"'@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
26+
category=DeprecationWarning,
27+
stacklevel=1,
28+
)
29+
@staticmethod
30+
def depends_on(objects: PyTree[str | Path]) -> None: ...
31+
32+
__all__ = [
33+
"Expression",
34+
"MARK_GEN",
35+
"Mark",
36+
"MarkDecorator",
37+
"MarkGenerator",
38+
"ParseError",
39+
"select_by_keyword",
40+
"select_by_mark",
41+
]

src/_pytask/mark/structures.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,12 @@ def store_mark(obj: Callable[..., Any], mark: Mark) -> None:
167167
)
168168

169169

170+
_DEPRECATION_DECORATOR = """'@pytask.mark.{}' is deprecated starting pytask \
171+
v0.4.0 and will be removed in v0.5.0. To upgrade your project to the new syntax, read \
172+
the tutorial on product and dependencies: https://tinyurl.com/yrezszr4.
173+
"""
174+
175+
170176
class MarkGenerator:
171177
"""Factory for :class:`MarkDecorator` objects.
172178
@@ -191,6 +197,13 @@ def __getattr__(self, name: str) -> MarkDecorator | Any:
191197
if name[0] == "_":
192198
raise AttributeError("Marker name must NOT start with underscore")
193199

200+
if name in ("depends_on", "produces"):
201+
warnings.warn(
202+
_DEPRECATION_DECORATOR.format(name),
203+
category=DeprecationWarning,
204+
stacklevel=1,
205+
)
206+
194207
# If the name is not in the set of known marks after updating,
195208
# then it really is time to issue a warning or an error.
196209
if self.config is not None and name not in self.config["markers"]:

src/_pytask/profile.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ class _ExportFormats(enum.Enum):
4747
CSV = "csv"
4848

4949

50-
class Runtime(BaseTable): # type: ignore[valid-type, misc]
50+
class Runtime(BaseTable):
5151
"""Record of runtimes of tasks."""
5252

5353
__tablename__ = "runtime"

tests/test_collect.py

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -312,7 +312,7 @@ def task_my_task():
312312

313313

314314
@pytest.mark.end_to_end()
315-
def test_collect_string_product_with_task_decorator(tmp_path):
315+
def test_collect_string_product_with_task_decorator(runner, tmp_path):
316316
source = """
317317
import pytask
318318
@@ -321,25 +321,38 @@ def task_write_text(produces="out.txt"):
321321
produces.touch()
322322
"""
323323
tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source))
324-
session = main({"paths": tmp_path})
325-
assert session.exit_code == ExitCode.OK
324+
result = runner.invoke(cli, [tmp_path.as_posix()])
325+
assert result.exit_code == ExitCode.OK
326326
assert tmp_path.joinpath("out.txt").exists()
327327

328328

329329
@pytest.mark.end_to_end()
330-
def test_collect_string_product_as_function_default(tmp_path):
330+
def test_collect_string_product_as_function_default(runner, tmp_path):
331331
source = """
332-
import pytask
333-
334332
def task_write_text(produces="out.txt"):
335333
produces.touch()
336334
"""
337335
tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source))
338-
session = main({"paths": tmp_path})
339-
assert session.exit_code == ExitCode.OK
336+
result = runner.invoke(cli, [tmp_path.as_posix()])
337+
assert result.exit_code == ExitCode.OK
340338
assert tmp_path.joinpath("out.txt").exists()
341339

342340

341+
@pytest.mark.end_to_end()
342+
def test_collect_string_product_raises_error_with_annotation(runner, tmp_path):
343+
source = """
344+
from pytask import Product
345+
from typing_extensions import Annotated
346+
347+
def task_write_text(out: Annotated[str, Product] = "out.txt") -> None:
348+
out.touch()
349+
"""
350+
tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source))
351+
result = runner.invoke(cli, [tmp_path.as_posix()])
352+
assert result.exit_code == ExitCode.COLLECTION_FAILED
353+
assert "If you declare products with 'Annotated[..., Product]'" in result.output
354+
355+
343356
@pytest.mark.end_to_end()
344357
def test_product_cannot_mix_different_product_types(tmp_path):
345358
source = """
@@ -389,3 +402,23 @@ def task_example(
389402
report = session.collection_reports[0]
390403
assert report.outcome == CollectionOutcome.FAIL
391404
assert "The task uses multiple" in str(report.exc_info[1])
405+
406+
407+
@pytest.mark.end_to_end()
408+
def test_deprecation_warning_for_strings_in_depends_on(runner, tmp_path):
409+
source = """
410+
import pytask
411+
from pathlib import Path
412+
413+
@pytask.mark.depends_on("in.txt")
414+
@pytask.mark.produces("out.txt")
415+
def task_write_text(depends_on, produces):
416+
produces.touch()
417+
"""
418+
tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source))
419+
tmp_path.joinpath("in.txt").touch()
420+
421+
result = runner.invoke(cli, [tmp_path.as_posix()])
422+
assert "FutureWarning" in result.output
423+
assert "Using strings to specify a dependency" in result.output
424+
assert "Using strings to specify a product" in result.output

tests/test_mark.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import subprocess
34
import sys
45
import textwrap
56

@@ -354,3 +355,23 @@ def task_example():
354355
assert result.exit_code == ExitCode.OK
355356
assert "1 Succeeded" in result.output
356357
assert "Warnings" in result.output
358+
359+
360+
@pytest.mark.end_to_end()
361+
def test_deprecation_warnings_for_decorators(tmp_path):
362+
source = """
363+
import pytask
364+
365+
@pytask.mark.depends_on("in.txt")
366+
@pytask.mark.produces("out.txt")
367+
def task_write_text(depends_on, produces):
368+
...
369+
"""
370+
tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source))
371+
tmp_path.joinpath("in.txt").touch()
372+
373+
result = subprocess.run(
374+
("pytest", tmp_path.joinpath("task_module.py").as_posix()), capture_output=True
375+
)
376+
assert b"DeprecationWarning: '@pytask.mark.depends_on'" in result.stdout
377+
assert b"DeprecationWarning: '@pytask.mark.produces'" in result.stdout

0 commit comments

Comments
 (0)