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.
Files changed (107) hide show
  1. {pygittools-0.2.2 → pygittools-0.3.0}/PKG-INFO +8 -10
  2. {pygittools-0.2.2 → pygittools-0.3.0}/README.md +3 -5
  3. pygittools-0.3.0/pyproject.toml +27 -0
  4. pygittools-0.2.2/pyproject.toml → pygittools-0.3.0/pyproject.toml.orig +6 -11
  5. pygittools-0.3.0/src/pygittools/actions.py +322 -0
  6. pygittools-0.3.0/src/pygittools/actions_test.py +266 -0
  7. pygittools-0.3.0/src/pygittools/help.md +109 -0
  8. {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/hooks_push.py +1 -1
  9. {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/merge.py +1 -1
  10. {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tags_sync.py +2 -2
  11. {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tasks.py +91 -6
  12. {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tasks_test.py +52 -0
  13. {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/app.py +41 -52
  14. {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/app_test.py +34 -21
  15. {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/branches.py +22 -4
  16. {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/branches_test.py +16 -0
  17. {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/changes_list.py +114 -124
  18. {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/changes_list_test.py +63 -69
  19. {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/conftest.py +27 -18
  20. {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/coverage_gaps_test.py +100 -98
  21. {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/git_colors.py +146 -334
  22. {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/git_colors_test.py +10 -7
  23. pygittools-0.3.0/src/pygittools/tui/git_worktrees.py +156 -0
  24. pygittools-0.3.0/src/pygittools/tui/git_worktrees_test.py +98 -0
  25. {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/input_context.py +61 -26
  26. pygittools-0.3.0/src/pygittools/tui/input_context_test.py +194 -0
  27. pygittools-0.3.0/src/pygittools/tui/keys.py +87 -0
  28. pygittools-0.3.0/src/pygittools/tui/log_list.py +113 -0
  29. {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/log_list_test.py +9 -24
  30. {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/pages/__init__.py +3 -0
  31. pygittools-0.3.0/src/pygittools/tui/pages/actions.py +332 -0
  32. pygittools-0.3.0/src/pygittools/tui/pages/actions_test.py +179 -0
  33. pygittools-0.3.0/src/pygittools/tui/pages/branches.py +171 -0
  34. pygittools-0.3.0/src/pygittools/tui/pages/help.py +59 -0
  35. pygittools-0.3.0/src/pygittools/tui/pages/home.py +211 -0
  36. pygittools-0.3.0/src/pygittools/tui/pages/no_repo.py +44 -0
  37. pygittools-0.3.0/src/pygittools/tui/pages/projects.py +99 -0
  38. {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/pages/task_editor.py +114 -90
  39. pygittools-0.3.0/src/pygittools/tui/pages/worktree_add.py +120 -0
  40. pygittools-0.3.0/src/pygittools/tui/pages_test.py +410 -0
  41. {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/project_label.py +2 -1
  42. {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/pygitweb_layout_test.py +2 -0
  43. pygittools-0.3.0/src/pygittools/tui/status_bar.py +39 -0
  44. {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/status_bar_test.py +16 -9
  45. pygittools-0.3.0/src/pygittools/tui/tabs.py +44 -0
  46. pygittools-0.3.0/src/pygittools/tui/tabs_test.py +28 -0
  47. {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/tasks_list.py +57 -73
  48. pygittools-0.3.0/src/pygittools/tui/tasks_list_test.py +23 -0
  49. {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/types.py +5 -2
  50. {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/worktree.py +21 -13
  51. {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/worktree_test.py +10 -0
  52. pygittools-0.3.0/src/pygittools/tui/worktrees_list.py +160 -0
  53. pygittools-0.3.0/src/pygittools/tui/worktrees_list_test.py +83 -0
  54. pygittools-0.2.2/help.md +0 -56
  55. pygittools-0.2.2/hook_samples/README.md +0 -62
  56. pygittools-0.2.2/hook_samples/commit-msg.pattern +0 -24
  57. pygittools-0.2.2/hook_samples/post-commit.notify +0 -24
  58. pygittools-0.2.2/hook_samples/post-commit.push-remotes +0 -23
  59. pygittools-0.2.2/hook_samples/post-receive.notify +0 -27
  60. pygittools-0.2.2/hook_samples/pre-commit.ruff +0 -18
  61. pygittools-0.2.2/hook_samples/pre-receive.branch-permissions +0 -69
  62. pygittools-0.2.2/hook_samples/pre-receive.protected-pattern +0 -37
  63. pygittools-0.2.2/pygittools.egg-info/PKG-INFO +0 -72
  64. pygittools-0.2.2/pygittools.egg-info/SOURCES.txt +0 -153
  65. pygittools-0.2.2/pygittools.egg-info/dependency_links.txt +0 -1
  66. pygittools-0.2.2/pygittools.egg-info/entry_points.txt +0 -2
  67. pygittools-0.2.2/pygittools.egg-info/requires.txt +0 -2
  68. pygittools-0.2.2/pygittools.egg-info/top_level.txt +0 -1
  69. pygittools-0.2.2/setup.cfg +0 -4
  70. pygittools-0.2.2/tui/draw.py +0 -33
  71. pygittools-0.2.2/tui/draw_test.py +0 -56
  72. pygittools-0.2.2/tui/input_context_test.py +0 -135
  73. pygittools-0.2.2/tui/log_list.py +0 -132
  74. pygittools-0.2.2/tui/pages/branches.py +0 -168
  75. pygittools-0.2.2/tui/pages/help.py +0 -65
  76. pygittools-0.2.2/tui/pages/home.py +0 -152
  77. pygittools-0.2.2/tui/pages/no_repo.py +0 -47
  78. pygittools-0.2.2/tui/pages/projects.py +0 -106
  79. pygittools-0.2.2/tui/pages_test.py +0 -213
  80. pygittools-0.2.2/tui/status_bar.py +0 -32
  81. pygittools-0.2.2/tui/tabs.py +0 -71
  82. pygittools-0.2.2/tui/tabs_test.py +0 -16
  83. pygittools-0.2.2/tui/ucurses.py +0 -26
  84. {pygittools-0.2.2 → pygittools-0.3.0}/LICENSE +0 -0
  85. {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/__init__.py +0 -0
  86. {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/__meta__.py +0 -0
  87. {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/hooks.py +0 -0
  88. {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/hooks_notify.py +0 -0
  89. {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/hooks_patterns.py +0 -0
  90. {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/hooks_patterns_test.py +0 -0
  91. {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/hooks_push_test.py +0 -0
  92. {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/hooks_ruff.py +0 -0
  93. {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/main.py +0 -0
  94. {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/mcp_server.py +0 -0
  95. {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/merge_test.py +0 -0
  96. {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tags_sync_test.py +0 -0
  97. {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tasks_query.py +0 -0
  98. {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tasks_query_test.py +0 -0
  99. {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/__init__.py +0 -0
  100. {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/colors.json +0 -0
  101. {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/commit_draft.py +0 -0
  102. {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/commit_draft_test.py +0 -0
  103. {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/projects.py +0 -0
  104. {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/projects_test.py +0 -0
  105. {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/pygitweb_layout.py +0 -0
  106. {pygittools-0.2.2 → pygittools-0.3.0/src/pygittools}/tui/repo.py +0 -0
  107. {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.2.2
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: uni-curses>=3.1.2
11
- Dynamic: license-file
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
- PGT is a command line curses application you can use to make commits, switch branches and repos, and do other basic git tasks. Keybinds:
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
- PGT is a command line curses application you can use to make commits, switch branches and repos, and do other basic git tasks. Keybinds:
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.2.2"
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
- "uni-curses>=3.1.2",
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
- [tool.setuptools.package-dir]
25
- pygittools = "."
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)