aicp-cli 0.3.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.
aicp/_utils.py ADDED
@@ -0,0 +1,183 @@
1
+ """Cross-platform helpers shared across aicp — macOS, Linux, Windows.
2
+
3
+ Patterns (platform flags, ANSI/Windows VT enabling, ``have()``) mirror
4
+ ``~/Documents/Workspace/ai-accounts``'s ``src/ai_accounts/_utils.py`` so the
5
+ two projects feel like one family; nothing is imported across the two repos,
6
+ each ships its own copy (same posture the zsh original documents for
7
+ ``lib/spinner.sh`` and ``lib/box_render.py``).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import shutil
14
+ import signal
15
+ import subprocess
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ IS_WINDOWS = sys.platform == "win32"
20
+ IS_MACOS = sys.platform == "darwin"
21
+ IS_LINUX = sys.platform.startswith("linux")
22
+
23
+ BOLD = "\033[1m"
24
+ DIM = "\033[2m"
25
+ RESET = "\033[0m"
26
+ GREEN = "\033[38;5;82m"
27
+ YELLOW = "\033[38;5;220m"
28
+ RED = "\033[38;5;203m"
29
+ CYAN = "\033[38;5;87m"
30
+ MAGENTA = "\033[38;5;213m"
31
+ BLUE = "\033[38;5;75m"
32
+
33
+
34
+ def _enable_windows_ansi() -> bool:
35
+ """Turn on virtual-terminal processing so ANSI escapes render on Windows.
36
+
37
+ No-op (returns True) on non-Windows. On modern Windows 10+ consoles this
38
+ flips ENABLE_VIRTUAL_TERMINAL_PROCESSING for both stdout and stderr.
39
+ """
40
+ if not IS_WINDOWS:
41
+ return True
42
+ try:
43
+ import ctypes
44
+
45
+ kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
46
+ ENABLE_VT = 0x0004
47
+ ok = False
48
+ for std_handle in (-11, -12): # STD_OUTPUT_HANDLE, STD_ERROR_HANDLE
49
+ handle = kernel32.GetStdHandle(std_handle)
50
+ if handle in (0, -1):
51
+ continue
52
+ mode = ctypes.c_uint32()
53
+ if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
54
+ continue
55
+ if kernel32.SetConsoleMode(handle, mode.value | ENABLE_VT):
56
+ ok = True
57
+ return ok
58
+ except Exception: # noqa: BLE001 - best-effort VT enable, any ctypes failure means "no"
59
+ return False
60
+
61
+
62
+ # Enable VT once at import time so ANSI output renders on Windows terminals.
63
+ _WIN_ANSI_OK = _enable_windows_ansi()
64
+
65
+
66
+ def color_supported(stream=None) -> bool:
67
+ """Whether ANSI color/animation should be drawn on *stream* (default stderr)."""
68
+ stream = stream if stream is not None else sys.stderr
69
+ try:
70
+ if not stream.isatty():
71
+ return False
72
+ except Exception: # noqa: BLE001 - a stream with no working isatty() is never a TTY
73
+ return False
74
+ if os.environ.get("NO_COLOR"):
75
+ return False
76
+ if IS_WINDOWS:
77
+ return _WIN_ANSI_OK or bool(os.environ.get("WT_SESSION"))
78
+ return True
79
+
80
+
81
+ def have(cmd: str) -> bool:
82
+ """True if *cmd* resolves to an executable on PATH."""
83
+ return shutil.which(cmd) is not None
84
+
85
+
86
+ def home_config_dir(env_var: str, default: Path) -> Path:
87
+ """Resolve a config path: ``$<env_var>`` override, else *default*."""
88
+ override = os.environ.get(env_var)
89
+ return Path(override) if override else default
90
+
91
+
92
+ # ── interruptible subprocess ─────────────────────────────────────────────────
93
+ #
94
+ # The zsh original's ``_aicp_timeout`` wraps every CLI invocation with GNU
95
+ # `timeout --foreground` specifically so Ctrl+C reaches the child directly:
96
+ # without --foreground, `timeout` puts the child in a NEW process group, and
97
+ # the terminal only delivers SIGINT to the FOREGROUND process group — so the
98
+ # signal never reached the CLI, the CLI never exited, and the whole script
99
+ # hung waiting on it (a real, previously-observed bug, not a theoretical one;
100
+ # see the long comment above `_aicp_timeout` in bin/aicp). The documented cost
101
+ # there is that a timeout then only signals the CLI itself, not any
102
+ # grandchildren it spawned — an interruptible Ctrl+C is worth more than
103
+ # reaping those.
104
+ #
105
+ # run_interruptible reproduces the same posture without shelling out to GNU
106
+ # timeout at all:
107
+ # POSIX the child is placed in THIS process's own process group (i.e. no
108
+ # new group is created for it — the same effect --foreground
109
+ # achieves), so Ctrl+C at the terminal reaches it directly, same as
110
+ # if it had been run with no wrapper at all.
111
+ # Windows there is no equivalent "foreground process group" concept.
112
+ # CREATE_NEW_PROCESS_GROUP lets this process forward an interrupt to
113
+ # just the child WITHOUT also killing itself (plain Ctrl+C on
114
+ # Windows targets every process attached to the same console) — but
115
+ # that means the child does NOT receive the console's own Ctrl+C
116
+ # directly; the caller must catch KeyboardInterrupt and forward the
117
+ # event itself. The event sent is CTRL_BREAK_EVENT, not
118
+ # CTRL_C_EVENT: Win32's GenerateConsoleCtrlEvent (what
119
+ # Popen.send_signal calls under the hood) documents that
120
+ # CTRL_C_EVENT CANNOT target a specific, non-zero process group —
121
+ # the call reports success but the child is never actually
122
+ # signaled — while CTRL_BREAK_EVENT can. This is an honest
123
+ # limitation, not a full port of the POSIX behavior: a child that
124
+ # ignores CTRL_BREAK_EVENT (or is a console subsystem app with no
125
+ # handler installed), or that treats CTRL_BREAK as an unconditional
126
+ # kill rather than a graceful one (a real difference from Ctrl+C,
127
+ # which most well-behaved CLIs treat as cancellable), will not stop
128
+ # cleanly, and there is no equivalent of --foreground's direct
129
+ # delivery. Callers on Windows needing a hard kill should follow up
130
+ # with Popen.terminate()/kill() rather than assume the event lands.
131
+ def run_interruptible(cmd, **kwargs) -> subprocess.CompletedProcess[str]:
132
+ """Run *cmd* so Ctrl+C reaches it directly (POSIX) or can be forwarded
133
+ (Windows), returning a ``subprocess.CompletedProcess`` like
134
+ ``subprocess.run`` would.
135
+
136
+ T2 layers a timeout and a kill-after grace period on top of this (its own
137
+ fallback-chain budget per CLI); T4 uses it bare for slow, uninterruptible
138
+ calls like ``git fetch``. Extra ``**kwargs`` are passed through to
139
+ ``subprocess.Popen`` unchanged (e.g. ``cwd``, ``env``, ``stdout``).
140
+ """
141
+ kwargs.setdefault("text", True)
142
+ if IS_WINDOWS:
143
+ kwargs["creationflags"] = kwargs.get("creationflags", 0) | subprocess.CREATE_NEW_PROCESS_GROUP
144
+ with subprocess.Popen(cmd, **kwargs) as proc:
145
+ try:
146
+ stdout, stderr = proc.communicate()
147
+ except KeyboardInterrupt:
148
+ try:
149
+ # CTRL_C_EVENT cannot target a specific process group (Win32
150
+ # would report success while never actually signaling the
151
+ # child) — CTRL_BREAK_EVENT is the one that can. See the
152
+ # module comment above for the full explanation.
153
+ proc.send_signal(signal.CTRL_BREAK_EVENT) # type: ignore[attr-defined]
154
+ except Exception: # noqa: BLE001 - CTRL_BREAK_EVENT delivery can fail many ways; fall back to a hard terminate
155
+ proc.terminate()
156
+ stdout, stderr = proc.communicate()
157
+ raise
158
+ except BaseException:
159
+ proc.kill()
160
+ raise
161
+ return subprocess.CompletedProcess(cmd, proc.returncode, stdout, stderr)
162
+
163
+ # POSIX: start_new_session=False (the default) keeps the child in this
164
+ # process's own process group — no new group is created for it, so the
165
+ # terminal's SIGINT (delivered to the whole foreground process group)
166
+ # reaches the child the same way it would with no wrapper at all. This is
167
+ # the direct equivalent of GNU `timeout --foreground`.
168
+ # `with Popen(...)` plus a kill on any unhandled exception, the way
169
+ # subprocess.run itself does it: between spawn and wait, anything escaping
170
+ # that is NOT the interrupt handled below would otherwise leave a live
171
+ # child behind with nobody left to reap it.
172
+ with subprocess.Popen(cmd, **kwargs) as proc:
173
+ try:
174
+ stdout, stderr = proc.communicate()
175
+ except KeyboardInterrupt:
176
+ # The child already received the same SIGINT (same process group);
177
+ # wait for it to unwind rather than second-guessing it with a kill.
178
+ stdout, stderr = proc.communicate()
179
+ raise
180
+ except BaseException:
181
+ proc.kill()
182
+ raise
183
+ return subprocess.CompletedProcess(cmd, proc.returncode, stdout, stderr)
aicp/budget.py ADDED
@@ -0,0 +1,158 @@
1
+ """Per-CLI timeout budget: a floor that grows with the diff, widened by history.
2
+
3
+ Port of ``_aicp_budget``/``_aicp_history_budget`` in ``~/scripts/bin/aicp``.
4
+
5
+ floor AICP_TIMEOUT_BASE covers cold start plus a small prompt
6
+ growth AICP_TIMEOUT_PER_FILE per changed or untracked file
7
+ AICP_TIMEOUT_PER_100L per 100 changed lines in tracked files
8
+ ceiling AICP_TIMEOUT_MAX so a real hang is still caught
9
+ override AICP_STEP_TIMEOUT pins the budget, skipping formula AND history
10
+
11
+ The budget is computed per CLI, inside the fallback loop rather than once above
12
+ it, so a fast CLI's own history never inflates a slow CLI's budget. History may
13
+ only WIDEN the budget above the formula, never shrink it below — and the
14
+ widened value is deliberately NOT re-capped at ``AICP_TIMEOUT_MAX``: that
15
+ ceiling exists to bound the no-history default, while a recorded successful run
16
+ is direct evidence this CLI legitimately needs that long.
17
+
18
+ Every value is validated as a plain non-negative integer before any arithmetic.
19
+ A malformed value falls back to :data:`FLOOR` with a stated note instead of
20
+ crashing: a bad value in a config file would otherwise brick aicp in every repo
21
+ on the machine, not just the one invocation that set it.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import os
27
+ import re
28
+ import subprocess
29
+ from dataclasses import dataclass
30
+ from pathlib import Path
31
+
32
+ from aicp import timing
33
+ from aicp.i18n import t
34
+
35
+ __all__ = ["FLOOR", "Budget", "compute"]
36
+
37
+ FLOOR = 180 # the zsh original's own documented AICP_TIMEOUT_BASE default
38
+
39
+ _DEFAULTS = {
40
+ "AICP_TIMEOUT_BASE": 180,
41
+ "AICP_TIMEOUT_PER_FILE": 15,
42
+ "AICP_TIMEOUT_PER_100L": 5,
43
+ "AICP_TIMEOUT_MAX": 1800,
44
+ }
45
+
46
+ _INT = re.compile(r"^[0-9]+$") # zsh's <->; str.isdigit() would accept "²"
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class Budget:
51
+ """Seconds allowed for one CLI invocation, plus the note explaining it."""
52
+
53
+ seconds: int
54
+ note: str
55
+
56
+
57
+ def _env(name: str, default: str) -> str:
58
+ """*name*'s value, treating exported-but-EMPTY as unset.
59
+
60
+ zsh sets every knob with ``: "${AICP_TIMEOUT_BASE:=180}"``, and ``:=``
61
+ substitutes the default for an unset **or empty** variable — so
62
+ ``AICP_TIMEOUT_BASE=""`` computes a real formula there rather than being
63
+ reported as a bad value. ``os.environ.get`` alone would not.
64
+ """
65
+ raw = os.environ.get(name)
66
+ return default if raw is None or raw == "" else raw
67
+
68
+
69
+ def _int_env(name: str) -> int | None:
70
+ """The knob as an int, or None if it is set to something non-numeric."""
71
+ raw = _env(name, str(_DEFAULTS[name]))
72
+ return int(raw) if _INT.match(raw) else None
73
+
74
+
75
+ def _git_count(args: list[str], cwd: Path | None) -> str:
76
+ try:
77
+ done = subprocess.run(
78
+ ["git", *args], cwd=cwd, capture_output=True, text=True, timeout=30, check=False
79
+ )
80
+ except (OSError, subprocess.SubprocessError):
81
+ return ""
82
+ return done.stdout if done.returncode == 0 else ""
83
+
84
+
85
+ def _pending(cwd: Path | None) -> tuple[int, int]:
86
+ """``(changed files, changed lines)`` per git, or ``(0, 0)`` if it can't say."""
87
+ files = len(_git_count(["status", "--porcelain"], cwd).splitlines())
88
+ lines = 0
89
+ for row in _git_count(["diff", "HEAD", "--numstat"], cwd).splitlines():
90
+ added, _, rest = row.partition("\t")
91
+ deleted = rest.partition("\t")[0]
92
+ # Binary files report "-" for both counts; awk read those as 0 too.
93
+ lines += sum(int(n) for n in (added, deleted) if _INT.match(n))
94
+ return files, lines
95
+
96
+
97
+ def _history(cli: str) -> int:
98
+ """This CLI's largest successful run, multiplied out — 0 if there is none."""
99
+ raw_lines = _env("AICP_TIMEOUT_HISTORY_LINES", "500")
100
+ lines = int(raw_lines) if _INT.match(raw_lines) else 0
101
+ try:
102
+ mult = float(_env("AICP_TIMEOUT_HISTORY_MULT", "1.3"))
103
+ except ValueError:
104
+ return 0 # unusable multiplier: no widening, same as no history at all
105
+ best = timing.max_ok_seconds(cli, lines=lines)
106
+ if best <= 0:
107
+ return 0
108
+ try:
109
+ # `(max * mult) + 0.999999` truncated — the zsh/awk expression, i.e. ceil.
110
+ widened = int(best * mult + 0.999999)
111
+ except (OverflowError, ValueError):
112
+ # float() accepts "inf"/"1e400"/"nan" without raising above, and the
113
+ # product then has no integer form at all. The zsh original gates the
114
+ # awk result the same way afterwards -- `[[ "$history" == <-> ]] ||
115
+ # history=0` -- rather than trusting the computation; a value settable
116
+ # from .aicprc must never be able to crash aicp in every repo on the
117
+ # machine, which is this module's stated no-crash invariant.
118
+ return 0
119
+ return max(widened, 0)
120
+
121
+
122
+ def compute(cli: str, *, cwd: Path | None = None) -> Budget:
123
+ """The seconds *cli* gets for one step, and the note shown beside it."""
124
+ pinned = os.environ.get("AICP_STEP_TIMEOUT")
125
+ if pinned:
126
+ if _INT.match(pinned):
127
+ return Budget(int(pinned), t("budget_note_fixed", "fixed"))
128
+ return Budget(
129
+ FLOOR, t("budget_note_bad_step", "bad AICP_STEP_TIMEOUT — using floor")
130
+ )
131
+
132
+ knobs = {name: _int_env(name) for name in _DEFAULTS}
133
+ if any(value is None for value in knobs.values()):
134
+ return Budget(
135
+ FLOOR, t("budget_note_bad_env", "bad AICP_TIMEOUT_* value — using floor")
136
+ )
137
+
138
+ files, lines = _pending(cwd)
139
+ formula = (
140
+ knobs["AICP_TIMEOUT_BASE"]
141
+ + knobs["AICP_TIMEOUT_PER_FILE"] * files
142
+ + knobs["AICP_TIMEOUT_PER_100L"] * lines // 100
143
+ )
144
+ formula = min(formula, knobs["AICP_TIMEOUT_MAX"])
145
+
146
+ history = _history(cli)
147
+ if history > formula:
148
+ return Budget(
149
+ history,
150
+ t(
151
+ "budget_note_history",
152
+ "%s files · %s lines · history %ss",
153
+ files,
154
+ lines,
155
+ history,
156
+ ),
157
+ )
158
+ return Budget(formula, t("budget_note_plain", "%s files · %s lines", files, lines))