robotframework-harness 1.0.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.
- rf_harness/__init__.py +3 -0
- rf_harness/_assets/.claude-plugin/plugin.json +10 -0
- rf_harness/_assets/bin/rfh-launch +60 -0
- rf_harness/_assets/bin/rfh-launch.cmd +19 -0
- rf_harness/_assets/bin/rfh_launch.py +719 -0
- rf_harness/_assets/hooks/hooks.json +84 -0
- rf_harness/_assets/skills/rf-harness/SKILL.md +78 -0
- rf_harness/_assets/skills/rf-harness/references/browser.md +157 -0
- rf_harness/_assets/skills/rf-harness/references/debug.md +39 -0
- rf_harness/_assets/skills/rf-harness/references/explore.md +52 -0
- rf_harness/_assets/skills/rf-harness/references/requests.md +37 -0
- rf_harness/_assets/skills/rf-harness/references/results.md +34 -0
- rf_harness/_assets/skills/rf-harness/references/selenium.md +40 -0
- rf_harness/_assets/skills/rf-harness/references/waits.md +28 -0
- rf_harness/_assets/variants/codex/hooks.json +72 -0
- rf_harness/_assets/variants/copilot/hooks.json +53 -0
- rf_harness/_assets/variants/opencode/plugins/rf-harness.js +130 -0
- rf_harness/config.py +255 -0
- rf_harness/installer/__init__.py +1 -0
- rf_harness/installer/_assets.py +73 -0
- rf_harness/installer/adapters/__init__.py +37 -0
- rf_harness/installer/adapters/_base.py +189 -0
- rf_harness/installer/adapters/claude_code.py +88 -0
- rf_harness/installer/adapters/codex.py +108 -0
- rf_harness/installer/adapters/copilot_cli.py +89 -0
- rf_harness/installer/adapters/opencode.py +62 -0
- rf_harness/installer/adapters/vscode.py +56 -0
- rf_harness/installer/cli.py +490 -0
- rf_harness/installer/codex_trust.py +190 -0
- rf_harness/installer/coexist.py +211 -0
- rf_harness/installer/demo_site/index.html +28 -0
- rf_harness/installer/doctor.py +343 -0
- rf_harness/installer/envfind.py +354 -0
- rf_harness/installer/executor.py +288 -0
- rf_harness/installer/homes.py +102 -0
- rf_harness/installer/manifest.py +204 -0
- rf_harness/installer/prompt.py +58 -0
- rf_harness/installer/transforms.py +483 -0
- rf_harness/runtime/__init__.py +1 -0
- rf_harness/runtime/analyzer.py +132 -0
- rf_harness/runtime/data/failure_rules.json +77 -0
- rf_harness/runtime/data/kwnames_cache.json +1 -0
- rf_harness/runtime/data/repl_format_rules.json +455 -0
- rf_harness/runtime/data/robocop-agent.toml +58 -0
- rf_harness/runtime/desktop/__init__.py +87 -0
- rf_harness/runtime/desktop/_env.py +134 -0
- rf_harness/runtime/desktop/decision.py +616 -0
- rf_harness/runtime/desktop/focus.py +1094 -0
- rf_harness/runtime/desktop/guidance.py +91 -0
- rf_harness/runtime/desktop/launch_env.py +562 -0
- rf_harness/runtime/desktop/safety.py +509 -0
- rf_harness/runtime/desktop/signals.py +321 -0
- rf_harness/runtime/docs.py +190 -0
- rf_harness/runtime/docsgen.py +174 -0
- rf_harness/runtime/engine.py +1000 -0
- rf_harness/runtime/execd.py +1131 -0
- rf_harness/runtime/guards.py +294 -0
- rf_harness/runtime/hints.py +1523 -0
- rf_harness/runtime/hook.py +1897 -0
- rf_harness/runtime/hookcore.py +832 -0
- rf_harness/runtime/kwdoc.py +285 -0
- rf_harness/runtime/kwdocs.py +286 -0
- rf_harness/runtime/leanfmt.py +451 -0
- rf_harness/runtime/lintd.py +423 -0
- rf_harness/runtime/livetmo.py +385 -0
- rf_harness/runtime/pagestate.py +489 -0
- rf_harness/runtime/portable.py +191 -0
- rf_harness/runtime/procs.py +106 -0
- rf_harness/runtime/profiles.py +144 -0
- rf_harness/runtime/proto.py +120 -0
- rf_harness/runtime/pwlog.py +89 -0
- rf_harness/runtime/replfmt.py +556 -0
- rf_harness/runtime/rfh_repl_listener.py +244 -0
- rf_harness/runtime/richfmt.py +301 -0
- rf_harness/runtime/robotcode_adapter.py +157 -0
- rf_harness/runtime/rthooks.py +623 -0
- rf_harness/runtime/smoke.py +113 -0
- robotframework_harness-1.0.0.dist-info/METADATA +85 -0
- robotframework_harness-1.0.0.dist-info/RECORD +82 -0
- robotframework_harness-1.0.0.dist-info/WHEEL +4 -0
- robotframework_harness-1.0.0.dist-info/entry_points.txt +3 -0
- robotframework_harness-1.0.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,719 @@
|
|
|
1
|
+
"""rf-harness hook launcher: find the project's Python and hand the hook event to ``rf_harness.runtime.hook``.
|
|
2
|
+
|
|
3
|
+
Every agent's hook configuration runs::
|
|
4
|
+
|
|
5
|
+
python3 -S <plugin-or-install-dir>/bin/rfh-launch <Event> --agent <agent|auto> [--set RFH_NAME=value]...
|
|
6
|
+
|
|
7
|
+
with the agent's JSON payload on stdin. This module is stdlib only, runs on any Python 3.6+, and never fails:
|
|
8
|
+
a missing or incompatible runtime prints ONE setup hint on SessionStart and nothing otherwise, and every internal
|
|
9
|
+
error exits 0 (a crashing pre-tool hook denies the tool in Copilot CLI).
|
|
10
|
+
|
|
11
|
+
Interpreter order (design D3):
|
|
12
|
+
1. ``RFH_PYTHON`` (authoritative: no fallback when it lacks the runtime),
|
|
13
|
+
2. ``<project>/.rf/harness.json`` (written by ``rf-harness install``),
|
|
14
|
+
3. ``VIRTUAL_ENV`` / ``CONDA_PREFIX``,
|
|
15
|
+
4. ``<project>/.venv``,
|
|
16
|
+
5. ``UV_PROJECT_ENVIRONMENT`` or the project's poetry environment (computed, no subprocess),
|
|
17
|
+
6. ``python3`` on PATH.
|
|
18
|
+
The first interpreter whose site-packages hold ``robotframework_harness-<X.Y>.*.dist-info`` with the same major.minor
|
|
19
|
+
as ``LAUNCHER_VERSION`` and an ``rf_harness/runtime/hook.py`` wins. Nothing heavy is imported: venv site-packages are
|
|
20
|
+
scanned on disk; other interpreters report their ``sys.path`` once (cached per interpreter in the user cache dir).
|
|
21
|
+
|
|
22
|
+
Agent ``auto`` (the shared plugin hooks.json, read by Claude Code, Copilot CLI, VS Code and Codex) is resolved from
|
|
23
|
+
the environment, the parent processes and the payload. Copilot's SessionEnd is dropped (it fires after every run).
|
|
24
|
+
Identical events delivered twice within a few seconds (overlapping hook configurations) run once.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import os
|
|
28
|
+
import sys
|
|
29
|
+
import time
|
|
30
|
+
|
|
31
|
+
LAUNCHER_VERSION = "1.0.0"
|
|
32
|
+
SETUP_COMMAND = "uvx robotframework-harness install --runtime-only"
|
|
33
|
+
RUNTIME_MODULE = "rf_harness.runtime.hook"
|
|
34
|
+
DIST_PREFIX = "robotframework_harness-"
|
|
35
|
+
AGENTS = ("claude", "codex", "copilot", "vscode", "opencode")
|
|
36
|
+
AGENT_ALIASES = {"claude-code": "claude", "copilot-cli": "copilot", "vs-code": "vscode"}
|
|
37
|
+
CANONICAL_EVENTS = {
|
|
38
|
+
"sessionstart": "SessionStart",
|
|
39
|
+
"userpromptsubmit": "UserPromptSubmit",
|
|
40
|
+
"userpromptsubmitted": "UserPromptSubmit",
|
|
41
|
+
"pretooluse": "PreToolUse",
|
|
42
|
+
"posttooluse": "PostToolUse",
|
|
43
|
+
"posttoolusefailure": "PostToolUseFailure",
|
|
44
|
+
"stop": "Stop",
|
|
45
|
+
"agentstop": "Stop",
|
|
46
|
+
"sessionend": "SessionEnd",
|
|
47
|
+
}
|
|
48
|
+
VSCODE_TOOLS = frozenset((
|
|
49
|
+
"create_file", "replace_string_in_file", "multi_replace_string_in_file", "insert_edit_into_file", "edit_file",
|
|
50
|
+
"run_in_terminal", "read_file", "get_terminal_output", "manage_todo_list",
|
|
51
|
+
))
|
|
52
|
+
ANCESTOR_AGENTS = (("codex", "codex"), ("claude", "claude"), ("copilot", "copilot"), ("code", "vscode"),
|
|
53
|
+
("code-insiders", "vscode"), ("opencode", "opencode"))
|
|
54
|
+
PIPE_SAFE_BYTES = 16000 # smallest default pipe buffer (macOS); larger payloads go through a temp file
|
|
55
|
+
DEDUPE_SECONDS = 5.0
|
|
56
|
+
TRACE_ENV = "RF_HARNESS_LAUNCH_TRACE"
|
|
57
|
+
DEDUPE_ENV = "RF_HARNESS_DEDUPE_S"
|
|
58
|
+
CACHE_ENV = "RF_HARNESS_CACHE_DIR"
|
|
59
|
+
IS_WINDOWS = os.name == "nt"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ------------------------------------------------------------------------------------------------ JSON
|
|
63
|
+
# `import json` pulls in re/enum/functools/collections (~15 ms under -S, most of the launcher's own time); the C
|
|
64
|
+
# accelerator `_json` scans and encodes without them. Any problem falls back to the json module.
|
|
65
|
+
class _ScanContext:
|
|
66
|
+
strict = True
|
|
67
|
+
object_hook = None
|
|
68
|
+
object_pairs_hook = None
|
|
69
|
+
parse_float = float
|
|
70
|
+
parse_int = int
|
|
71
|
+
memo = {}
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def parse_constant(name):
|
|
75
|
+
return {"NaN": float("nan"), "Infinity": float("inf"), "-Infinity": float("-inf")}[name]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
_WS = " \t\n\r"
|
|
79
|
+
try:
|
|
80
|
+
import _json
|
|
81
|
+
|
|
82
|
+
_SCAN = _json.make_scanner(_ScanContext())
|
|
83
|
+
_ENC = _json.encode_basestring_ascii
|
|
84
|
+
except Exception: # pragma: no cover - e.g. PyPy
|
|
85
|
+
_SCAN = _ENC = None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def loads(text):
|
|
89
|
+
if _SCAN is not None:
|
|
90
|
+
start = len(text) - len(text.lstrip(_WS))
|
|
91
|
+
try:
|
|
92
|
+
value, end = _SCAN(text, start)
|
|
93
|
+
except Exception:
|
|
94
|
+
value, end = None, -1
|
|
95
|
+
if end >= 0 and not text[end:].strip(_WS):
|
|
96
|
+
return value
|
|
97
|
+
import json
|
|
98
|
+
|
|
99
|
+
return json.loads(text)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def dumps(value):
|
|
103
|
+
if _ENC is None: # pragma: no cover
|
|
104
|
+
import json
|
|
105
|
+
|
|
106
|
+
return json.dumps(value)
|
|
107
|
+
if value is None:
|
|
108
|
+
return "null"
|
|
109
|
+
if value is True:
|
|
110
|
+
return "true"
|
|
111
|
+
if value is False:
|
|
112
|
+
return "false"
|
|
113
|
+
if isinstance(value, str):
|
|
114
|
+
return _ENC(value)
|
|
115
|
+
if isinstance(value, (int, float)):
|
|
116
|
+
return repr(value) if value == value and value not in (float("inf"), float("-inf")) else "null"
|
|
117
|
+
if isinstance(value, dict):
|
|
118
|
+
return "{" + ", ".join(_ENC(str(k)) + ": " + dumps(v) for k, v in value.items()) + "}"
|
|
119
|
+
if isinstance(value, (list, tuple)):
|
|
120
|
+
return "[" + ", ".join(dumps(v) for v in value) + "]"
|
|
121
|
+
return _ENC(str(value))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ------------------------------------------------------------------------------------------------ arguments / input
|
|
125
|
+
def parse_args(argv):
|
|
126
|
+
"""Return (event, agent, sets, rest). ``--set RFH_X=v`` is consumed here; unknown args go to the runtime."""
|
|
127
|
+
event, agent, sets, rest = "", "auto", {}, []
|
|
128
|
+
i = 0
|
|
129
|
+
while i < len(argv):
|
|
130
|
+
arg = argv[i]
|
|
131
|
+
if arg == "--agent" and i + 1 < len(argv):
|
|
132
|
+
agent = argv[i + 1]
|
|
133
|
+
i += 2
|
|
134
|
+
continue
|
|
135
|
+
if arg.startswith("--agent="):
|
|
136
|
+
agent = arg.split("=", 1)[1]
|
|
137
|
+
i += 1
|
|
138
|
+
continue
|
|
139
|
+
if arg == "--set" and i + 1 < len(argv):
|
|
140
|
+
key, _, value = argv[i + 1].partition("=")
|
|
141
|
+
if key.startswith("RFH_"):
|
|
142
|
+
sets[key] = value
|
|
143
|
+
i += 2
|
|
144
|
+
continue
|
|
145
|
+
if not event and not arg.startswith("-"):
|
|
146
|
+
event = arg
|
|
147
|
+
else:
|
|
148
|
+
rest.append(arg)
|
|
149
|
+
i += 1
|
|
150
|
+
agent = (agent or "auto").strip().lower()
|
|
151
|
+
return event, AGENT_ALIASES.get(agent, agent), sets, rest
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def canonical_event(event):
|
|
155
|
+
return CANONICAL_EVENTS.get(event.replace("_", "").replace("-", "").lower(), event)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def read_stdin():
|
|
159
|
+
try:
|
|
160
|
+
stream = sys.stdin
|
|
161
|
+
if stream is None or stream.isatty():
|
|
162
|
+
return b""
|
|
163
|
+
return stream.buffer.read()
|
|
164
|
+
except Exception:
|
|
165
|
+
return b""
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def parse_payload(data):
|
|
169
|
+
if not data or not data.strip():
|
|
170
|
+
return {}
|
|
171
|
+
try:
|
|
172
|
+
payload = loads(data.decode("utf-8", "replace"))
|
|
173
|
+
except ValueError:
|
|
174
|
+
return {}
|
|
175
|
+
return payload if isinstance(payload, dict) else {}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ------------------------------------------------------------------------------------------------ agent detection
|
|
179
|
+
def ancestor_agent(max_depth=8):
|
|
180
|
+
"""Agent named by the nearest parent process (Linux /proc only; cheap file reads)."""
|
|
181
|
+
if not os.path.isdir("/proc/self"):
|
|
182
|
+
return None
|
|
183
|
+
pid = os.getppid()
|
|
184
|
+
for _ in range(max_depth):
|
|
185
|
+
if pid <= 1:
|
|
186
|
+
return None
|
|
187
|
+
try:
|
|
188
|
+
with open(f"/proc/{pid}/cmdline", "rb") as fh:
|
|
189
|
+
parts = [p for p in fh.read().split(b"\0") if p][:2]
|
|
190
|
+
with open(f"/proc/{pid}/stat", "rb") as fh:
|
|
191
|
+
stat = fh.read()
|
|
192
|
+
except OSError:
|
|
193
|
+
return None
|
|
194
|
+
names = [os.path.basename(p.decode("utf-8", "replace")).lower() for p in parts]
|
|
195
|
+
for exe, agent in ANCESTOR_AGENTS:
|
|
196
|
+
if any(n == exe or n == exe + ".exe" for n in names):
|
|
197
|
+
return agent
|
|
198
|
+
try:
|
|
199
|
+
pid = int(stat[stat.rindex(b")") + 2:].split()[1])
|
|
200
|
+
except (ValueError, IndexError):
|
|
201
|
+
return None
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def detect_agent(event, payload, environ, ancestry=None):
|
|
206
|
+
"""Concrete agent for ``--agent auto``: environment, then parent processes, then the payload shape."""
|
|
207
|
+
if environ.get("COPILOT_CLI"):
|
|
208
|
+
return "copilot"
|
|
209
|
+
if environ.get("CLAUDECODE"):
|
|
210
|
+
return "claude"
|
|
211
|
+
if "turn_id" in payload:
|
|
212
|
+
return "codex"
|
|
213
|
+
tool = str(payload.get("tool_name") or "")
|
|
214
|
+
tool_input = payload.get("tool_input") if isinstance(payload.get("tool_input"), dict) else {}
|
|
215
|
+
if tool in VSCODE_TOOLS or (tool == "apply_patch" and "input" in tool_input) or "filePath" in tool_input:
|
|
216
|
+
return "vscode"
|
|
217
|
+
found = ancestry() if ancestry is not None else ancestor_agent()
|
|
218
|
+
if found:
|
|
219
|
+
return found
|
|
220
|
+
if "model" in payload and "transcript_path" in payload:
|
|
221
|
+
return "codex"
|
|
222
|
+
if any(k.startswith("VSCODE_") for k in environ):
|
|
223
|
+
return "vscode"
|
|
224
|
+
if event[:1].islower():
|
|
225
|
+
return "copilot"
|
|
226
|
+
return "claude"
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ------------------------------------------------------------------------------------------------ project root
|
|
230
|
+
def _has_robot_files(directory, limit=4000):
|
|
231
|
+
try:
|
|
232
|
+
with os.scandir(directory) as it:
|
|
233
|
+
for n, entry in enumerate(it):
|
|
234
|
+
if n >= limit:
|
|
235
|
+
return False
|
|
236
|
+
if entry.name.endswith(".robot"):
|
|
237
|
+
return True
|
|
238
|
+
except OSError:
|
|
239
|
+
return False
|
|
240
|
+
return False
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def project_root(start):
|
|
244
|
+
"""Nearest ancestor with .rf/, .git or pyproject.toml; else the nearest with *.robot files; else ``start``."""
|
|
245
|
+
start = os.path.abspath(start)
|
|
246
|
+
home = os.path.abspath(os.path.expanduser("~"))
|
|
247
|
+
directory, robot_dir = start, None
|
|
248
|
+
for _ in range(64):
|
|
249
|
+
if (os.path.isdir(os.path.join(directory, ".rf")) or os.path.exists(os.path.join(directory, ".git"))
|
|
250
|
+
or os.path.isfile(os.path.join(directory, "pyproject.toml"))):
|
|
251
|
+
return directory
|
|
252
|
+
if robot_dir is None and directory != home and _has_robot_files(directory):
|
|
253
|
+
robot_dir = directory
|
|
254
|
+
parent = os.path.dirname(directory)
|
|
255
|
+
if parent == directory:
|
|
256
|
+
break
|
|
257
|
+
directory = parent
|
|
258
|
+
return robot_dir or start
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def start_dir(payload):
|
|
262
|
+
cwd = payload.get("cwd") if isinstance(payload.get("cwd"), str) else ""
|
|
263
|
+
if cwd and os.path.isdir(cwd):
|
|
264
|
+
return cwd
|
|
265
|
+
return os.getcwd()
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# ------------------------------------------------------------------------------------------------ interpreters
|
|
269
|
+
def read_json(path):
|
|
270
|
+
try:
|
|
271
|
+
with open(path, encoding="utf-8") as fh:
|
|
272
|
+
data = loads(fh.read())
|
|
273
|
+
return data if isinstance(data, dict) else {}
|
|
274
|
+
except (OSError, ValueError):
|
|
275
|
+
return {}
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def venv_python(prefix):
|
|
279
|
+
if not prefix:
|
|
280
|
+
return None
|
|
281
|
+
names = (("Scripts", "python.exe"), ("python.exe",)) if IS_WINDOWS else (("bin", "python"), ("bin", "python3"))
|
|
282
|
+
for parts in names:
|
|
283
|
+
path = os.path.join(prefix, *parts)
|
|
284
|
+
if os.path.isfile(path):
|
|
285
|
+
return path
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _project_name(pyproject_text):
|
|
290
|
+
import re
|
|
291
|
+
|
|
292
|
+
for section in ("tool.poetry", "project"):
|
|
293
|
+
match = re.search(r"^\[" + re.escape(section) + r"\]\s*$(.*?)(?=^\[|\Z)", pyproject_text, re.M | re.S)
|
|
294
|
+
if match:
|
|
295
|
+
name = re.search(r"""^name\s*=\s*["']([^"']+)["']""", match.group(1), re.M)
|
|
296
|
+
if name:
|
|
297
|
+
return name.group(1)
|
|
298
|
+
return None
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def poetry_virtualenvs_dir(environ):
|
|
302
|
+
if environ.get("POETRY_VIRTUALENVS_PATH"):
|
|
303
|
+
return environ["POETRY_VIRTUALENVS_PATH"]
|
|
304
|
+
if environ.get("POETRY_CACHE_DIR"):
|
|
305
|
+
return os.path.join(environ["POETRY_CACHE_DIR"], "virtualenvs")
|
|
306
|
+
home = os.path.expanduser("~")
|
|
307
|
+
if IS_WINDOWS:
|
|
308
|
+
base = environ.get("LOCALAPPDATA") or os.path.join(home, "AppData", "Local")
|
|
309
|
+
return os.path.join(base, "pypoetry", "Cache", "virtualenvs")
|
|
310
|
+
if sys.platform == "darwin":
|
|
311
|
+
return os.path.join(home, "Library", "Caches", "pypoetry", "virtualenvs")
|
|
312
|
+
return os.path.join(environ.get("XDG_CACHE_HOME") or os.path.join(home, ".cache"), "pypoetry", "virtualenvs")
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def poetry_env_name(name, root):
|
|
316
|
+
"""Poetry's ``EnvManager.generate_env_name`` (name + 8 chars of the hashed project path)."""
|
|
317
|
+
import base64
|
|
318
|
+
import hashlib
|
|
319
|
+
import re
|
|
320
|
+
|
|
321
|
+
sanitized = re.sub(r'[ $`!*@"\\\r\n\t]', "_", name.lower())[:42]
|
|
322
|
+
digest = hashlib.sha256(os.path.normcase(os.path.realpath(root)).encode("utf-8")).digest()
|
|
323
|
+
return sanitized + "-" + base64.urlsafe_b64encode(digest).decode()[:8]
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def poetry_python(root, environ):
|
|
327
|
+
pyproject = os.path.join(root, "pyproject.toml")
|
|
328
|
+
try:
|
|
329
|
+
with open(pyproject, encoding="utf-8") as fh:
|
|
330
|
+
text = fh.read()
|
|
331
|
+
except OSError:
|
|
332
|
+
return None
|
|
333
|
+
if "[tool.poetry" not in text and not os.path.isfile(os.path.join(root, "poetry.lock")):
|
|
334
|
+
return None
|
|
335
|
+
name = _project_name(text)
|
|
336
|
+
base = poetry_virtualenvs_dir(environ)
|
|
337
|
+
if not name or not os.path.isdir(base):
|
|
338
|
+
return None
|
|
339
|
+
prefix = poetry_env_name(name, root) + "-py"
|
|
340
|
+
try:
|
|
341
|
+
matches = sorted(e for e in os.listdir(base) if e.startswith(prefix))
|
|
342
|
+
except OSError:
|
|
343
|
+
return None
|
|
344
|
+
for entry in reversed(matches):
|
|
345
|
+
python = venv_python(os.path.join(base, entry))
|
|
346
|
+
if python:
|
|
347
|
+
return python
|
|
348
|
+
return None
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def path_python(environ):
|
|
352
|
+
names = ("python.exe", "python3.exe") if IS_WINDOWS else ("python3",)
|
|
353
|
+
for directory in (environ.get("PATH") or "").split(os.pathsep):
|
|
354
|
+
for name in names:
|
|
355
|
+
path = os.path.join(directory, name)
|
|
356
|
+
if os.path.isfile(path) and os.access(path, os.X_OK):
|
|
357
|
+
return path
|
|
358
|
+
return None
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def candidates(root, environ):
|
|
362
|
+
"""(source, python) pairs in the D3 order; RFH_PYTHON alone when set."""
|
|
363
|
+
explicit = environ.get("RFH_PYTHON")
|
|
364
|
+
if explicit:
|
|
365
|
+
return [("RFH_PYTHON", explicit)]
|
|
366
|
+
found = []
|
|
367
|
+
harness = read_json(os.path.join(root, ".rf", "harness.json"))
|
|
368
|
+
if isinstance(harness.get("python"), str):
|
|
369
|
+
found.append(("harness.json", harness["python"]))
|
|
370
|
+
for var in ("VIRTUAL_ENV", "CONDA_PREFIX"):
|
|
371
|
+
python = venv_python(environ.get(var))
|
|
372
|
+
if python:
|
|
373
|
+
found.append((var, python))
|
|
374
|
+
python = venv_python(os.path.join(root, ".venv"))
|
|
375
|
+
if python:
|
|
376
|
+
found.append((".venv", python))
|
|
377
|
+
uv_env = environ.get("UV_PROJECT_ENVIRONMENT")
|
|
378
|
+
if uv_env:
|
|
379
|
+
python = venv_python(uv_env if os.path.isabs(uv_env) else os.path.join(root, uv_env))
|
|
380
|
+
if python:
|
|
381
|
+
found.append(("UV_PROJECT_ENVIRONMENT", python))
|
|
382
|
+
python = poetry_python(root, environ)
|
|
383
|
+
if python:
|
|
384
|
+
found.append(("poetry", python))
|
|
385
|
+
python = path_python(environ)
|
|
386
|
+
if python:
|
|
387
|
+
found.append(("PATH", python))
|
|
388
|
+
seen, unique = set(), []
|
|
389
|
+
for source, python in found:
|
|
390
|
+
key = os.path.normcase(os.path.abspath(python))
|
|
391
|
+
if key not in seen:
|
|
392
|
+
seen.add(key)
|
|
393
|
+
unique.append((source, python))
|
|
394
|
+
return unique
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
# ------------------------------------------------------------------------------------------------ runtime check
|
|
398
|
+
def _expand_pth(site_dir, dirs):
|
|
399
|
+
try:
|
|
400
|
+
names = sorted(n for n in os.listdir(site_dir) if n.endswith(".pth"))
|
|
401
|
+
except OSError:
|
|
402
|
+
return
|
|
403
|
+
for name in names:
|
|
404
|
+
try:
|
|
405
|
+
with open(os.path.join(site_dir, name), encoding="utf-8", errors="replace") as fh:
|
|
406
|
+
for line in fh:
|
|
407
|
+
line = line.strip()
|
|
408
|
+
if not line or line.startswith(("#", "import ", "import\t")):
|
|
409
|
+
continue
|
|
410
|
+
path = line if os.path.isabs(line) else os.path.join(site_dir, line)
|
|
411
|
+
if os.path.isdir(path) and path not in dirs:
|
|
412
|
+
dirs.append(path)
|
|
413
|
+
except OSError:
|
|
414
|
+
continue
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def static_site_dirs(python):
|
|
418
|
+
"""site-packages of a venv / conda interpreter, found on disk (plus path entries of its .pth files)."""
|
|
419
|
+
prefix = os.path.dirname(os.path.dirname(os.path.abspath(python)))
|
|
420
|
+
if IS_WINDOWS and not os.path.isfile(os.path.join(prefix, "pyvenv.cfg")):
|
|
421
|
+
prefix = os.path.dirname(os.path.abspath(python))
|
|
422
|
+
if not (os.path.isfile(os.path.join(prefix, "pyvenv.cfg")) or os.path.isdir(os.path.join(prefix, "conda-meta"))):
|
|
423
|
+
return None
|
|
424
|
+
dirs = []
|
|
425
|
+
if IS_WINDOWS:
|
|
426
|
+
candidates_ = [os.path.join(prefix, "Lib", "site-packages")]
|
|
427
|
+
else:
|
|
428
|
+
lib = os.path.join(prefix, "lib")
|
|
429
|
+
try:
|
|
430
|
+
candidates_ = [os.path.join(lib, n, "site-packages") for n in sorted(os.listdir(lib)) if n.startswith("python")]
|
|
431
|
+
except OSError:
|
|
432
|
+
candidates_ = []
|
|
433
|
+
for site_dir in candidates_:
|
|
434
|
+
if os.path.isdir(site_dir):
|
|
435
|
+
dirs.append(site_dir)
|
|
436
|
+
for site_dir in list(dirs):
|
|
437
|
+
_expand_pth(site_dir, dirs)
|
|
438
|
+
return dirs
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def cache_dir(environ):
|
|
442
|
+
if environ.get(CACHE_ENV):
|
|
443
|
+
return environ[CACHE_ENV]
|
|
444
|
+
home = os.path.expanduser("~")
|
|
445
|
+
if IS_WINDOWS:
|
|
446
|
+
return os.path.join(environ.get("LOCALAPPDATA") or os.path.join(home, "AppData", "Local"), "rf-harness")
|
|
447
|
+
return os.path.join(environ.get("XDG_CACHE_HOME") or os.path.join(home, ".cache"), "rf-harness")
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def queried_sys_path(python, environ):
|
|
451
|
+
"""``sys.path`` reported by the interpreter itself, cached per interpreter path + mtime."""
|
|
452
|
+
try:
|
|
453
|
+
stamp = f"{os.path.abspath(python)}|{os.stat(python).st_mtime}"
|
|
454
|
+
except OSError:
|
|
455
|
+
return []
|
|
456
|
+
cache_file = os.path.join(cache_dir(environ), "launch-paths.json")
|
|
457
|
+
cache = read_json(cache_file)
|
|
458
|
+
if isinstance(cache.get(stamp), list):
|
|
459
|
+
return cache[stamp]
|
|
460
|
+
import subprocess
|
|
461
|
+
|
|
462
|
+
env = dict(environ)
|
|
463
|
+
env.pop("PYTHONSTARTUP", None)
|
|
464
|
+
try:
|
|
465
|
+
proc = subprocess.Popen([python, "-c", "import sys,json;print(json.dumps([p for p in sys.path if p]))"],
|
|
466
|
+
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, env=env)
|
|
467
|
+
out, _ = proc.communicate(timeout=5)
|
|
468
|
+
paths = loads(out.decode("utf-8", "replace") or "[]")
|
|
469
|
+
except Exception:
|
|
470
|
+
return []
|
|
471
|
+
if not isinstance(paths, list):
|
|
472
|
+
return []
|
|
473
|
+
try:
|
|
474
|
+
os.makedirs(os.path.dirname(cache_file), exist_ok=True)
|
|
475
|
+
cache = {k: v for k, v in cache.items() if isinstance(v, list)}
|
|
476
|
+
cache[stamp] = paths
|
|
477
|
+
tmp = f"{cache_file}.{os.getpid()}.tmp"
|
|
478
|
+
with open(tmp, "w", encoding="utf-8") as fh:
|
|
479
|
+
fh.write(dumps(dict(list(cache.items())[-32:])))
|
|
480
|
+
os.replace(tmp, cache_file)
|
|
481
|
+
except OSError:
|
|
482
|
+
pass
|
|
483
|
+
return paths
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def runtime_in(dirs):
|
|
487
|
+
"""(version or None, has_runtime_package) for a list of import directories."""
|
|
488
|
+
version, has_runtime = None, False
|
|
489
|
+
for directory in dirs:
|
|
490
|
+
if not has_runtime and os.path.isfile(os.path.join(directory, "rf_harness", "runtime", "hook.py")):
|
|
491
|
+
has_runtime = True
|
|
492
|
+
if version is None:
|
|
493
|
+
try:
|
|
494
|
+
names = os.listdir(directory)
|
|
495
|
+
except OSError:
|
|
496
|
+
continue
|
|
497
|
+
for name in names:
|
|
498
|
+
if name.startswith(DIST_PREFIX) and name.endswith(".dist-info"):
|
|
499
|
+
version = name[len(DIST_PREFIX):-len(".dist-info")] or _metadata_version(directory, name)
|
|
500
|
+
break
|
|
501
|
+
return version, has_runtime
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _metadata_version(directory, dist_info):
|
|
505
|
+
try:
|
|
506
|
+
with open(os.path.join(directory, dist_info, "METADATA"), encoding="utf-8", errors="replace") as fh:
|
|
507
|
+
for line in fh:
|
|
508
|
+
if line.startswith("Version:"):
|
|
509
|
+
return line.split(":", 1)[1].strip()
|
|
510
|
+
if not line.strip():
|
|
511
|
+
break
|
|
512
|
+
except OSError:
|
|
513
|
+
pass
|
|
514
|
+
return None
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def compatible(version):
|
|
518
|
+
return bool(version) and version.split(".")[:2] == LAUNCHER_VERSION.split(".")[:2]
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def inspect_python(python, root, environ, harness):
|
|
522
|
+
"""Return (status, version) with status in ok | missing | incompatible | absent."""
|
|
523
|
+
if not python or not os.path.isfile(python):
|
|
524
|
+
return "absent", None
|
|
525
|
+
dirs = static_site_dirs(python)
|
|
526
|
+
version, has_runtime = runtime_in(dirs or [])
|
|
527
|
+
if not (version and has_runtime):
|
|
528
|
+
recorded = harness.get("site_packages") if harness.get("python") == python else None
|
|
529
|
+
dirs = recorded if isinstance(recorded, list) and recorded else queried_sys_path(python, environ)
|
|
530
|
+
version, has_runtime = runtime_in(dirs)
|
|
531
|
+
if not (version and has_runtime):
|
|
532
|
+
return "missing", version
|
|
533
|
+
return ("ok" if compatible(version) else "incompatible"), version
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def resolve(root, environ):
|
|
537
|
+
"""First compatible interpreter: (python, source, status, version). status is that of the best candidate."""
|
|
538
|
+
harness = read_json(os.path.join(root, ".rf", "harness.json"))
|
|
539
|
+
first = None
|
|
540
|
+
for source, python in candidates(root, environ):
|
|
541
|
+
status, version = inspect_python(python, root, environ, harness)
|
|
542
|
+
if status == "ok":
|
|
543
|
+
return python, source, status, version
|
|
544
|
+
if first is None or (first[2] != "incompatible" and status == "incompatible"):
|
|
545
|
+
first = (python, source, status, version)
|
|
546
|
+
return first or (None, None, "missing", None)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
# ------------------------------------------------------------------------------------------------ output / hand-off
|
|
550
|
+
def setup_hint(agent, status, python, version):
|
|
551
|
+
where = f" ({python})" if python else ""
|
|
552
|
+
if status == "incompatible":
|
|
553
|
+
text = (f"[rfh] The Robot Framework harness runtime {version} in this project's Python environment{where} "
|
|
554
|
+
f"does not match the plugin {LAUNCHER_VERSION}; the harness hooks are inactive until you run: "
|
|
555
|
+
f"{SETUP_COMMAND}")
|
|
556
|
+
else:
|
|
557
|
+
text = (f"[rfh] The Robot Framework harness runtime is not installed in this project's Python environment"
|
|
558
|
+
f"{where}; the harness hooks are inactive until you run: {SETUP_COMMAND}")
|
|
559
|
+
if agent == "opencode":
|
|
560
|
+
return {"action": "context", "text": text}
|
|
561
|
+
if agent == "copilot":
|
|
562
|
+
return {"additionalContext": text}
|
|
563
|
+
return {"hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": text}}
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def trace(environ, record):
|
|
567
|
+
path = environ.get(TRACE_ENV)
|
|
568
|
+
if not path:
|
|
569
|
+
return
|
|
570
|
+
try:
|
|
571
|
+
with open(path, "a", encoding="utf-8") as fh:
|
|
572
|
+
fh.write(dumps(record) + "\n")
|
|
573
|
+
except OSError:
|
|
574
|
+
pass
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def is_duplicate(event, agent, data, environ):
|
|
578
|
+
"""True when the same event + payload was already launched within DEDUPE_SECONDS."""
|
|
579
|
+
try:
|
|
580
|
+
window = float(environ.get(DEDUPE_ENV, DEDUPE_SECONDS))
|
|
581
|
+
except ValueError:
|
|
582
|
+
window = DEDUPE_SECONDS
|
|
583
|
+
if window <= 0 or not data.strip():
|
|
584
|
+
return False
|
|
585
|
+
import zlib
|
|
586
|
+
|
|
587
|
+
blob = f"{event}\0{agent}\0".encode() + data
|
|
588
|
+
key = f"{zlib.crc32(blob):08x}{zlib.adler32(blob):08x}{len(blob):x}"
|
|
589
|
+
directory = os.path.join(cache_dir(environ), "dedupe")
|
|
590
|
+
path = os.path.join(directory, key)
|
|
591
|
+
now = time.time()
|
|
592
|
+
try:
|
|
593
|
+
os.makedirs(directory, exist_ok=True)
|
|
594
|
+
fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
595
|
+
os.close(fd)
|
|
596
|
+
except FileExistsError:
|
|
597
|
+
try:
|
|
598
|
+
if now - os.stat(path).st_mtime < window:
|
|
599
|
+
return True
|
|
600
|
+
os.utime(path, None)
|
|
601
|
+
except OSError:
|
|
602
|
+
return False
|
|
603
|
+
return False
|
|
604
|
+
except OSError:
|
|
605
|
+
return False
|
|
606
|
+
if int(now * 1000) % 50 == 0: # occasional cleanup of old markers
|
|
607
|
+
try:
|
|
608
|
+
for name in os.listdir(directory):
|
|
609
|
+
stale = os.path.join(directory, name)
|
|
610
|
+
if now - os.stat(stale).st_mtime > 60:
|
|
611
|
+
os.unlink(stale)
|
|
612
|
+
except OSError:
|
|
613
|
+
pass
|
|
614
|
+
return False
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def refeed_stdin(data):
|
|
618
|
+
"""Make ``data`` the next process's stdin (fd 0) before exec."""
|
|
619
|
+
if len(data) <= PIPE_SAFE_BYTES:
|
|
620
|
+
read_fd, write_fd = os.pipe()
|
|
621
|
+
view = memoryview(data)
|
|
622
|
+
while view:
|
|
623
|
+
written = os.write(write_fd, view)
|
|
624
|
+
view = view[written:]
|
|
625
|
+
os.close(write_fd)
|
|
626
|
+
os.dup2(read_fd, 0)
|
|
627
|
+
os.close(read_fd)
|
|
628
|
+
return
|
|
629
|
+
import tempfile
|
|
630
|
+
|
|
631
|
+
tmp = tempfile.TemporaryFile()
|
|
632
|
+
tmp.write(data)
|
|
633
|
+
tmp.flush()
|
|
634
|
+
tmp.seek(0)
|
|
635
|
+
os.dup2(tmp.fileno(), 0)
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def hand_off(python, event, agent, rest, data, env):
|
|
639
|
+
argv = [python, "-m", RUNTIME_MODULE, event, "--agent", agent] + list(rest)
|
|
640
|
+
if IS_WINDOWS: # no real exec on Windows: run, forward stdout and the exit code
|
|
641
|
+
import subprocess
|
|
642
|
+
|
|
643
|
+
try:
|
|
644
|
+
sys.stdout.flush()
|
|
645
|
+
except Exception:
|
|
646
|
+
pass
|
|
647
|
+
try:
|
|
648
|
+
code = subprocess.run(argv, input=data, env=env).returncode
|
|
649
|
+
except OSError:
|
|
650
|
+
return 0
|
|
651
|
+
return code if code in (0, 2) else 0
|
|
652
|
+
refeed_stdin(data)
|
|
653
|
+
os.execve(python, argv, env)
|
|
654
|
+
return 0 # pragma: no cover - execve does not return
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def emit(obj):
|
|
658
|
+
try:
|
|
659
|
+
sys.stdout.write(dumps(obj) + "\n")
|
|
660
|
+
sys.stdout.flush()
|
|
661
|
+
except Exception:
|
|
662
|
+
pass
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def run(argv, environ=None):
|
|
666
|
+
environ = dict(os.environ if environ is None else environ)
|
|
667
|
+
started = time.time()
|
|
668
|
+
event, agent, sets, rest = parse_args(argv)
|
|
669
|
+
data = read_stdin()
|
|
670
|
+
payload = parse_payload(data)
|
|
671
|
+
canon = canonical_event(event)
|
|
672
|
+
if agent not in AGENTS:
|
|
673
|
+
agent = detect_agent(event, payload, environ)
|
|
674
|
+
record = {"event": event, "agent": agent, "launcher": LAUNCHER_VERSION}
|
|
675
|
+
if agent == "copilot" and canon == "SessionEnd":
|
|
676
|
+
record.update(action="skip", reason="copilot fires SessionEnd after every run")
|
|
677
|
+
trace(environ, record)
|
|
678
|
+
return 0
|
|
679
|
+
root = project_root(start_dir(payload))
|
|
680
|
+
python, source, status, version = resolve(root, environ)
|
|
681
|
+
record.update(root=root, python=python, source=source, status=status, version=version)
|
|
682
|
+
if status != "ok":
|
|
683
|
+
record["action"] = "hint" if canon == "SessionStart" else "skip"
|
|
684
|
+
record["ms"] = round((time.time() - started) * 1000, 1)
|
|
685
|
+
trace(environ, record)
|
|
686
|
+
if canon == "SessionStart":
|
|
687
|
+
emit(setup_hint(agent, status, python, version))
|
|
688
|
+
return 0
|
|
689
|
+
if is_duplicate(canon, agent, data, environ):
|
|
690
|
+
record.update(action="skip", reason="duplicate delivery")
|
|
691
|
+
trace(environ, record)
|
|
692
|
+
return 0
|
|
693
|
+
env = dict(environ)
|
|
694
|
+
harness_env = read_json(os.path.join(root, ".rf", "harness.json")).get("env")
|
|
695
|
+
if isinstance(harness_env, dict):
|
|
696
|
+
for key, value in harness_env.items():
|
|
697
|
+
if isinstance(key, str) and key.startswith("RFH_") and key not in env:
|
|
698
|
+
env[key] = str(value)
|
|
699
|
+
env.update(sets)
|
|
700
|
+
record.update(action="exec", ms=round((time.time() - started) * 1000, 1))
|
|
701
|
+
trace(environ, record)
|
|
702
|
+
return hand_off(python, event, agent, rest, data, env)
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def main(argv=None):
|
|
706
|
+
code = 0
|
|
707
|
+
try:
|
|
708
|
+
code = run(sys.argv[1:] if argv is None else argv)
|
|
709
|
+
except BaseException: # noqa: B036 - the launcher must never fail a hook
|
|
710
|
+
code = 0
|
|
711
|
+
try:
|
|
712
|
+
sys.stdout.flush()
|
|
713
|
+
except Exception:
|
|
714
|
+
pass
|
|
715
|
+
os._exit(code if code in (0, 2) else 0)
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
if __name__ == "__main__":
|
|
719
|
+
main()
|