/
structures.py
239 lines (176 loc) · 7.31 KB
/
structures.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
from __future__ import annotations
import warnings
from typing import Any
from typing import Callable
from typing import Iterable
from typing import Mapping
from _pytask.mark_utils import get_all_marks
from _pytask.models import CollectionMetadata
from _pytask.typing import is_task_function
from attrs import define
from attrs import field
from attrs import validators
@define(frozen=True)
class Mark:
"""A class for a mark containing the name, positional and keyword arguments."""
name: str
"""str: Name of the mark."""
args: tuple[Any, ...]
"""Tuple[Any]: Positional arguments of the mark decorator."""
kwargs: Mapping[str, Any]
"""Mapping[str, Any]: Keyword arguments of the mark decorator."""
def combined_with(self, other: Mark) -> Mark:
"""Return a new Mark which is a combination of this Mark and another Mark.
Combines by appending args and merging kwargs.
Parameters
----------
other : pytask.mark.structures.Mark
The mark to combine with.
Returns
-------
Mark
The new mark which is a combination of two marks.
"""
assert self.name == other.name
return Mark(self.name, self.args + other.args, {**self.kwargs, **other.kwargs})
@define
class MarkDecorator:
"""A decorator for applying a mark on task function.
Decorators are created with :class:`pytask.mark`.
.. code-block:: python
mark1 = pytask.mark.NAME # Simple MarkDecorator
mark2 = pytask.mark.NAME(name1=value) # Parametrized MarkDecorator
and can then be applied as decorators to task functions
.. code-block:: python
@mark2
def task_function():
pass
When a :class:`MarkDecorator` is called it does the following:
1. If called with a single function as its only positional argument and no
additional keyword arguments, it attaches the mark to the function, containing
all the arguments already stored internally in the :class:`MarkDecorator`.
2. When called in any other case, it returns a new :class:`MarkDecorator` instance
with the original :class:`MarkDecorator`'s content updated with the arguments
passed to this call.
Notes
-----
The rules above prevent decorators from storing only a single function or class
reference as their positional argument with no additional keyword or positional
arguments. You can work around this by using :meth:`MarkDecorator.with_args()`.
"""
mark: Mark = field(validator=validators.instance_of(Mark))
@property
def name(self) -> str:
"""Alias for mark.name."""
return self.mark.name
@property
def args(self) -> tuple[Any, ...]:
"""Alias for mark.args."""
return self.mark.args
@property
def kwargs(self) -> Mapping[str, Any]:
"""Alias for mark.kwargs."""
return self.mark.kwargs
def __repr__(self) -> str:
return f"<MarkDecorator {self.mark!r}>"
def with_args(self, *args: Any, **kwargs: Any) -> MarkDecorator:
"""Return a MarkDecorator with extra arguments added.
Unlike calling the MarkDecorator, ``with_args()`` can be used even if the sole
argument is a callable.
"""
mark = Mark(self.name, args, kwargs)
return self.__class__(self.mark.combined_with(mark))
def __call__(self, *args: Any, **kwargs: Any) -> MarkDecorator:
"""Call the MarkDecorator."""
if args and not kwargs:
func = args[0]
if len(args) == 1 and is_task_function(func):
store_mark(func, self.mark)
return func
return self.with_args(*args, **kwargs)
def get_unpacked_marks(obj: Callable[..., Any]) -> list[Mark]:
"""Obtain the unpacked marks that are stored on an object."""
mark_list = get_all_marks(obj)
return normalize_mark_list(mark_list)
def normalize_mark_list(mark_list: Iterable[Mark | MarkDecorator]) -> list[Mark]:
"""Normalize marker decorating helpers to mark objects.
Parameters
----------
mark_list : List[Union[Mark, MarkDecorator]]
Returns
-------
List[Mark]
"""
extracted = [getattr(mark, "mark", mark) for mark in mark_list]
for mark in extracted:
if not isinstance(mark, Mark): # pragma: no cover
msg = f"Got {mark!r} instead of Mark."
raise TypeError(msg)
return [x for x in extracted if isinstance(x, Mark)]
def store_mark(obj: Callable[..., Any], mark: Mark) -> None:
"""Store a Mark on an object.
This is used to implement the Mark declarations/decorators correctly.
"""
assert isinstance(mark, Mark), mark
if hasattr(obj, "pytask_meta"):
obj.pytask_meta.markers = [*get_unpacked_marks(obj), mark]
else:
obj.pytask_meta = CollectionMetadata( # type: ignore[attr-defined]
markers=[mark]
)
_DEPRECATION_DECORATOR = """'@pytask.mark.{}' is deprecated starting pytask \
v0.4.0 and will be removed in v0.5.0. To upgrade your project to the new syntax, read \
the tutorial on product and dependencies: https://tinyurl.com/pytask-deps-prods.
"""
class MarkGenerator:
"""Factory for :class:`MarkDecorator` objects.
Exposed as a :class:`pytask.mark` singleton instance.
Example
-------
>>> import pytask
>>> @pytask.mark.skip
... def task_function():
... pass
applies a 'skip' :class:`Mark` on ``task_function``.
"""
config: dict[str, Any] | None = None
"""Optional[Dict[str, Any]]: The configuration."""
def __getattr__(self, name: str) -> MarkDecorator | Any:
if name[0] == "_":
msg = "Marker name must NOT start with underscore"
raise AttributeError(msg)
if name in ("depends_on", "produces"):
warnings.warn(
_DEPRECATION_DECORATOR.format(name),
category=FutureWarning,
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 name in ("parametrize", "parameterize", "parametrise", "parameterise"):
msg = (
"@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."
)
raise NotImplementedError(msg) from None
if self.config["strict_markers"]:
msg = f"Unknown pytask.mark.{name}."
raise ValueError(msg)
warnings.warn(
f"Unknown pytask.mark.{name} - is this a typo? You can register "
"custom marks to avoid this warning.",
stacklevel=2,
)
if name == "task":
from _pytask.task_utils import task
warnings.warn(
"'@pytask.mark.task' is deprecated starting pytask v0.4.0 and will be "
"removed in v0.5.0. Use '@pytask.task' instead.",
category=FutureWarning,
stacklevel=1,
)
return task
return MarkDecorator(Mark(name, (), {}))
MARK_GEN = MarkGenerator()