forked from github/spec-kit
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy path__init__.py
More file actions
204 lines (177 loc) · 8.97 KB
/
Copy path__init__.py
File metadata and controls
204 lines (177 loc) · 8.97 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
"""Gate step — human review gate."""
from __future__ import annotations
import re
import sys
from pathlib import Path
from typing import Any
from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus
from specify_cli.workflows.expressions import evaluate_expression
#: C0 control characters except tab. Stripped from anything derived from a
#: ``show_file`` before it is printed — both the file's contents and the path
#: itself — so a file (or a path) containing ANSI escapes (e.g. ``\x1b[2J``)
#: cannot clear the screen or spoof the prompt/options at the terminal.
_CONTROL_CHARS = re.compile(r"[\x00-\x08\x0b-\x1f\x7f]")
class GateStep(StepBase):
"""Interactive review gate.
When running in an interactive terminal, prompts the user to choose
an option (e.g. approve / reject). Falls back to ``PAUSED`` when
stdin is not a TTY (CI, piped input) so the run can be resumed
later with ``specify workflow resume``.
The user's choice is stored in ``output.choice``. ``on_reject``
controls abort / skip behaviour.
"""
type_key = "gate"
#: Maximum number of ``show_file`` lines rendered at the prompt, so a
#: large file cannot flood the terminal before the choice.
MAX_SHOW_FILE_LINES = 200
def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
message = config.get("message", "Review required.")
if isinstance(message, str) and "{{" in message:
message = evaluate_expression(message, context)
options = config.get("options", ["approve", "reject"])
on_reject = config.get("on_reject", "abort")
show_file = config.get("show_file")
if isinstance(show_file, str) and "{{" in show_file:
show_file = evaluate_expression(show_file, context)
# ``evaluate_expression`` can return a non-string for a single
# expression (e.g. a number from a prior step), and a literal
# non-string is also possible; coerce so it is rendered rather
# than silently skipped at the prompt.
if show_file is not None:
show_file = str(show_file)
output = {
"message": message,
"options": options,
"on_reject": on_reject,
"show_file": show_file,
"choice": None,
}
# Non-interactive: pause for later resume (the file is not read here)
if not sys.stdin.isatty():
return StepResult(status=StepStatus.PAUSED, output=output)
# Interactive: prompt the user. ``show_file`` contents are folded
# into the displayed message so the operator can review the
# referenced material before choosing. Composing the prompt text
# here keeps ``_prompt`` to its ``(message, options)`` contract, so
# adding review material never widens the interactive seam.
choice = self._prompt(self._compose_prompt(message, show_file), options)
output["choice"] = choice
if choice in ("reject", "abort"):
if on_reject == "abort":
output["aborted"] = True
return StepResult(
status=StepStatus.FAILED,
output=output,
error=f"Gate rejected by user at step {config.get('id', '?')!r}",
)
if on_reject == "retry":
# Pause so the next resume re-executes this gate
return StepResult(status=StepStatus.PAUSED, output=output)
# on_reject == "skip" → completed, downstream steps decide
return StepResult(status=StepStatus.COMPLETED, output=output)
return StepResult(status=StepStatus.COMPLETED, output=output)
@classmethod
def _compose_prompt(cls, message: object, show_file: str | None) -> str:
"""Build the gate's display text.
``message`` may be a non-string (e.g. a YAML numeric literal that
``execute`` does not coerce), so it is rendered through ``str``.
When ``show_file`` names a file, its contents (read safely, see
``_read_show_file``) are appended below the message so the operator
can review the referenced material before choosing. Always returns a
``str`` — possibly multi-line — for ``_prompt`` to render in the box.
"""
text = str(message)
if not show_file:
return text
# The path is opened with the original value but displayed stripped,
# so a path that itself contains escapes cannot spoof the terminal.
header = f"{_CONTROL_CHARS.sub('', show_file)}:"
body = "\n".join(
[header, *(f" {line}" for line in cls._read_show_file(show_file))]
)
return f"{text}\n\n{body}"
@staticmethod
def _prompt(message: str, options: list[str]) -> str:
"""Display the gate message and prompt for a choice.
``message`` may span multiple lines (e.g. when review material has
been folded in); each line is rendered inside the gate box.
"""
print("\n ┌─ Gate ─────────────────────────────────────")
for line in message.split("\n"):
print(f" │ {line}" if line else " │")
print(" │")
for i, opt in enumerate(options, 1):
print(f" │ [{i}] {opt}")
print(" └────────────────────────────────────────────")
while True:
try:
raw = input(f" Choose [1-{len(options)}]: ").strip()
except (EOFError, KeyboardInterrupt):
print()
return options[-1] # default to last (usually reject)
if raw.isdigit() and 1 <= int(raw) <= len(options):
return options[int(raw) - 1]
# Also accept the option name directly
if raw.lower() in [o.lower() for o in options]:
return next(o for o in options if o.lower() == raw.lower())
print(f" Invalid choice. Enter 1-{len(options)} or an option name.")
@staticmethod
def _read_show_file(show_file: str) -> list[str]:
"""Return the lines of ``show_file`` for display.
Reads at most ``MAX_SHOW_FILE_LINES`` lines so a large file cannot
flood the prompt, and returns a short notice instead of raising
when the file is missing, undecodable, or names an invalid path,
so a misconfigured ``show_file`` never breaks the interactive
prompt. ``ValueError`` covers paths the OS rejects outright (e.g.
an embedded NUL byte), which ``Path.open`` raises before any I/O.
Control characters are stripped from each line so file content
cannot inject ANSI escape sequences into the terminal.
"""
lines: list[str] = []
truncated = False
try:
with Path(show_file).open(encoding="utf-8") as handle:
for line in handle:
if len(lines) >= GateStep.MAX_SHOW_FILE_LINES:
truncated = True
break
lines.append(_CONTROL_CHARS.sub("", line.rstrip("\n")))
except (OSError, UnicodeDecodeError, ValueError) as exc:
# ``exc`` echoes the (possibly hostile) path, so strip it too.
return [_CONTROL_CHARS.sub("", f"(could not read file: {exc})")]
if not lines and not truncated:
return ["(file is empty)"]
if truncated:
lines.append(
f"… (output truncated at {GateStep.MAX_SHOW_FILE_LINES} lines)"
)
return lines
def validate(self, config: dict[str, Any]) -> list[str]:
errors = super().validate(config)
if "message" not in config:
errors.append(
f"Gate step {config.get('id', '?')!r} is missing 'message' field."
)
options = config.get("options", ["approve", "reject"])
if not isinstance(options, list) or not options:
errors.append(
f"Gate step {config.get('id', '?')!r}: 'options' must be a non-empty list."
)
elif not all(isinstance(o, str) for o in options):
errors.append(
f"Gate step {config.get('id', '?')!r}: all options must be strings."
)
on_reject = config.get("on_reject", "abort")
if on_reject not in ("abort", "skip", "retry"):
errors.append(
f"Gate step {config.get('id', '?')!r}: 'on_reject' must be "
f"'abort', 'skip', or 'retry'."
)
if on_reject in ("abort", "retry") and isinstance(options, list):
reject_choices = {"reject", "abort"}
if not any(o.lower() in reject_choices for o in options):
errors.append(
f"Gate step {config.get('id', '?')!r}: on_reject={on_reject!r} "
f"but options has no 'reject' or 'abort' choice."
)
return errors