nur 0.1.0__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.
nur/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Nur — script discovery and execution engine."""
2
+
3
+ from nur._version import __version__
4
+
5
+ __all__ = ["__version__"]
nur/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.0'
22
+ __version_tuple__ = version_tuple = (0, 1, 0)
23
+
24
+ __commit_id__ = commit_id = None
nur/cli.py ADDED
@@ -0,0 +1,112 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import typer
7
+
8
+ import nur
9
+ from nur.discovery import discover
10
+ from nur.execution import run_direct
11
+ from nur.registry import AmbiguousTaskError, Registry, UnknownTaskError
12
+
13
+ app = typer.Typer(
14
+ add_completion=False, context_settings={"help_option_names": ["-h", "--help"]}
15
+ )
16
+
17
+
18
+ def split_passthrough(argv: list[str]) -> tuple[list[str], list[str]]:
19
+ """Split argv on the first ``--`` into task args and passthrough args."""
20
+ if "--" in argv:
21
+ i = argv.index("--")
22
+ return argv[:i], argv[i + 1 :]
23
+ return list(argv), []
24
+
25
+
26
+ def format_list(registry: Registry) -> str:
27
+ """Render the discovered tasks grouped by provider prefix."""
28
+ lines: list[str] = []
29
+ for prefix, tasks in registry.groups().items():
30
+ lines.append(prefix)
31
+ for task in tasks:
32
+ desc = f" {task.description}" if task.description else ""
33
+ lines.append(f" {task.name}{desc}")
34
+ return "\n".join(lines)
35
+
36
+
37
+ def _version_callback(value: bool) -> None:
38
+ # Eager option: print the version and exit before the command body runs.
39
+ if value:
40
+ typer.echo(nur.__version__)
41
+ raise typer.Exit(0)
42
+
43
+
44
+ @app.command()
45
+ def _run(
46
+ ctx: typer.Context,
47
+ task: str | None = typer.Argument(
48
+ None, help="task to run, e.g. 'test' or 'make:test'; 'list' to list"
49
+ ),
50
+ version: bool = typer.Option( # noqa: ARG001 (consumed by the eager callback)
51
+ False, # noqa: FBT003
52
+ "-V",
53
+ "--version",
54
+ help="print version and exit",
55
+ callback=_version_callback,
56
+ is_eager=True,
57
+ ),
58
+ ) -> None:
59
+ """Discover and run a project task, or launch the TUI when none is given."""
60
+ # Passthrough args (everything after ``--``) are stashed on the context by main().
61
+ extra: list[str] = ctx.obj["extra"] if ctx.obj else []
62
+ cwd = Path.cwd()
63
+
64
+ registry = discover(cwd)
65
+
66
+ if task == "list":
67
+ typer.echo(format_list(registry))
68
+ raise typer.Exit(0)
69
+
70
+ if task is None:
71
+ if registry.is_empty():
72
+ typer.echo("nur: no supported task files found in this directory.")
73
+ raise typer.Exit(0)
74
+ # Lazy import — keeps the direct-run path from paying Textual's import cost.
75
+ from nur.tui.app import launch # noqa: PLC0415
76
+
77
+ raise typer.Exit(launch(registry, cwd))
78
+
79
+ try:
80
+ resolved = registry.resolve(task)
81
+ except AmbiguousTaskError as exc:
82
+ typer.echo(
83
+ f"nur: '{exc.name}' is ambiguous; candidates: {', '.join(exc.candidates)}",
84
+ err=True,
85
+ )
86
+ raise typer.Exit(2) from exc
87
+ except UnknownTaskError as exc:
88
+ msg = f"nur: unknown task '{exc.query}'"
89
+ if exc.suggestions:
90
+ msg += f"; did you mean: {', '.join(exc.suggestions)}?"
91
+ typer.echo(msg, err=True)
92
+ raise typer.Exit(2) from exc
93
+
94
+ raise typer.Exit(run_direct(resolved, extra, cwd))
95
+
96
+
97
+ def main(argv: list[str] | None = None) -> int:
98
+ """Entry point: parse args with Typer, returning an int instead of exiting.
99
+
100
+ Typer/Click raise ``SystemExit`` for help, version, and every command exit;
101
+ catching it here keeps ``main``'s int contract so it stays usable from tests
102
+ and embedders. Passthrough args are handed to the command via ``ctx.obj``.
103
+ """
104
+ raw = sys.argv[1:] if argv is None else argv
105
+ left, extra = split_passthrough(raw)
106
+ try:
107
+ app(args=left, prog_name="nur", obj={"extra": extra})
108
+ except SystemExit as exc:
109
+ if exc.code is None: # pragma: no cover - Typer always exits with an int code
110
+ return 0
111
+ return exc.code if isinstance(exc.code, int) else 2
112
+ return 0 # pragma: no cover - Typer's app() always raises SystemExit
nur/discovery.py ADDED
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import TYPE_CHECKING
5
+
6
+ from nur.providers import PROVIDERS
7
+ from nur.registry import Registry
8
+
9
+ if TYPE_CHECKING:
10
+ from pathlib import Path
11
+
12
+ from nur.models import Provider, Task
13
+
14
+ log = logging.getLogger("nur")
15
+
16
+
17
+ def discover(cwd: Path, providers: list[Provider] | None = None) -> Registry:
18
+ providers = PROVIDERS if providers is None else providers
19
+ tasks: list[Task] = []
20
+ for provider in providers:
21
+ try:
22
+ if provider.detect(cwd):
23
+ tasks.extend(provider.discover(cwd))
24
+ # Backstop: a provider must never break discovery for the others.
25
+ except Exception as exc: # pylint: disable=broad-exception-caught
26
+ log.warning("nur: provider %r failed (%s)", provider.prefix, exc)
27
+ return Registry(tasks)
nur/execution.py ADDED
@@ -0,0 +1,121 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import os
5
+ import signal
6
+ import subprocess
7
+ import sys
8
+ import threading
9
+ from typing import TYPE_CHECKING
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Callable
13
+ from pathlib import Path
14
+
15
+ from nur.models import Task
16
+
17
+ # Conventional "command not found" exit status (as used by POSIX shells).
18
+ RUNNER_NOT_FOUND = 127
19
+
20
+
21
+ def run_direct(task: Task, extra_args: list[str], cwd: Path) -> int:
22
+ """Run a task with inherited stdio; return the child's exit code.
23
+
24
+ A natively-parsed provider (npm/PDM/poe) can surface a task whose runner
25
+ binary is not installed. Rather than letting ``FileNotFoundError`` escape
26
+ as a traceback, report a controlled error and return ``127``.
27
+ """
28
+ argv = task.run_argv(extra_args)
29
+ try:
30
+ completed = subprocess.run(argv, cwd=cwd, check=False)
31
+ except FileNotFoundError:
32
+ print(
33
+ f"nur: runner '{argv[0]}' is not installed "
34
+ f"(needed to run {task.qualified_name}).",
35
+ file=sys.stderr,
36
+ )
37
+ return RUNNER_NOT_FOUND
38
+ return completed.returncode
39
+
40
+
41
+ class ProcessRunner:
42
+ """Runs a command with combined output captured and streamed line-by-line.
43
+
44
+ Blocking; intended to be driven from a background thread by the TUI.
45
+ """
46
+
47
+ def __init__(self) -> None:
48
+ self._proc: subprocess.Popen[str] | None = None
49
+ self._lock = threading.Lock()
50
+
51
+ def run(self, argv: list[str], cwd: Path, on_line: Callable[[str], None]) -> int:
52
+ # Put the child in its own process group so an interrupt can signal the
53
+ # whole tree (e.g. npm -> node, make -> sh), not just the runner PID:
54
+ # start_new_session on POSIX, CREATE_NEW_PROCESS_GROUP on Windows. Each
55
+ # flag is a no-op on the other platform, so both are always passed.
56
+ creationflags = 0
57
+ start_new_session = False
58
+ if sys.platform == "win32":
59
+ creationflags = subprocess.CREATE_NEW_PROCESS_GROUP
60
+ else:
61
+ start_new_session = True
62
+ # Not a `with` block: the process is stored on self and outlives this
63
+ # method so interrupt() can signal it; cleanup happens in the finally.
64
+ try:
65
+ proc = subprocess.Popen( # pylint: disable=consider-using-with
66
+ argv,
67
+ cwd=cwd,
68
+ stdout=subprocess.PIPE,
69
+ stderr=subprocess.STDOUT,
70
+ stdin=subprocess.DEVNULL,
71
+ text=True,
72
+ bufsize=1,
73
+ creationflags=creationflags,
74
+ start_new_session=start_new_session,
75
+ )
76
+ except FileNotFoundError:
77
+ # Natively-discovered task whose runner isn't installed: surface a
78
+ # message in the output pane instead of an empty pane + exited(1).
79
+ on_line(f"nur: runner '{argv[0]}' is not installed.\n")
80
+ return RUNNER_NOT_FOUND
81
+ with self._lock:
82
+ self._proc = proc
83
+ try:
84
+ assert proc.stdout is not None
85
+ for line in proc.stdout:
86
+ on_line(line)
87
+ finally:
88
+ # Runs even if on_line raises, so we never leak the pipe or the
89
+ # child and never leave stale runner state behind.
90
+ # stdout is always a PIPE here, so the None branch never runs.
91
+ if proc.stdout is not None: # pragma: no branch
92
+ proc.stdout.close()
93
+ code = proc.wait()
94
+ with self._lock:
95
+ self._proc = None
96
+ return code
97
+
98
+ # interrupt() drives OS signals against a live child, so it is exercised
99
+ # manually (see tests/tui/test_app_execution.py) rather than in unit tests.
100
+ def interrupt(self) -> None: # pragma: no cover
101
+ with self._lock:
102
+ proc = self._proc
103
+ if proc is None or proc.poll() is not None:
104
+ return
105
+ # if/else (not an early return) so type checkers treat each arm as a
106
+ # platform-specific branch and don't flag the other as unreachable.
107
+ if sys.platform == "win32":
108
+ # Windows has no os.killpg; CTRL_BREAK is the documented way to
109
+ # interrupt a CREATE_NEW_PROCESS_GROUP child (and, unlike CTRL_C,
110
+ # it targets only the child's group, never nur's own console).
111
+ with contextlib.suppress(OSError):
112
+ proc.send_signal(signal.CTRL_BREAK_EVENT)
113
+ else:
114
+ try:
115
+ os.killpg(os.getpgid(proc.pid), signal.SIGINT)
116
+ except OSError:
117
+ # Process already gone (ProcessLookupError), or the group could
118
+ # not be signalled (PermissionError) -- both subclass OSError;
119
+ # fall back to signalling the process directly.
120
+ with contextlib.suppress(ProcessLookupError, OSError):
121
+ proc.send_signal(signal.SIGINT)
nur/models.py ADDED
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
5
+
6
+ if TYPE_CHECKING:
7
+ from pathlib import Path
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class Task:
12
+ name: str
13
+ prefix: str
14
+ argv_base: tuple[str, ...]
15
+ description: str | None = None
16
+ definition: str = ""
17
+ source_file: str = ""
18
+ # Tokens inserted before passthrough args when (and only when) extra args
19
+ # are forwarded, e.g. npm requires `npm run <script> -- <args>`.
20
+ passthrough_prefix: tuple[str, ...] = ()
21
+
22
+ @property
23
+ def qualified_name(self) -> str:
24
+ return f"{self.prefix}:{self.name}"
25
+
26
+ def run_argv(self, extra_args: list[str] | None = None) -> list[str]:
27
+ extra = extra_args or []
28
+ separator = self.passthrough_prefix if extra else ()
29
+ return [*self.argv_base, *separator, *extra]
30
+
31
+
32
+ @runtime_checkable
33
+ class Provider(Protocol):
34
+ prefix: str
35
+
36
+ def detect(self, cwd: Path) -> bool: ...
37
+
38
+ def discover(self, cwd: Path) -> list[Task]: ...
@@ -0,0 +1,30 @@
1
+ from typing import TYPE_CHECKING
2
+
3
+ from nur.providers.just import JustProvider
4
+ from nur.providers.make import MakeProvider
5
+ from nur.providers.npm import NpmProvider
6
+ from nur.providers.pdm import PdmProvider
7
+ from nur.providers.poe import PoeProvider
8
+ from nur.providers.task import TaskfileProvider
9
+
10
+ if TYPE_CHECKING:
11
+ from nur.models import Provider
12
+
13
+ PROVIDERS: list[Provider] = [
14
+ NpmProvider(),
15
+ MakeProvider(),
16
+ PdmProvider(),
17
+ PoeProvider(),
18
+ JustProvider(),
19
+ TaskfileProvider(),
20
+ ]
21
+
22
+ __all__ = [
23
+ "PROVIDERS",
24
+ "JustProvider",
25
+ "MakeProvider",
26
+ "NpmProvider",
27
+ "PdmProvider",
28
+ "PoeProvider",
29
+ "TaskfileProvider",
30
+ ]
nur/providers/just.py ADDED
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import subprocess
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from nur.models import Task
9
+
10
+ if TYPE_CHECKING:
11
+ from pathlib import Path
12
+
13
+ log = logging.getLogger("nur")
14
+
15
+
16
+ def parse_dump(text: str, source_file: str = "justfile") -> list[Task]:
17
+ data = json.loads(text)
18
+ tasks: list[Task] = []
19
+ recipes: dict[str, Any] = data.get("recipes") or {}
20
+ for name, recipe in recipes.items():
21
+ tasks.append(
22
+ Task(
23
+ name=name,
24
+ prefix="just",
25
+ argv_base=("just", name),
26
+ description=recipe.get("doc"),
27
+ source_file=source_file,
28
+ )
29
+ )
30
+ return tasks
31
+
32
+
33
+ class JustProvider:
34
+ prefix = "just"
35
+
36
+ def _source_file(self, cwd: Path) -> str:
37
+ return "justfile" if (cwd / "justfile").is_file() else "Justfile"
38
+
39
+ def detect(self, cwd: Path) -> bool:
40
+ return (cwd / "justfile").is_file() or (cwd / "Justfile").is_file()
41
+
42
+ def discover(self, cwd: Path) -> list[Task]:
43
+ try:
44
+ result = subprocess.run(
45
+ ["just", "--dump", "--dump-format", "json"],
46
+ cwd=cwd,
47
+ capture_output=True,
48
+ text=True,
49
+ check=False,
50
+ )
51
+ except OSError:
52
+ log.warning("nur: 'just' not installed; skipping justfile tasks")
53
+ return []
54
+ try:
55
+ return parse_dump(result.stdout, self._source_file(cwd))
56
+ except (json.JSONDecodeError, AttributeError) as exc:
57
+ log.warning("nur: skipping justfile (%s)", exc)
58
+ return []
nur/providers/make.py ADDED
@@ -0,0 +1,92 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import re
5
+ from typing import TYPE_CHECKING
6
+
7
+ from nur.models import Task
8
+
9
+ if TYPE_CHECKING:
10
+ from pathlib import Path
11
+
12
+ log = logging.getLogger("nur")
13
+
14
+ # A rule's left-hand side: one or more target names before ':' or '::'.
15
+ # The negative lookahead excludes variable assignments (':=', '='); recipe
16
+ # lines are tab-indented so they never start with [A-Za-z0-9].
17
+ _RULE_RE = re.compile(r"^([A-Za-z0-9][^:=#]*?)\s*::?(?!=)")
18
+ # A single valid target name (excludes '%' pattern rules and '.'-prefixed).
19
+ _NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]*$")
20
+ # An inline "## description" anywhere on the line.
21
+ _DESC_RE = re.compile(r"##\s*(.*?)\s*$")
22
+
23
+
24
+ def _targets_on_line(line: str) -> tuple[list[str], str | None]:
25
+ """Return (target names, description) for one Makefile line.
26
+
27
+ Handles multiple targets sharing a rule, e.g. ``build test: ## desc``.
28
+ """
29
+ match = _RULE_RE.match(line)
30
+ if not match:
31
+ return [], None
32
+ names = [
33
+ token
34
+ for token in match.group(1).split()
35
+ if _NAME_RE.match(token) and token != "Makefile"
36
+ ]
37
+ desc_match = _DESC_RE.search(line)
38
+ return names, (desc_match.group(1) if desc_match else None)
39
+
40
+
41
+ def parse_targets(text: str) -> list[str]:
42
+ """Extract target names from Makefile *text* without executing anything.
43
+
44
+ Deliberately does NOT shell out to ``make``: the database dump
45
+ (``make -pRrq``) still evaluates ``$(shell ...)`` / ``!=`` assignments while
46
+ reading the file, so a repository's Makefile could run arbitrary commands
47
+ merely by discovering/listing tasks. Text parsing is safe, at the cost of
48
+ not resolving ``include`` directives or computed targets.
49
+ """
50
+ names: list[str] = []
51
+ seen: set[str] = set()
52
+ for line in text.splitlines():
53
+ for name in _targets_on_line(line)[0]:
54
+ if name not in seen:
55
+ seen.add(name)
56
+ names.append(name)
57
+ return names
58
+
59
+
60
+ def parse_descriptions(text: str) -> dict[str, str]:
61
+ out: dict[str, str] = {}
62
+ for line in text.splitlines():
63
+ names, desc = _targets_on_line(line)
64
+ if desc:
65
+ for name in names:
66
+ out.setdefault(name, desc)
67
+ return out
68
+
69
+
70
+ class MakeProvider:
71
+ prefix = "make"
72
+
73
+ def detect(self, cwd: Path) -> bool:
74
+ return (cwd / "Makefile").is_file()
75
+
76
+ def discover(self, cwd: Path) -> list[Task]:
77
+ try:
78
+ text = (cwd / "Makefile").read_text()
79
+ except OSError as exc:
80
+ log.warning("nur: skipping Makefile (%s)", exc)
81
+ return []
82
+ descriptions = parse_descriptions(text)
83
+ return [
84
+ Task(
85
+ name=name,
86
+ prefix=self.prefix,
87
+ argv_base=("make", name),
88
+ description=descriptions.get(name),
89
+ source_file="Makefile",
90
+ )
91
+ for name in parse_targets(text)
92
+ ]
nur/providers/npm.py ADDED
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from nur.models import Task
8
+
9
+ if TYPE_CHECKING:
10
+ from pathlib import Path
11
+
12
+ log = logging.getLogger("nur")
13
+
14
+ # (lockfile, package manager) in precedence order. Bun writes a text `bun.lock`
15
+ # by default since 1.2 and the legacy binary `bun.lockb` with `--save-text-lockfile`
16
+ # disabled; recognize both.
17
+ _LOCKFILES: tuple[tuple[str, str], ...] = (
18
+ ("bun.lock", "bun"),
19
+ ("bun.lockb", "bun"),
20
+ ("pnpm-lock.yaml", "pnpm"),
21
+ ("yarn.lock", "yarn"),
22
+ ("package-lock.json", "npm"),
23
+ )
24
+
25
+
26
+ def resolve_pm(cwd: Path) -> str:
27
+ for lockfile, pm in _LOCKFILES:
28
+ if (cwd / lockfile).exists():
29
+ return pm
30
+ return "npm"
31
+
32
+
33
+ class NpmProvider:
34
+ prefix = "npm"
35
+
36
+ def detect(self, cwd: Path) -> bool:
37
+ return (cwd / "package.json").is_file()
38
+
39
+ def discover(self, cwd: Path) -> list[Task]:
40
+ try:
41
+ data = json.loads((cwd / "package.json").read_text())
42
+ except (OSError, json.JSONDecodeError) as exc:
43
+ log.warning("nur: skipping package.json (%s)", exc)
44
+ return []
45
+ # Annotated Any (not dict) so the runtime isinstance guard below stays
46
+ # meaningful: package.json "scripts" may not be an object.
47
+ scripts: Any = data.get("scripts") or {}
48
+ if not isinstance(scripts, dict):
49
+ log.warning("nur: skipping package.json ('scripts' is not an object)")
50
+ return []
51
+ pm = resolve_pm(cwd)
52
+ return [
53
+ Task(
54
+ name=name,
55
+ prefix=self.prefix,
56
+ argv_base=(pm, "run", name),
57
+ definition=str(command),
58
+ source_file="package.json",
59
+ # npm requires `npm run <script> -- <args>`; pnpm/yarn/bun accept
60
+ # the `--` separator too, so emit it uniformly when forwarding.
61
+ passthrough_prefix=("--",),
62
+ )
63
+ for name, command in scripts.items()
64
+ ]
nur/providers/pdm.py ADDED
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import tomllib
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from nur.models import Task
8
+
9
+ if TYPE_CHECKING:
10
+ from pathlib import Path
11
+
12
+ log = logging.getLogger("nur")
13
+
14
+
15
+ def _load_scripts(cwd: Path) -> dict[str, object] | None:
16
+ """Return the [tool.pdm.scripts] table, or None if absent/unreadable."""
17
+ path = cwd / "pyproject.toml"
18
+ try:
19
+ data = tomllib.loads(path.read_text())
20
+ except (OSError, tomllib.TOMLDecodeError) as exc:
21
+ log.warning("nur: skipping pyproject.toml (%s)", exc)
22
+ return None
23
+ tool = data.get("tool", {})
24
+ pdm: dict[str, Any] = tool.get("pdm", {}) if isinstance(tool, dict) else {}
25
+ scripts = pdm.get("scripts") if isinstance(pdm, dict) else None
26
+ return scripts if isinstance(scripts, dict) else None
27
+
28
+
29
+ def _definition_and_help(value: object) -> tuple[str, str | None]:
30
+ if isinstance(value, dict):
31
+ body = value.get("cmd") or value.get("shell") or value.get("call") or ""
32
+ if isinstance(body, list):
33
+ body = " ".join(str(x) for x in body)
34
+ help_val = value.get("help")
35
+ return str(body), (help_val if isinstance(help_val, str) else None)
36
+ return str(value), None
37
+
38
+
39
+ class PdmProvider:
40
+ prefix = "pdm"
41
+
42
+ def detect(self, cwd: Path) -> bool:
43
+ scripts = _load_scripts(cwd)
44
+ return bool(scripts)
45
+
46
+ def discover(self, cwd: Path) -> list[Task]:
47
+ scripts = _load_scripts(cwd)
48
+ if not scripts:
49
+ return []
50
+ tasks: list[Task] = []
51
+ for name, value in scripts.items():
52
+ if name == "_": # PDM reserved global-options key
53
+ continue
54
+ definition, help_text = _definition_and_help(value)
55
+ tasks.append(
56
+ Task(
57
+ name=name,
58
+ prefix=self.prefix,
59
+ argv_base=("pdm", "run", name),
60
+ description=help_text,
61
+ definition=definition,
62
+ source_file="pyproject.toml",
63
+ )
64
+ )
65
+ return tasks