duty 1.5.0__py3-none-any.whl → 1.6.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. duty/__init__.py +49 -2
  2. duty/__main__.py +1 -1
  3. duty/_internal/__init__.py +0 -0
  4. duty/_internal/callables/__init__.py +34 -0
  5. duty/{callables → _internal/callables}/_io.py +2 -0
  6. duty/_internal/callables/autoflake.py +132 -0
  7. duty/_internal/callables/black.py +176 -0
  8. duty/_internal/callables/blacken_docs.py +92 -0
  9. duty/_internal/callables/build.py +76 -0
  10. duty/_internal/callables/coverage.py +716 -0
  11. duty/_internal/callables/flake8.py +222 -0
  12. duty/_internal/callables/git_changelog.py +178 -0
  13. duty/_internal/callables/griffe.py +227 -0
  14. duty/_internal/callables/interrogate.py +152 -0
  15. duty/_internal/callables/isort.py +573 -0
  16. duty/_internal/callables/mkdocs.py +256 -0
  17. duty/_internal/callables/mypy.py +496 -0
  18. duty/_internal/callables/pytest.py +475 -0
  19. duty/_internal/callables/ruff.py +399 -0
  20. duty/_internal/callables/safety.py +82 -0
  21. duty/_internal/callables/ssort.py +38 -0
  22. duty/_internal/callables/twine.py +284 -0
  23. duty/_internal/cli.py +322 -0
  24. duty/_internal/collection.py +246 -0
  25. duty/_internal/context.py +111 -0
  26. duty/{debug.py → _internal/debug.py} +13 -15
  27. duty/_internal/decorator.py +111 -0
  28. duty/_internal/exceptions.py +12 -0
  29. duty/_internal/tools/__init__.py +41 -0
  30. duty/{tools → _internal/tools}/_autoflake.py +8 -4
  31. duty/{tools → _internal/tools}/_base.py +15 -2
  32. duty/{tools → _internal/tools}/_black.py +5 -5
  33. duty/{tools → _internal/tools}/_blacken_docs.py +10 -5
  34. duty/{tools → _internal/tools}/_build.py +4 -4
  35. duty/{tools → _internal/tools}/_coverage.py +8 -4
  36. duty/{tools → _internal/tools}/_flake8.py +10 -12
  37. duty/{tools → _internal/tools}/_git_changelog.py +8 -4
  38. duty/{tools → _internal/tools}/_griffe.py +8 -4
  39. duty/{tools → _internal/tools}/_interrogate.py +4 -4
  40. duty/{tools → _internal/tools}/_isort.py +8 -6
  41. duty/{tools → _internal/tools}/_mkdocs.py +8 -4
  42. duty/{tools → _internal/tools}/_mypy.py +5 -5
  43. duty/{tools → _internal/tools}/_pytest.py +8 -4
  44. duty/{tools → _internal/tools}/_ruff.py +11 -5
  45. duty/{tools → _internal/tools}/_safety.py +13 -8
  46. duty/{tools → _internal/tools}/_ssort.py +10 -6
  47. duty/{tools → _internal/tools}/_twine.py +11 -5
  48. duty/_internal/tools/_yore.py +96 -0
  49. duty/_internal/validation.py +266 -0
  50. duty/callables/__init__.py +4 -4
  51. duty/callables/autoflake.py +11 -126
  52. duty/callables/black.py +12 -171
  53. duty/callables/blacken_docs.py +11 -86
  54. duty/callables/build.py +12 -71
  55. duty/callables/coverage.py +12 -711
  56. duty/callables/flake8.py +12 -217
  57. duty/callables/git_changelog.py +12 -173
  58. duty/callables/griffe.py +12 -222
  59. duty/callables/interrogate.py +12 -147
  60. duty/callables/isort.py +12 -568
  61. duty/callables/mkdocs.py +12 -251
  62. duty/callables/mypy.py +11 -490
  63. duty/callables/pytest.py +12 -470
  64. duty/callables/ruff.py +12 -394
  65. duty/callables/safety.py +11 -76
  66. duty/callables/ssort.py +12 -33
  67. duty/callables/twine.py +12 -279
  68. duty/cli.py +10 -316
  69. duty/collection.py +12 -228
  70. duty/completions.bash +9 -9
  71. duty/context.py +12 -107
  72. duty/decorator.py +12 -108
  73. duty/exceptions.py +13 -10
  74. duty/tools.py +63 -0
  75. duty/validation.py +12 -262
  76. {duty-1.5.0.dist-info → duty-1.6.1.dist-info}/METADATA +4 -3
  77. duty-1.6.1.dist-info/RECORD +81 -0
  78. {duty-1.5.0.dist-info → duty-1.6.1.dist-info}/WHEEL +1 -1
  79. {duty-1.5.0.dist-info → duty-1.6.1.dist-info}/entry_points.txt +1 -1
  80. duty/tools/__init__.py +0 -48
  81. duty-1.5.0.dist-info/RECORD +0 -54
  82. {duty-1.5.0.dist-info → duty-1.6.1.dist-info}/licenses/LICENSE +0 -0
duty/collection.py CHANGED
@@ -1,233 +1,17 @@
1
- """Module containing all the logic."""
1
+ """Deprecated. Import from `duty` directly."""
2
2
 
3
- from __future__ import annotations
3
+ # YORE: Bump 2: Remove file.
4
4
 
5
- import inspect
6
- import sys
7
- from copy import deepcopy
8
- from importlib import util as importlib_util
9
- from typing import Any, Callable, ClassVar, Union
5
+ import warnings
6
+ from typing import Any
10
7
 
11
- from duty.context import Context
8
+ from duty._internal import collection
12
9
 
13
- DutyListType = list[Union[str, Callable, "Duty"]]
14
- default_duties_file = "duties.py"
15
10
 
16
-
17
- class Duty:
18
- """The main duty class."""
19
-
20
- default_options: ClassVar[dict[str, Any]] = {}
21
-
22
- def __init__(
23
- self,
24
- name: str,
25
- description: str,
26
- function: Callable,
27
- collection: Collection | None = None,
28
- aliases: set | None = None,
29
- pre: DutyListType | None = None,
30
- post: DutyListType | None = None,
31
- opts: dict[str, Any] | None = None,
32
- ) -> None:
33
- """Initialize the duty.
34
-
35
- Parameters:
36
- name: The duty name.
37
- description: The duty description.
38
- function: The duty function.
39
- collection: The collection on which to attach this duty.
40
- aliases: A list of aliases for this duty.
41
- pre: A list of duties to run before this one.
42
- post: A list of duties to run after this one.
43
- opts: Options used to create the context instance.
44
- """
45
- self.name = name
46
- self.description = description
47
- self.function = function
48
- self.aliases = aliases or set()
49
- self.pre = pre or []
50
- self.post = post or []
51
- self.options = opts or self.default_options
52
- self.options_override: dict = {}
53
-
54
- self.collection: Collection | None = None
55
- if collection:
56
- collection.add(self)
57
-
58
- @property
59
- def context(self) -> Context:
60
- """Return a new context instance.
61
-
62
- Returns:
63
- A new context instance.
64
- """
65
- return Context(self.options, self.options_override)
66
-
67
- def run(self, *args: Any, **kwargs: Any) -> None:
68
- """Run the duty.
69
-
70
- This is just a shortcut for `duty(duty.context, *args, **kwargs)`.
71
-
72
- Parameters:
73
- args: Positional arguments passed to the function.
74
- kwargs: Keyword arguments passed to the function.
75
- """
76
- self(self.context, *args, **kwargs)
77
-
78
- def run_duties(self, context: Context, duties_list: DutyListType) -> None:
79
- """Run a list of duties.
80
-
81
- Parameters:
82
- context: The context to use.
83
- duties_list: The list of duties to run.
84
-
85
- Raises:
86
- RuntimeError: When a duty name is given to pre or post duties.
87
- Indeed, without a parent collection, it is impossible
88
- to find another duty by its name.
89
- """
90
- for duty_item in duties_list:
91
- if callable(duty_item):
92
- # Item is a proper duty, or a callable: run it.
93
- duty_item(context)
94
- elif isinstance(duty_item, str):
95
- # Item is a reference to a duty.
96
- if self.collection is None:
97
- raise RuntimeError(f"Can't find duty by name without a collection ({duty_item})")
98
- # Get the duty and run it.
99
- self.collection.get(duty_item)(context)
100
-
101
- def __call__(self, context: Context, *args: Any, **kwargs: Any) -> None:
102
- """Run the duty function.
103
-
104
- Parameters:
105
- context: The context to use.
106
- args: Positional arguments passed to the function.
107
- kwargs: Keyword arguments passed to the function.
108
- """
109
- self.run_duties(context, self.pre)
110
- self.function(context, *args, **kwargs)
111
- self.run_duties(context, self.post)
112
-
113
-
114
- class Collection:
115
- """A collection of duties.
116
-
117
- Attributes:
118
- path: The path to the duties file.
119
- duties: The list of duties.
120
- aliases: A dictionary of aliases pointing to their respective duties.
121
- """
122
-
123
- def __init__(self, path: str = default_duties_file) -> None:
124
- """Initialize the collection.
125
-
126
- Parameters:
127
- path: The path to the duties file.
128
- """
129
- self.path = path
130
- self.duties: dict[str, Duty] = {}
131
- self.aliases: dict[str, Duty] = {}
132
-
133
- def clear(self) -> None:
134
- """Clear the collection."""
135
- self.duties.clear()
136
- self.aliases.clear()
137
-
138
- def names(self) -> list[str]:
139
- """Return the list of duties names and aliases.
140
-
141
- Returns:
142
- The list of duties names and aliases.
143
- """
144
- return list(self.duties.keys()) + list(self.aliases.keys())
145
-
146
- def completion_candidates(self, args: tuple[str, ...]) -> list[str]:
147
- """Find shell completion candidates within this collection.
148
-
149
- Returns:
150
- The list of shell completion candidates, sorted alphabetically.
151
- """
152
- # Find last duty name in args.
153
- name = None
154
- names = set(self.names())
155
- for arg in reversed(args):
156
- if arg in names:
157
- name = arg
158
- break
159
-
160
- completion_names = sorted(names)
161
-
162
- # If no duty found, return names.
163
- if name is None:
164
- return completion_names
165
-
166
- params = [
167
- f"{param.name}="
168
- for param in inspect.signature(self.get(name).function).parameters.values()
169
- if param.kind is not param.VAR_POSITIONAL
170
- ][1:]
171
-
172
- # If duty found, return names *and* duty parameters.
173
- return completion_names + sorted(params)
174
-
175
- def get(self, name_or_alias: str) -> Duty:
176
- """Get a duty by its name or alias.
177
-
178
- Parameters:
179
- name_or_alias: The name or alias of the duty.
180
-
181
- Returns:
182
- A duty.
183
- """
184
- try:
185
- return self.duties[name_or_alias]
186
- except KeyError:
187
- return self.aliases[name_or_alias]
188
-
189
- def format_help(self) -> str:
190
- """Format a message listing the duties.
191
-
192
- Returns:
193
- A string listing the duties and their summary.
194
- """
195
- lines = []
196
- # 20 makes the summary aligned with options description
197
- longest_name = max(*(len(name) for name in self.duties), 20)
198
- for name, duty in self.duties.items():
199
- description = duty.description.split("\n")[0]
200
- lines.append(f"{name:{longest_name}} {description}")
201
- return "\n".join(lines)
202
-
203
- def add(self, duty: Duty) -> None:
204
- """Add a duty to the collection.
205
-
206
- Parameters:
207
- duty: The duty to add.
208
- """
209
- if duty.collection is not None:
210
- # we must copy the duty to be able to add it
211
- # in multiple collections
212
- duty = deepcopy(duty)
213
- duty.collection = self
214
- self.duties[duty.name] = duty
215
- for alias in duty.aliases:
216
- self.aliases[alias] = duty
217
-
218
- def load(self, path: str | None = None) -> None:
219
- """Load duties from a Python file.
220
-
221
- Parameters:
222
- path: The path to the Python file to load.
223
- Uses the collection's path by default.
224
- """
225
- path = path or self.path
226
- spec = importlib_util.spec_from_file_location("duty.duties", path)
227
- if spec:
228
- duties = importlib_util.module_from_spec(spec)
229
- sys.modules["duty.duties"] = duties
230
- spec.loader.exec_module(duties) # type: ignore[union-attr]
231
- declared_duties = inspect.getmembers(duties, lambda member: isinstance(member, Duty))
232
- for _, duty in declared_duties:
233
- self.add(duty)
11
+ def __getattr__(name: str) -> Any:
12
+ warnings.warn(
13
+ "Importing from `duty.collection` is deprecated. Import from `duty` directly.",
14
+ DeprecationWarning,
15
+ stacklevel=2,
16
+ )
17
+ return getattr(collection, name)
duty/completions.bash CHANGED
@@ -1,18 +1,18 @@
1
- # Invoke tab-completion script to be sourced with Bash shell.
2
- # Known to work on Bash 3.x, untested on 4.x.
1
+ # Taken and adapted from pyinvoke:
2
+ # Copyright (c) 2020 Jeff Forcier.
3
+ # All rights reserved.
3
4
 
4
5
  _complete_duty() {
5
6
  local candidates
6
7
 
7
- # COMP_WORDS contains the entire command string up til now (including
8
- # program name).
9
- # We hand it to Invoke so it can figure out the current context: spit back
10
- # core options, task names, the current task's options, or some combo.
8
+ # COMP_WORDS contains the entire command string up til now (including # program name).
9
+ # We hand it to Invoke so it can figure out the current context:
10
+ # spit back core options, task names, the current task's options, or some combo.
11
11
  candidates=$(duty --complete -- "${COMP_WORDS[@]}")
12
12
 
13
- # `compgen -W` takes list of valid options & a partial word & spits back
14
- # possible matches. Necessary for any partial word completions (vs
15
- # completions performed when no partial words are present).
13
+ # `compgen -W` takes list of valid options & a partial word & spits back possible matches.
14
+ # Necessary for any partial word completions
15
+ # (vs. completions performed when no partial words are present).
16
16
  #
17
17
  # $2 is the current word or token being tabbed on, either empty string or a
18
18
  # partial word, and thus wants to be compgen'd to arrive at some subset of
duty/context.py CHANGED
@@ -1,112 +1,17 @@
1
- """Module containing the context definition."""
1
+ """Deprecated. Import from `duty` directly."""
2
2
 
3
- from __future__ import annotations
3
+ # YORE: Bump 2: Remove file.
4
4
 
5
- import os
6
- from contextlib import contextmanager, suppress
7
- from typing import TYPE_CHECKING, Any, Callable, Union
5
+ import warnings
6
+ from typing import Any
8
7
 
9
- from failprint.runners import run as failprint_run
8
+ from duty._internal import context
10
9
 
11
- from duty.exceptions import DutyFailure
12
- from duty.tools import Tool
13
10
 
14
- if TYPE_CHECKING:
15
- from collections.abc import Iterator
16
-
17
- CmdType = Union[str, list[str], Callable]
18
-
19
-
20
- class Context:
21
- """A simple context class.
22
-
23
- Context instances are passed to functions decorated with `duty`.
24
- """
25
-
26
- def __init__(self, options: dict[str, Any], options_override: dict[str, Any] | None = None) -> None:
27
- """Initialize the context.
28
-
29
- Parameters:
30
- options: Base options specified in `@duty(**options)`.
31
- options_override: Options that override `run` and `@duty` options.
32
- This argument is used to allow users to override options from the CLI or environment.
33
- """
34
- self._options = options
35
- self._option_stack: list[dict[str, Any]] = []
36
- self._options_override = options_override or {}
37
-
38
- @contextmanager
39
- def cd(self, directory: str) -> Iterator:
40
- """Change working directory as a context manager.
41
-
42
- Parameters:
43
- directory: The directory to go into.
44
-
45
- Yields:
46
- Nothing.
47
- """
48
- if not directory:
49
- yield
50
- return
51
- old_wd = os.getcwd()
52
- os.chdir(directory)
53
- try:
54
- yield
55
- finally:
56
- os.chdir(old_wd)
57
-
58
- def run(self, cmd: CmdType, **options: Any) -> str:
59
- """Run a command in a subprocess or a Python callable.
60
-
61
- Parameters:
62
- cmd: A command or a Python callable.
63
- options: Options passed to `failprint` functions.
64
-
65
- Raises:
66
- DutyFailure: When the exit code / function result is greather than 0.
67
-
68
- Returns:
69
- The output of the command.
70
- """
71
- final_options = dict(self._options)
72
- final_options.update(options)
73
-
74
- if "command" not in final_options and isinstance(cmd, Tool):
75
- with suppress(ValueError):
76
- final_options["command"] = cmd.cli_command
77
-
78
- allow_overrides = final_options.pop("allow_overrides", True)
79
- workdir = final_options.pop("workdir", None)
80
-
81
- if allow_overrides:
82
- final_options.update(self._options_override)
83
-
84
- with self.cd(workdir):
85
- try:
86
- result = failprint_run(cmd, **final_options)
87
- except KeyboardInterrupt as ki:
88
- raise DutyFailure(130) from ki
89
-
90
- if result.code:
91
- raise DutyFailure(result.code)
92
-
93
- return result.output
94
-
95
- @contextmanager
96
- def options(self, **opts: Any) -> Iterator:
97
- """Change options as a context manager.
98
-
99
- Can be nested as will, previous options will pop once out of the with clause.
100
-
101
- Parameters:
102
- **opts: Options used in `run`.
103
-
104
- Yields:
105
- Nothing.
106
- """
107
- self._option_stack.append(self._options)
108
- self._options = {**self._options, **opts}
109
- try:
110
- yield
111
- finally:
112
- self._options = self._option_stack.pop()
11
+ def __getattr__(name: str) -> Any:
12
+ warnings.warn(
13
+ "Importing from `duty.context` is deprecated. Import from `duty` directly.",
14
+ DeprecationWarning,
15
+ stacklevel=2,
16
+ )
17
+ return getattr(context, name)
duty/decorator.py CHANGED
@@ -1,113 +1,17 @@
1
- """Module containing the decorator provided to users."""
1
+ """Deprecated. Import from `duty` directly."""
2
2
 
3
- from __future__ import annotations
3
+ # YORE: Bump 2: Remove file.
4
4
 
5
- import inspect
6
- from functools import wraps
7
- from typing import TYPE_CHECKING, Any, Callable, overload
5
+ import warnings
6
+ from typing import Any
8
7
 
9
- from duty.collection import Duty, DutyListType
8
+ from duty._internal import decorator
10
9
 
11
- if TYPE_CHECKING:
12
- from collections.abc import Iterable
13
10
 
14
- from duty.context import Context
15
-
16
-
17
- def _skip(func: Callable, reason: str) -> Callable:
18
- @wraps(func)
19
- def wrapper(ctx: Context, *args, **kwargs) -> None: # noqa: ARG001,ANN002,ANN003
20
- ctx.run(lambda: True, title=reason)
21
-
22
- return wrapper
23
-
24
-
25
- def create_duty(
26
- func: Callable,
27
- *,
28
- name: str | None = None,
29
- aliases: Iterable[str] | None = None,
30
- pre: DutyListType | None = None,
31
- post: DutyListType | None = None,
32
- skip_if: bool = False,
33
- skip_reason: str | None = None,
34
- **opts: Any,
35
- ) -> Duty:
36
- """Register a duty in the collection.
37
-
38
- Parameters:
39
- func: The callable to register as a duty.
40
- name: The duty name.
41
- aliases: A set of aliases for this duty.
42
- pre: Pre-duties.
43
- post: Post-duties.
44
- skip_if: Skip running the duty if the given condition is met.
45
- skip_reason: Custom message when skipping.
46
- opts: Options passed to the context.
47
-
48
- Returns:
49
- The registered duty.
50
- """
51
- aliases = set(aliases) if aliases else set()
52
- name = name or func.__name__
53
- dash_name = name.replace("_", "-")
54
- if name != dash_name:
55
- aliases.add(name)
56
- name = dash_name
57
- description = inspect.getdoc(func) or ""
58
- if skip_if:
59
- func = _skip(func, skip_reason or f"{dash_name}: skipped")
60
- duty = Duty(name, description, func, aliases=aliases, pre=pre, post=post, opts=opts)
61
- duty.__name__ = name # type: ignore[attr-defined]
62
- duty.__doc__ = description
63
- duty.__wrapped__ = func # type: ignore[attr-defined]
64
- return duty
65
-
66
-
67
- @overload
68
- def duty(**kwargs: Any) -> Callable[[Callable], Duty]: ...
69
-
70
-
71
- @overload
72
- def duty(func: Callable) -> Duty: ...
73
-
74
-
75
- def duty(*args: Any, **kwargs: Any) -> Callable | Duty:
76
- """Decorate a callable to transform it and register it as a duty.
77
-
78
- Parameters:
79
- args: One callable.
80
- kwargs: Context options.
81
-
82
- Raises:
83
- ValueError: When the decorator is misused.
84
-
85
- Examples:
86
- Decorate a function:
87
-
88
- ```python
89
- @duty
90
- def clean(ctx):
91
- ctx.run("rm -rf build", silent=True)
92
- ```
93
-
94
- Pass options to the context:
95
-
96
- ```python
97
- @duty(silent=True)
98
- def clean(ctx):
99
- ctx.run("rm -rf build") # silent=True is implied
100
- ```
101
-
102
- Returns:
103
- A duty when used without parentheses, a decorator otherwise.
104
- """
105
- if args:
106
- if len(args) > 1:
107
- raise ValueError("The duty decorator accepts only one positional argument")
108
- return create_duty(args[0], **kwargs)
109
-
110
- def decorator(func: Callable) -> Duty:
111
- return create_duty(func, **kwargs)
112
-
113
- return decorator
11
+ def __getattr__(name: str) -> Any:
12
+ warnings.warn(
13
+ "Importing from `duty.decorator` is deprecated. Import from `duty` directly.",
14
+ DeprecationWarning,
15
+ stacklevel=2,
16
+ )
17
+ return getattr(decorator, name)
duty/exceptions.py CHANGED
@@ -1,14 +1,17 @@
1
- """Module containing the project's exceptions."""
1
+ """Deprecated. Import from `duty` directly."""
2
2
 
3
+ # YORE: Bump 2: Remove file.
3
4
 
4
- class DutyFailure(Exception): # noqa: N818
5
- """An exception raised when a duty fails."""
5
+ import warnings
6
+ from typing import Any
6
7
 
7
- def __init__(self, code: int) -> None:
8
- """Initialize the object.
8
+ from duty._internal import exceptions
9
9
 
10
- Parameters:
11
- code: The exit code of a command.
12
- """
13
- super().__init__(self)
14
- self.code = code
10
+
11
+ def __getattr__(name: str) -> Any:
12
+ warnings.warn(
13
+ "Importing from `duty.exceptions` is deprecated. Import from `duty` directly.",
14
+ DeprecationWarning,
15
+ stacklevel=2,
16
+ )
17
+ return getattr(exceptions, name)
duty/tools.py ADDED
@@ -0,0 +1,63 @@
1
+ """Our collection of tools."""
2
+
3
+ import warnings
4
+ from typing import Any
5
+
6
+ from duty._internal.tools import (
7
+ autoflake,
8
+ black,
9
+ blacken_docs,
10
+ build,
11
+ coverage,
12
+ flake8,
13
+ git_changelog,
14
+ griffe,
15
+ interrogate,
16
+ isort,
17
+ mkdocs,
18
+ mypy,
19
+ pytest,
20
+ ruff,
21
+ safety,
22
+ ssort,
23
+ twine,
24
+ yore,
25
+ )
26
+
27
+ __all__ = [
28
+ "autoflake",
29
+ "black",
30
+ "blacken_docs",
31
+ "build",
32
+ "coverage",
33
+ "flake8",
34
+ "git_changelog",
35
+ "griffe",
36
+ "interrogate",
37
+ "isort",
38
+ "mkdocs",
39
+ "mypy",
40
+ "pytest",
41
+ "ruff",
42
+ "safety",
43
+ "ssort",
44
+ "twine",
45
+ "yore",
46
+ ]
47
+
48
+
49
+ # YORE: Bump 2: Remove block.
50
+ def __getattr__(name: str) -> Any:
51
+ """Return the tool or lazy object by name."""
52
+ from failprint import lazy # noqa: F401,PLC0415
53
+
54
+ from duty._internal.tools._base import LazyStderr, LazyStdout, Tool # noqa: F401,PLC0415
55
+
56
+ if name in locals():
57
+ warnings.warn(
58
+ f"Importing `{name}` from `duty.tools` is deprecated, import directly from `duty` instead.",
59
+ DeprecationWarning,
60
+ stacklevel=2,
61
+ )
62
+ return locals()[name]
63
+ raise AttributeError(f"module '{__name__}' has no attribute '{name}'")