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 +5 -0
- nur/_version.py +24 -0
- nur/cli.py +112 -0
- nur/discovery.py +27 -0
- nur/execution.py +121 -0
- nur/models.py +38 -0
- nur/providers/__init__.py +30 -0
- nur/providers/just.py +58 -0
- nur/providers/make.py +92 -0
- nur/providers/npm.py +64 -0
- nur/providers/pdm.py +65 -0
- nur/providers/poe.py +61 -0
- nur/providers/task.py +70 -0
- nur/py.typed +0 -0
- nur/registry.py +61 -0
- nur/tui/__init__.py +0 -0
- nur/tui/app.py +211 -0
- nur/tui/app.tcss +6 -0
- nur-0.1.0.dist-info/METADATA +254 -0
- nur-0.1.0.dist-info/RECORD +23 -0
- nur-0.1.0.dist-info/WHEEL +4 -0
- nur-0.1.0.dist-info/entry_points.txt +2 -0
- nur-0.1.0.dist-info/licenses/LICENSE +21 -0
nur/__init__.py
ADDED
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
|