pygittools 0.2.2__tar.gz → 0.3.0__tar.gz
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.
- {pygittools-0.2.2 → pygittools-0.3.0}/PKG-INFO +8 -10
- {pygittools-0.2.2 → pygittools-0.3.0}/README.md +3 -5
- pygittools-0.3.0/pyproject.toml +27 -0
- pygittools-0.2.2/pyproject.toml → pygittools-0.3.0/pyproject.toml.orig +6 -11
- pygittools-0.3.0/src/pygittools/actions.py +322 -0
- pygittools-0.3.0/src/pygittools/actions_test.py +266 -0
- pygittools-0.3.0/src/pygittools/help.md +109 -0
- {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/hooks_push.py +1 -1
- {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/merge.py +1 -1
- {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tags_sync.py +2 -2
- {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tasks.py +91 -6
- {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tasks_test.py +52 -0
- {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/app.py +41 -52
- {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/app_test.py +34 -21
- {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/branches.py +22 -4
- {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/branches_test.py +16 -0
- {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/changes_list.py +114 -124
- {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/changes_list_test.py +63 -69
- {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/conftest.py +27 -18
- {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/coverage_gaps_test.py +100 -98
- {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/git_colors.py +146 -334
- {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/git_colors_test.py +10 -7
- pygittools-0.3.0/src/pygittools/tui/git_worktrees.py +156 -0
- pygittools-0.3.0/src/pygittools/tui/git_worktrees_test.py +98 -0
- {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/input_context.py +61 -26
- pygittools-0.3.0/src/pygittools/tui/input_context_test.py +194 -0
- pygittools-0.3.0/src/pygittools/tui/keys.py +87 -0
- pygittools-0.3.0/src/pygittools/tui/log_list.py +113 -0
- {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/log_list_test.py +9 -24
- {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/pages/__init__.py +3 -0
- pygittools-0.3.0/src/pygittools/tui/pages/actions.py +332 -0
- pygittools-0.3.0/src/pygittools/tui/pages/actions_test.py +179 -0
- pygittools-0.3.0/src/pygittools/tui/pages/branches.py +171 -0
- pygittools-0.3.0/src/pygittools/tui/pages/help.py +59 -0
- pygittools-0.3.0/src/pygittools/tui/pages/home.py +211 -0
- pygittools-0.3.0/src/pygittools/tui/pages/no_repo.py +44 -0
- pygittools-0.3.0/src/pygittools/tui/pages/projects.py +99 -0
- {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/pages/task_editor.py +114 -90
- pygittools-0.3.0/src/pygittools/tui/pages/worktree_add.py +120 -0
- pygittools-0.3.0/src/pygittools/tui/pages_test.py +410 -0
- {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/project_label.py +2 -1
- {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/pygitweb_layout_test.py +2 -0
- pygittools-0.3.0/src/pygittools/tui/status_bar.py +39 -0
- {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/status_bar_test.py +16 -9
- pygittools-0.3.0/src/pygittools/tui/tabs.py +44 -0
- pygittools-0.3.0/src/pygittools/tui/tabs_test.py +28 -0
- {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/tasks_list.py +57 -73
- pygittools-0.3.0/src/pygittools/tui/tasks_list_test.py +23 -0
- {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/types.py +5 -2
- {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/worktree.py +21 -13
- {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/worktree_test.py +10 -0
- pygittools-0.3.0/src/pygittools/tui/worktrees_list.py +160 -0
- pygittools-0.3.0/src/pygittools/tui/worktrees_list_test.py +83 -0
- pygittools-0.2.2/help.md +0 -56
- pygittools-0.2.2/hook_samples/README.md +0 -62
- pygittools-0.2.2/hook_samples/commit-msg.pattern +0 -24
- pygittools-0.2.2/hook_samples/post-commit.notify +0 -24
- pygittools-0.2.2/hook_samples/post-commit.push-remotes +0 -23
- pygittools-0.2.2/hook_samples/post-receive.notify +0 -27
- pygittools-0.2.2/hook_samples/pre-commit.ruff +0 -18
- pygittools-0.2.2/hook_samples/pre-receive.branch-permissions +0 -69
- pygittools-0.2.2/hook_samples/pre-receive.protected-pattern +0 -37
- pygittools-0.2.2/pygittools.egg-info/PKG-INFO +0 -72
- pygittools-0.2.2/pygittools.egg-info/SOURCES.txt +0 -153
- pygittools-0.2.2/pygittools.egg-info/dependency_links.txt +0 -1
- pygittools-0.2.2/pygittools.egg-info/entry_points.txt +0 -2
- pygittools-0.2.2/pygittools.egg-info/requires.txt +0 -2
- pygittools-0.2.2/pygittools.egg-info/top_level.txt +0 -1
- pygittools-0.2.2/setup.cfg +0 -4
- pygittools-0.2.2/tui/draw.py +0 -33
- pygittools-0.2.2/tui/draw_test.py +0 -56
- pygittools-0.2.2/tui/input_context_test.py +0 -135
- pygittools-0.2.2/tui/log_list.py +0 -132
- pygittools-0.2.2/tui/pages/branches.py +0 -168
- pygittools-0.2.2/tui/pages/help.py +0 -65
- pygittools-0.2.2/tui/pages/home.py +0 -152
- pygittools-0.2.2/tui/pages/no_repo.py +0 -47
- pygittools-0.2.2/tui/pages/projects.py +0 -106
- pygittools-0.2.2/tui/pages_test.py +0 -213
- pygittools-0.2.2/tui/status_bar.py +0 -32
- pygittools-0.2.2/tui/tabs.py +0 -71
- pygittools-0.2.2/tui/tabs_test.py +0 -16
- pygittools-0.2.2/tui/ucurses.py +0 -26
- {pygittools-0.2.2 → pygittools-0.3.0}/LICENSE +0 -0
- {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/__init__.py +0 -0
- {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/__meta__.py +0 -0
- {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/hooks.py +0 -0
- {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/hooks_notify.py +0 -0
- {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/hooks_patterns.py +0 -0
- {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/hooks_patterns_test.py +0 -0
- {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/hooks_push_test.py +0 -0
- {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/hooks_ruff.py +0 -0
- {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/main.py +0 -0
- {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/mcp_server.py +0 -0
- {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/merge_test.py +0 -0
- {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tags_sync_test.py +0 -0
- {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tasks_query.py +0 -0
- {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tasks_query_test.py +0 -0
- {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/__init__.py +0 -0
- {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/colors.json +0 -0
- {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/commit_draft.py +0 -0
- {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/commit_draft_test.py +0 -0
- {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/projects.py +0 -0
- {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/projects_test.py +0 -0
- {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/pygitweb_layout.py +0 -0
- {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/repo.py +0 -0
- {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/repo_test.py +0 -0
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pygittools
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Hooks and task storage in Git
|
|
5
5
|
License-Expression: MIT
|
|
6
|
-
Requires-Python: >=3.11
|
|
7
|
-
Description-Content-Type: text/markdown
|
|
8
6
|
License-File: LICENSE
|
|
7
|
+
Requires-Dist: abcreg==0.2.0
|
|
9
8
|
Requires-Dist: pygit2>=1.12.0
|
|
10
|
-
Requires-Dist:
|
|
11
|
-
|
|
9
|
+
Requires-Dist: pyratatui>=0.3.0
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
12
|
|
|
13
13
|
# PyGitTools
|
|
14
14
|
|
|
@@ -16,14 +16,12 @@ Hooks, tooling, and pygit2 workflows used by PyGitWeb. Can be used headless for
|
|
|
16
16
|
|
|
17
17
|
## PGT
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
- Arrow keys or `j`/`k` to navigate
|
|
21
|
-
- `Enter` to confirm actions, stage or unstage files or categories
|
|
22
|
-
- `Space` to collapse menus
|
|
23
|
-
- `ctrl-c` or `esc` to exit.
|
|
19
|
+
`pgt` is a terminal UI for commits, branches, tasks, and worktrees. The home page has **Status**, **Log**, **Tasks**, and **Worktrees** tabs. `H` / `L` switch tabs, `J` / `K` move the selection, `Q` or `Ctrl-C` quits, and `?` opens help. On **Status**, `!` or the Actions row runs project scripts from `pyproject.toml` or `actions.toml`. Full key bindings are in the in-app help (`src/pygittools/help.md`).
|
|
24
20
|
|
|
25
21
|
To run from the repo root, use `uv run pgt`.
|
|
26
22
|
|
|
23
|
+
Task objects may include `points`, a Fibonacci story-point value: `0`, `1`, `2`, `3`, `5`, `8`, `13`, or `21`. Leave it unset when the task is unestimated.
|
|
24
|
+
|
|
27
25
|
## Hook framework
|
|
28
26
|
|
|
29
27
|
`pygittools.hooks` provides a thin `Hook` base class plus one subclass per git hook type
|
|
@@ -4,14 +4,12 @@ Hooks, tooling, and pygit2 workflows used by PyGitWeb. Can be used headless for
|
|
|
4
4
|
|
|
5
5
|
## PGT
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
- Arrow keys or `j`/`k` to navigate
|
|
9
|
-
- `Enter` to confirm actions, stage or unstage files or categories
|
|
10
|
-
- `Space` to collapse menus
|
|
11
|
-
- `ctrl-c` or `esc` to exit.
|
|
7
|
+
`pgt` is a terminal UI for commits, branches, tasks, and worktrees. The home page has **Status**, **Log**, **Tasks**, and **Worktrees** tabs. `H` / `L` switch tabs, `J` / `K` move the selection, `Q` or `Ctrl-C` quits, and `?` opens help. On **Status**, `!` or the Actions row runs project scripts from `pyproject.toml` or `actions.toml`. Full key bindings are in the in-app help (`src/pygittools/help.md`).
|
|
12
8
|
|
|
13
9
|
To run from the repo root, use `uv run pgt`.
|
|
14
10
|
|
|
11
|
+
Task objects may include `points`, a Fibonacci story-point value: `0`, `1`, `2`, `3`, `5`, `8`, `13`, or `21`. Leave it unset when the task is unestimated.
|
|
12
|
+
|
|
15
13
|
## Hook framework
|
|
16
14
|
|
|
17
15
|
`pygittools.hooks` provides a thin `Hook` base class plus one subclass per git hook type
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pygittools"
|
|
3
|
+
version = "0.3.0"
|
|
4
|
+
description = "Hooks and task storage in Git"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.11"
|
|
9
|
+
dependencies = [
|
|
10
|
+
"abcreg==0.2.0",
|
|
11
|
+
"pygit2>=1.12.0",
|
|
12
|
+
"pyratatui>=0.3.0",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.scripts]
|
|
16
|
+
pgt = "pygittools.main:main"
|
|
17
|
+
|
|
18
|
+
[tool.setuptools]
|
|
19
|
+
packages = [
|
|
20
|
+
"pygittools",
|
|
21
|
+
"pygittools.tui",
|
|
22
|
+
"pygittools.tui.pages",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[build-system]
|
|
26
|
+
requires = ["uv_build>=0.12.23,<0.13"]
|
|
27
|
+
build-backend = "uv_build"
|
|
@@ -1,18 +1,15 @@
|
|
|
1
|
-
[build-system]
|
|
2
|
-
requires = ["setuptools>=61", "wheel"]
|
|
3
|
-
build-backend = "setuptools.build_meta"
|
|
4
|
-
|
|
5
1
|
[project]
|
|
6
2
|
name = "pygittools"
|
|
7
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
8
4
|
description = "Hooks and task storage in Git"
|
|
9
5
|
readme = "README.md"
|
|
10
6
|
license = "MIT"
|
|
11
7
|
license-files = ["LICENSE"]
|
|
12
8
|
requires-python = ">=3.11"
|
|
13
9
|
dependencies = [
|
|
10
|
+
"abcreg==0.2.0",
|
|
14
11
|
"pygit2>=1.12.0",
|
|
15
|
-
"
|
|
12
|
+
"pyratatui>=0.3.0",
|
|
16
13
|
]
|
|
17
14
|
|
|
18
15
|
[project.scripts]
|
|
@@ -21,8 +18,6 @@ pgt = "pygittools.main:main"
|
|
|
21
18
|
[tool.setuptools]
|
|
22
19
|
packages = ["pygittools", "pygittools.tui", "pygittools.tui.pages"]
|
|
23
20
|
|
|
24
|
-
[
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
[tool.setuptools.package-data]
|
|
28
|
-
pygittools = ["hook_samples/**/*", "help.md", "tui/colors.json"]
|
|
21
|
+
[build-system]
|
|
22
|
+
requires = ["uv_build>=0.12.23,<0.13"]
|
|
23
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""Project actions: run named scripts from ``pyproject.toml`` or ``actions.toml``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import tomllib
|
|
8
|
+
from abc import abstractmethod
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from abcreg import AnnotatedABC # type: ignore[import-untyped]
|
|
13
|
+
|
|
14
|
+
_TOOL_TABLE = "pgt"
|
|
15
|
+
_ACTIONS_KEY = "actions"
|
|
16
|
+
_ACTIONS_FILE = "actions.toml"
|
|
17
|
+
_DEFAULT_SET_NAME = "default"
|
|
18
|
+
_FLAT_FILE_SET_NAME = "actions"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class ActionSpec:
|
|
23
|
+
name: str
|
|
24
|
+
script: str
|
|
25
|
+
args: tuple[str, ...] = ()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, slots=True)
|
|
29
|
+
class ActionSet:
|
|
30
|
+
"""A named group of actions the TUI can cycle through."""
|
|
31
|
+
|
|
32
|
+
name: str
|
|
33
|
+
specs: tuple[ActionSpec, ...]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Action(AnnotatedABC):
|
|
37
|
+
"""Run a named project action and return ``(ok, message)``."""
|
|
38
|
+
|
|
39
|
+
@abstractmethod
|
|
40
|
+
def action(self, name: str) -> tuple[bool, str]: ...
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def find_project_root(start: Path) -> Path | None:
|
|
44
|
+
"""Walk upward for ``pyproject.toml``, else the nearest ``actions.toml``."""
|
|
45
|
+
current = start.resolve()
|
|
46
|
+
actions_root: Path | None = None
|
|
47
|
+
for path in [current, *current.parents]:
|
|
48
|
+
if (path / "pyproject.toml").is_file():
|
|
49
|
+
return path
|
|
50
|
+
if actions_root is None and (path / _ACTIONS_FILE).is_file():
|
|
51
|
+
actions_root = path
|
|
52
|
+
return actions_root
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load_action_specs(project_root: Path) -> list[ActionSpec]:
|
|
56
|
+
"""Read ``[tool.pgt].actions`` from the project's ``pyproject.toml``."""
|
|
57
|
+
pgt = _pyproject_pgt(project_root)
|
|
58
|
+
if pgt is None:
|
|
59
|
+
return []
|
|
60
|
+
raw = pgt.get(_ACTIONS_KEY)
|
|
61
|
+
if raw is None:
|
|
62
|
+
return []
|
|
63
|
+
return _parse_actions(raw, where="[tool.pgt].actions")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def load_action_sets(project_root: Path) -> list[ActionSet]:
|
|
67
|
+
"""Load action sets from ``pyproject.toml`` and a root ``actions.toml``.
|
|
68
|
+
|
|
69
|
+
``[tool.pgt].actions`` is one set (named ``default``, or ``[tool.pgt].name``).
|
|
70
|
+
``[tool.pgt.sets.<name>]`` tables are further sets. ``actions.toml`` may repeat
|
|
71
|
+
that shape, and each other top-level table is also a set:
|
|
72
|
+
|
|
73
|
+
.. code-block:: toml
|
|
74
|
+
|
|
75
|
+
[quality]
|
|
76
|
+
actions = [
|
|
77
|
+
{ name = "lint", script = "scripts/lint.py" },
|
|
78
|
+
]
|
|
79
|
+
"""
|
|
80
|
+
sets = [*_pyproject_sets(project_root), *_actions_file_sets(project_root)]
|
|
81
|
+
seen: set[str] = set()
|
|
82
|
+
for action_set in sets:
|
|
83
|
+
if action_set.name in seen:
|
|
84
|
+
raise ValueError(f"Duplicate action set name: {action_set.name}")
|
|
85
|
+
seen.add(action_set.name)
|
|
86
|
+
return sets
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _pyproject_pgt(project_root: Path) -> dict[str, object] | None:
|
|
90
|
+
pyproject = project_root / "pyproject.toml"
|
|
91
|
+
if not pyproject.is_file():
|
|
92
|
+
return None
|
|
93
|
+
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
|
|
94
|
+
tool = data.get("tool")
|
|
95
|
+
if not isinstance(tool, dict):
|
|
96
|
+
return None
|
|
97
|
+
pgt = tool.get(_TOOL_TABLE)
|
|
98
|
+
if not isinstance(pgt, dict):
|
|
99
|
+
return None
|
|
100
|
+
return pgt
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _pyproject_sets(project_root: Path) -> list[ActionSet]:
|
|
104
|
+
pgt = _pyproject_pgt(project_root)
|
|
105
|
+
if pgt is None:
|
|
106
|
+
return []
|
|
107
|
+
sets: list[ActionSet] = []
|
|
108
|
+
flat = _parse_flat_set(
|
|
109
|
+
pgt,
|
|
110
|
+
default_name=_DEFAULT_SET_NAME,
|
|
111
|
+
actions_where="[tool.pgt].actions",
|
|
112
|
+
name_where="[tool.pgt].name",
|
|
113
|
+
)
|
|
114
|
+
if flat is not None:
|
|
115
|
+
sets.append(flat)
|
|
116
|
+
raw_sets = pgt.get("sets")
|
|
117
|
+
if raw_sets is not None:
|
|
118
|
+
sets.extend(_parse_named_tables(raw_sets, where="[tool.pgt].sets"))
|
|
119
|
+
return sets
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _actions_file_sets(project_root: Path) -> list[ActionSet]:
|
|
123
|
+
path = project_root / _ACTIONS_FILE
|
|
124
|
+
if not path.is_file():
|
|
125
|
+
return []
|
|
126
|
+
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
127
|
+
if not isinstance(data, dict):
|
|
128
|
+
raise ValueError("actions.toml must be a table")
|
|
129
|
+
sets: list[ActionSet] = []
|
|
130
|
+
actions_value = data.get(_ACTIONS_KEY)
|
|
131
|
+
consumed = {"name"}
|
|
132
|
+
if isinstance(actions_value, list):
|
|
133
|
+
consumed.add(_ACTIONS_KEY)
|
|
134
|
+
flat = _parse_flat_set(
|
|
135
|
+
data,
|
|
136
|
+
default_name=_FLAT_FILE_SET_NAME,
|
|
137
|
+
actions_where="actions.toml actions",
|
|
138
|
+
name_where="actions.toml name",
|
|
139
|
+
)
|
|
140
|
+
if flat is not None:
|
|
141
|
+
sets.append(flat)
|
|
142
|
+
elif "name" in data:
|
|
143
|
+
raise ValueError("actions.toml 'name' requires an 'actions' array")
|
|
144
|
+
for key, value in data.items():
|
|
145
|
+
if key in consumed:
|
|
146
|
+
continue
|
|
147
|
+
if not isinstance(key, str) or not key.strip():
|
|
148
|
+
raise ValueError("actions.toml set name must be a non-empty string")
|
|
149
|
+
if not isinstance(value, dict):
|
|
150
|
+
raise ValueError(f"actions.toml unexpected key {key!r}")
|
|
151
|
+
specs = _specs_from_set_table(value, where=f"actions.toml [{key.strip()}]")
|
|
152
|
+
if specs:
|
|
153
|
+
sets.append(ActionSet(name=key.strip(), specs=specs))
|
|
154
|
+
return sets
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _parse_flat_set(
|
|
158
|
+
data: dict[str, object],
|
|
159
|
+
*,
|
|
160
|
+
default_name: str,
|
|
161
|
+
actions_where: str,
|
|
162
|
+
name_where: str,
|
|
163
|
+
) -> ActionSet | None:
|
|
164
|
+
if _ACTIONS_KEY not in data:
|
|
165
|
+
if "name" in data:
|
|
166
|
+
raise ValueError(f"{name_where} requires 'actions'")
|
|
167
|
+
return None
|
|
168
|
+
raw_name = data.get("name", default_name)
|
|
169
|
+
if not isinstance(raw_name, str) or not raw_name.strip():
|
|
170
|
+
raise ValueError(f"{name_where} must be a non-empty string")
|
|
171
|
+
specs = tuple(_parse_actions(data[_ACTIONS_KEY], where=actions_where))
|
|
172
|
+
if not specs:
|
|
173
|
+
return None
|
|
174
|
+
return ActionSet(name=raw_name.strip(), specs=specs)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _parse_named_tables(raw: object, *, where: str) -> list[ActionSet]:
|
|
178
|
+
if not isinstance(raw, dict):
|
|
179
|
+
raise ValueError(f"{where} must be a table of action sets")
|
|
180
|
+
sets: list[ActionSet] = []
|
|
181
|
+
for key, value in raw.items():
|
|
182
|
+
if not isinstance(key, str) or not key.strip():
|
|
183
|
+
raise ValueError(f"{where} set name must be a non-empty string")
|
|
184
|
+
if not isinstance(value, dict):
|
|
185
|
+
raise ValueError(f"{where}.{key} must be a table")
|
|
186
|
+
name = key.strip()
|
|
187
|
+
specs = _specs_from_set_table(value, where=f"{where}.{name}")
|
|
188
|
+
if specs:
|
|
189
|
+
sets.append(ActionSet(name=name, specs=specs))
|
|
190
|
+
return sets
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _specs_from_set_table(value: dict[str, object], *, where: str) -> tuple[ActionSpec, ...]:
|
|
194
|
+
if _ACTIONS_KEY not in value:
|
|
195
|
+
raise ValueError(f"{where} missing 'actions'")
|
|
196
|
+
return tuple(_parse_actions(value[_ACTIONS_KEY], where=f"{where}.actions"))
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _parse_actions(raw: object, *, where: str) -> list[ActionSpec]:
|
|
200
|
+
if not isinstance(raw, list):
|
|
201
|
+
raise ValueError(f"{where} must be an array of name/script entries")
|
|
202
|
+
specs: list[ActionSpec] = []
|
|
203
|
+
for index, entry in enumerate(raw):
|
|
204
|
+
specs.append(_parse_action_entry(entry, where=f"{where}[{index}]"))
|
|
205
|
+
return specs
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _parse_action_entry(entry: object, *, where: str) -> ActionSpec:
|
|
209
|
+
if isinstance(entry, dict):
|
|
210
|
+
name = entry.get("name")
|
|
211
|
+
script = entry.get("script")
|
|
212
|
+
if not isinstance(name, str) or not name.strip():
|
|
213
|
+
raise ValueError(f"{where} missing string 'name'")
|
|
214
|
+
if not isinstance(script, str) or not script.strip():
|
|
215
|
+
raise ValueError(f"{where} missing string 'script'")
|
|
216
|
+
return ActionSpec(name=name.strip(), script=script.strip(), args=_parse_args(entry.get("args"), where=where))
|
|
217
|
+
if isinstance(entry, list) and len(entry) == 2:
|
|
218
|
+
name, script = entry
|
|
219
|
+
if not isinstance(name, str) or not name.strip():
|
|
220
|
+
raise ValueError(f"{where} name must be a non-empty string")
|
|
221
|
+
if not isinstance(script, str) or not script.strip():
|
|
222
|
+
raise ValueError(f"{where} script must be a non-empty string")
|
|
223
|
+
return ActionSpec(name=name.strip(), script=script.strip())
|
|
224
|
+
raise ValueError(f"{where} must be {{name=..., script=...}} or [name, script]")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _parse_args(raw: object, *, where: str) -> tuple[str, ...]:
|
|
228
|
+
if raw is None:
|
|
229
|
+
return ()
|
|
230
|
+
if not isinstance(raw, list):
|
|
231
|
+
raise ValueError(f"{where} 'args' must be an array of strings")
|
|
232
|
+
args: list[str] = []
|
|
233
|
+
for index, item in enumerate(raw):
|
|
234
|
+
if not isinstance(item, str) or item == "":
|
|
235
|
+
raise ValueError(f"{where} args[{index}] must be a non-empty string")
|
|
236
|
+
args.append(item)
|
|
237
|
+
return tuple(args)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def project_venv_python(project_root: Path) -> Path | None:
|
|
241
|
+
candidate = project_root / ".venv" / "bin" / "python"
|
|
242
|
+
if candidate.is_file():
|
|
243
|
+
return candidate
|
|
244
|
+
windows = project_root / ".venv" / "Scripts" / "python.exe"
|
|
245
|
+
if windows.is_file():
|
|
246
|
+
return windows
|
|
247
|
+
return None
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def run_action_script(project_root: Path, script: str, args: tuple[str, ...] = ()) -> tuple[bool, str]:
|
|
251
|
+
"""Execute ``script`` inside the project's ``.venv`` and return ``(ok, message)``."""
|
|
252
|
+
raw_script = Path(script)
|
|
253
|
+
script_path = raw_script.resolve() if raw_script.is_absolute() else (project_root / raw_script).resolve()
|
|
254
|
+
if not script_path.is_file():
|
|
255
|
+
return False, f"Script not found: {script_path}"
|
|
256
|
+
|
|
257
|
+
venv_python = project_venv_python(project_root)
|
|
258
|
+
if venv_python is None:
|
|
259
|
+
return False, f"Project venv not found under {project_root / '.venv'}"
|
|
260
|
+
|
|
261
|
+
venv_dir = venv_python.parent.parent
|
|
262
|
+
venv_bin = venv_python.parent
|
|
263
|
+
env = os.environ.copy()
|
|
264
|
+
env["VIRTUAL_ENV"] = str(venv_dir)
|
|
265
|
+
env["PATH"] = f"{venv_bin}{os.pathsep}{env.get('PATH', '')}"
|
|
266
|
+
env.pop("PYTHONHOME", None)
|
|
267
|
+
|
|
268
|
+
if script_path.suffix == ".py":
|
|
269
|
+
cmd = [str(venv_python), str(script_path), *args]
|
|
270
|
+
elif os.access(script_path, os.X_OK):
|
|
271
|
+
cmd = [str(script_path), *args]
|
|
272
|
+
else:
|
|
273
|
+
cmd = ["bash", str(script_path), *args]
|
|
274
|
+
|
|
275
|
+
proc = subprocess.run(
|
|
276
|
+
cmd,
|
|
277
|
+
cwd=project_root,
|
|
278
|
+
capture_output=True,
|
|
279
|
+
text=True,
|
|
280
|
+
env=env,
|
|
281
|
+
check=False,
|
|
282
|
+
)
|
|
283
|
+
output = _result_message(proc.stdout, proc.stderr)
|
|
284
|
+
if proc.returncode == 0:
|
|
285
|
+
return True, output or "ok"
|
|
286
|
+
if output:
|
|
287
|
+
return False, output
|
|
288
|
+
return False, f"exit {proc.returncode}"
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _result_message(stdout: str, stderr: str) -> str:
|
|
292
|
+
text = (stdout or "").strip() or (stderr or "").strip()
|
|
293
|
+
if not text:
|
|
294
|
+
return ""
|
|
295
|
+
lines = text.splitlines()
|
|
296
|
+
return lines[-1] if lines else text
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
@Action.register
|
|
300
|
+
class ProjectActions:
|
|
301
|
+
"""Default action runner backed by ``[tool.pgt].actions``.
|
|
302
|
+
|
|
303
|
+
Pass ``specs`` to run one set loaded by :func:`load_action_sets`.
|
|
304
|
+
"""
|
|
305
|
+
|
|
306
|
+
def __init__(self, project_root: Path, specs: list[ActionSpec] | None = None) -> None:
|
|
307
|
+
self._root = project_root.resolve()
|
|
308
|
+
self._specs = {spec.name: spec for spec in (specs if specs is not None else load_action_specs(self._root))}
|
|
309
|
+
|
|
310
|
+
@property
|
|
311
|
+
def specs(self) -> list[ActionSpec]:
|
|
312
|
+
return list(self._specs.values())
|
|
313
|
+
|
|
314
|
+
def action(self, name: str) -> tuple[bool, str]:
|
|
315
|
+
spec = self._specs.get(name)
|
|
316
|
+
if spec is None:
|
|
317
|
+
return False, f"Unknown action: {name}"
|
|
318
|
+
return run_action_script(self._root, spec.script, spec.args)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def project_actions(project_root: Path) -> ProjectActions:
|
|
322
|
+
return ProjectActions(project_root)
|