harness-detect 0.2.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.
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
"""Detect installed LLM harnesses and resolve their config/state paths.
|
|
2
|
+
|
|
3
|
+
This is a Python port of the ``@auron-labs/harness-detect`` TypeScript package,
|
|
4
|
+
the Go package at ``github.com/auron/harness-detect/packages/golang/harnessdetect``,
|
|
5
|
+
and the ``harness-detect`` Rust crate. It shares the same harness registry
|
|
6
|
+
(``data/harnesses.json``) and exposes the same API surface, adapted to
|
|
7
|
+
idiomatic Python naming.
|
|
8
|
+
|
|
9
|
+
Detection is data-driven: a harness counts as **installed** when a matching
|
|
10
|
+
executable is found on ``PATH`` **or** one or more of its known
|
|
11
|
+
config/state/cache/install/project paths exist on disk. All harness-specific
|
|
12
|
+
behavior lives in the registry JSON; this module contains no harness-specific
|
|
13
|
+
code paths.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import sys
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from importlib import resources
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Optional
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"CheckOptions",
|
|
29
|
+
"HarnessCheckResult",
|
|
30
|
+
"HarnessDefinition",
|
|
31
|
+
"HarnessEnvVar",
|
|
32
|
+
"HarnessError",
|
|
33
|
+
"HarnessInstallation",
|
|
34
|
+
"HarnessMatrix",
|
|
35
|
+
"HarnessPathSpec",
|
|
36
|
+
"HarnessRootDef",
|
|
37
|
+
"HarnessSupport",
|
|
38
|
+
"HarnessSupportArea",
|
|
39
|
+
"HarnessSupportPath",
|
|
40
|
+
"HarnessSupportRecord",
|
|
41
|
+
"HarnessSupportScope",
|
|
42
|
+
"ResolvedHarnessPath",
|
|
43
|
+
"check_harness",
|
|
44
|
+
"detect_harnesses",
|
|
45
|
+
"detect_installed_harnesses",
|
|
46
|
+
"get_raw_harness_data",
|
|
47
|
+
"get_harness_matrix",
|
|
48
|
+
"get_harness_support",
|
|
49
|
+
"list_harnesses",
|
|
50
|
+
"list_harness_support",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
__version__ = "0.2.0"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
# Registry loading (read at load time, mirroring the TypeScript approach)
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _load_matrix() -> HarnessMatrix:
|
|
62
|
+
"""Load and parse the bundled registry JSON."""
|
|
63
|
+
with (
|
|
64
|
+
resources.files("harness_detect.data")
|
|
65
|
+
.joinpath("harnesses.json")
|
|
66
|
+
.open("r", encoding="utf-8") as f
|
|
67
|
+
):
|
|
68
|
+
raw = json.load(f)
|
|
69
|
+
return HarnessMatrix(
|
|
70
|
+
version=raw["version"],
|
|
71
|
+
harnesses=tuple(HarnessDefinition.from_dict(h) for h in raw["harnesses"]),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
_MATRIX: Optional[HarnessMatrix] = None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _matrix() -> HarnessMatrix:
|
|
79
|
+
global _MATRIX
|
|
80
|
+
if _MATRIX is None:
|
|
81
|
+
_MATRIX = _load_matrix()
|
|
82
|
+
return _MATRIX
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
# Types
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class HarnessEnvVar:
|
|
92
|
+
"""A harness-relevant environment variable (documentation only)."""
|
|
93
|
+
|
|
94
|
+
name: str
|
|
95
|
+
description: str
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def from_dict(cls, d: dict) -> "HarnessEnvVar":
|
|
99
|
+
return cls(name=d["name"], description=d["description"])
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True)
|
|
103
|
+
class HarnessPathSpec:
|
|
104
|
+
"""One path template to check for a harness."""
|
|
105
|
+
|
|
106
|
+
id: str
|
|
107
|
+
category: str
|
|
108
|
+
kind: str
|
|
109
|
+
template: str
|
|
110
|
+
platforms: tuple[str, ...] = ()
|
|
111
|
+
|
|
112
|
+
@classmethod
|
|
113
|
+
def from_dict(cls, d: dict) -> "HarnessPathSpec":
|
|
114
|
+
return cls(
|
|
115
|
+
id=d["id"],
|
|
116
|
+
category=d["category"],
|
|
117
|
+
kind=d["kind"],
|
|
118
|
+
template=d["template"],
|
|
119
|
+
platforms=tuple(d.get("platforms", [])),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass(frozen=True)
|
|
124
|
+
class HarnessSupportPath:
|
|
125
|
+
"""One support-related path template."""
|
|
126
|
+
|
|
127
|
+
id: str
|
|
128
|
+
kind: str
|
|
129
|
+
template: str
|
|
130
|
+
platforms: tuple[str, ...] = ()
|
|
131
|
+
description: str = ""
|
|
132
|
+
|
|
133
|
+
@classmethod
|
|
134
|
+
def from_dict(cls, d: dict) -> "HarnessSupportPath":
|
|
135
|
+
return cls(
|
|
136
|
+
id=d["id"],
|
|
137
|
+
kind=d["kind"],
|
|
138
|
+
template=d["template"],
|
|
139
|
+
platforms=tuple(d.get("platforms", [])),
|
|
140
|
+
description=d.get("description", ""),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass(frozen=True)
|
|
145
|
+
class HarnessSupportScope:
|
|
146
|
+
"""One support capability leaf for one scope."""
|
|
147
|
+
|
|
148
|
+
status: str
|
|
149
|
+
paths: tuple[HarnessSupportPath, ...]
|
|
150
|
+
sources: tuple[str, ...]
|
|
151
|
+
confidence: str
|
|
152
|
+
notes: str = ""
|
|
153
|
+
|
|
154
|
+
@classmethod
|
|
155
|
+
def from_dict(cls, d: dict) -> "HarnessSupportScope":
|
|
156
|
+
return cls(
|
|
157
|
+
status=d["status"],
|
|
158
|
+
paths=tuple(HarnessSupportPath.from_dict(p) for p in d.get("paths", [])),
|
|
159
|
+
sources=tuple(d.get("sources", [])),
|
|
160
|
+
confidence=d["confidence"],
|
|
161
|
+
notes=d.get("notes", ""),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@dataclass(frozen=True)
|
|
166
|
+
class HarnessSupportArea:
|
|
167
|
+
"""One support capability area."""
|
|
168
|
+
|
|
169
|
+
global_: HarnessSupportScope
|
|
170
|
+
local: HarnessSupportScope
|
|
171
|
+
|
|
172
|
+
@classmethod
|
|
173
|
+
def from_dict(cls, d: dict) -> "HarnessSupportArea":
|
|
174
|
+
return cls(
|
|
175
|
+
global_=HarnessSupportScope.from_dict(d["global"]),
|
|
176
|
+
local=HarnessSupportScope.from_dict(d["local"]),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@dataclass(frozen=True)
|
|
181
|
+
class HarnessSupport:
|
|
182
|
+
"""Support metadata for a harness."""
|
|
183
|
+
|
|
184
|
+
config: HarnessSupportArea
|
|
185
|
+
skills: HarnessSupportArea
|
|
186
|
+
commands: HarnessSupportArea
|
|
187
|
+
agents: HarnessSupportArea
|
|
188
|
+
dot_agents: HarnessSupportArea
|
|
189
|
+
|
|
190
|
+
@classmethod
|
|
191
|
+
def from_dict(cls, d: dict) -> "HarnessSupport":
|
|
192
|
+
return cls(
|
|
193
|
+
config=HarnessSupportArea.from_dict(d["config"]),
|
|
194
|
+
skills=HarnessSupportArea.from_dict(d["skills"]),
|
|
195
|
+
commands=HarnessSupportArea.from_dict(d["commands"]),
|
|
196
|
+
agents=HarnessSupportArea.from_dict(d["agents"]),
|
|
197
|
+
dot_agents=HarnessSupportArea.from_dict(d["dotAgents"]),
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass(frozen=True)
|
|
202
|
+
class HarnessRootDef:
|
|
203
|
+
"""A derived template variable declared by a harness.
|
|
204
|
+
|
|
205
|
+
Roots are resolved in declaration order before path templates, allowing
|
|
206
|
+
later roots to reference earlier ones.
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
name: str
|
|
210
|
+
env: str = ""
|
|
211
|
+
use: str = ""
|
|
212
|
+
fallback: str = ""
|
|
213
|
+
|
|
214
|
+
@classmethod
|
|
215
|
+
def from_dict(cls, d: dict) -> "HarnessRootDef":
|
|
216
|
+
return cls(
|
|
217
|
+
name=d["name"],
|
|
218
|
+
env=d.get("env", ""),
|
|
219
|
+
use=d.get("use", ""),
|
|
220
|
+
fallback=d["fallback"],
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@dataclass(frozen=True)
|
|
225
|
+
class HarnessInstallation:
|
|
226
|
+
"""One documented installation method."""
|
|
227
|
+
|
|
228
|
+
method: str
|
|
229
|
+
package: str = ""
|
|
230
|
+
command: str = ""
|
|
231
|
+
url: str = ""
|
|
232
|
+
marketplace: str = ""
|
|
233
|
+
id: str = ""
|
|
234
|
+
platforms: tuple[str, ...] = ()
|
|
235
|
+
notes: str = ""
|
|
236
|
+
|
|
237
|
+
@classmethod
|
|
238
|
+
def from_dict(cls, d: dict) -> "HarnessInstallation":
|
|
239
|
+
return cls(
|
|
240
|
+
method=d["method"],
|
|
241
|
+
package=d.get("package", ""),
|
|
242
|
+
command=d.get("command", ""),
|
|
243
|
+
url=d.get("url", ""),
|
|
244
|
+
marketplace=d.get("marketplace", ""),
|
|
245
|
+
id=d.get("id", ""),
|
|
246
|
+
platforms=tuple(d.get("platforms", [])),
|
|
247
|
+
notes=d.get("notes", ""),
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@dataclass(frozen=True)
|
|
252
|
+
class HarnessDefinition:
|
|
253
|
+
"""A single LLM harness definition."""
|
|
254
|
+
|
|
255
|
+
key: str
|
|
256
|
+
name: str
|
|
257
|
+
aliases: tuple[str, ...]
|
|
258
|
+
executables: tuple[str, ...]
|
|
259
|
+
installations: tuple[HarnessInstallation, ...]
|
|
260
|
+
paths: tuple[HarnessPathSpec, ...]
|
|
261
|
+
roots: tuple[HarnessRootDef, ...]
|
|
262
|
+
support: HarnessSupport
|
|
263
|
+
env: tuple[HarnessEnvVar, ...]
|
|
264
|
+
sources: tuple[str, ...]
|
|
265
|
+
|
|
266
|
+
@classmethod
|
|
267
|
+
def from_dict(cls, d: dict) -> "HarnessDefinition":
|
|
268
|
+
return cls(
|
|
269
|
+
key=d["key"],
|
|
270
|
+
name=d["name"],
|
|
271
|
+
aliases=tuple(d.get("aliases", [])),
|
|
272
|
+
executables=tuple(d.get("executables", [])),
|
|
273
|
+
installations=tuple(
|
|
274
|
+
HarnessInstallation.from_dict(i) for i in d.get("installations", [])
|
|
275
|
+
),
|
|
276
|
+
paths=tuple(HarnessPathSpec.from_dict(p) for p in d.get("paths", [])),
|
|
277
|
+
roots=tuple(HarnessRootDef.from_dict(r) for r in d.get("roots", [])),
|
|
278
|
+
support=HarnessSupport.from_dict(d["support"]),
|
|
279
|
+
env=tuple(HarnessEnvVar.from_dict(e) for e in d.get("env", [])),
|
|
280
|
+
sources=tuple(d.get("sources", [])),
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@dataclass(frozen=True)
|
|
285
|
+
class HarnessMatrix:
|
|
286
|
+
"""The top-level registry document."""
|
|
287
|
+
|
|
288
|
+
version: int
|
|
289
|
+
harnesses: tuple[HarnessDefinition, ...]
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
@dataclass(frozen=True)
|
|
293
|
+
class HarnessSupportRecord:
|
|
294
|
+
"""One harness support entry."""
|
|
295
|
+
|
|
296
|
+
key: str
|
|
297
|
+
name: str
|
|
298
|
+
support: HarnessSupport
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@dataclass(frozen=True)
|
|
302
|
+
class ResolvedHarnessPath:
|
|
303
|
+
"""A resolved path entry: the original spec plus the resolved path and
|
|
304
|
+
whether it exists on disk."""
|
|
305
|
+
|
|
306
|
+
id: str
|
|
307
|
+
category: str
|
|
308
|
+
kind: str
|
|
309
|
+
template: str
|
|
310
|
+
platforms: tuple[str, ...]
|
|
311
|
+
path: Optional[str]
|
|
312
|
+
exists: bool
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@dataclass
|
|
316
|
+
class CheckOptions:
|
|
317
|
+
"""Options passed to :func:`check_harness` and :func:`detect_harnesses`.
|
|
318
|
+
|
|
319
|
+
When ``env`` is ``None`` the real process environment is used. When
|
|
320
|
+
provided, it **replaces** the process environment entirely (defaults are
|
|
321
|
+
still filled in for ``HOME``, ``XDG_*``, ``TMPDIR``, ``CWD``).
|
|
322
|
+
"""
|
|
323
|
+
|
|
324
|
+
env: Optional[dict[str, str]] = None
|
|
325
|
+
cwd: Optional[str] = None
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
@dataclass
|
|
329
|
+
class HarnessCheckResult:
|
|
330
|
+
"""The result of checking a single harness."""
|
|
331
|
+
|
|
332
|
+
key: str
|
|
333
|
+
name: str
|
|
334
|
+
installed: bool
|
|
335
|
+
executable_path: Optional[str]
|
|
336
|
+
harness: HarnessDefinition
|
|
337
|
+
paths: list[ResolvedHarnessPath] = field(default_factory=list)
|
|
338
|
+
matched_paths: list[ResolvedHarnessPath] = field(default_factory=list)
|
|
339
|
+
reasons: list[str] = field(default_factory=list)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
class HarnessError(Exception):
|
|
343
|
+
"""Raised when a harness key or alias is not found."""
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
# ---------------------------------------------------------------------------
|
|
347
|
+
# Public API
|
|
348
|
+
# ---------------------------------------------------------------------------
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def get_raw_harness_data() -> HarnessMatrix:
|
|
352
|
+
"""Return the full registry (immutable)."""
|
|
353
|
+
return _matrix()
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def get_harness_matrix() -> HarnessMatrix:
|
|
357
|
+
"""Return the full registry (immutable)."""
|
|
358
|
+
return get_raw_harness_data()
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def list_harnesses() -> list[HarnessDefinition]:
|
|
362
|
+
"""Return the list of harness definitions (immutable)."""
|
|
363
|
+
return list(_matrix().harnesses)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def get_harness_support(input: str) -> HarnessSupportRecord:
|
|
367
|
+
"""Return support metadata for one harness by key or alias."""
|
|
368
|
+
harness = _find_harness_definition(input)
|
|
369
|
+
if harness is None:
|
|
370
|
+
raise HarnessError(f"Unknown harness: {input}")
|
|
371
|
+
return HarnessSupportRecord(
|
|
372
|
+
key=harness.key,
|
|
373
|
+
name=harness.name,
|
|
374
|
+
support=harness.support,
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def list_harness_support() -> list[HarnessSupportRecord]:
|
|
379
|
+
"""Return support metadata for all harnesses."""
|
|
380
|
+
return [
|
|
381
|
+
HarnessSupportRecord(key=h.key, name=h.name, support=h.support)
|
|
382
|
+
for h in _matrix().harnesses
|
|
383
|
+
]
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def check_harness(
|
|
387
|
+
input: str, options: Optional[CheckOptions] = None
|
|
388
|
+
) -> HarnessCheckResult:
|
|
389
|
+
"""Check a single harness by key or alias (case-insensitive, trimmed).
|
|
390
|
+
|
|
391
|
+
Raises :class:`HarnessError` with the message ``"Unknown harness: <input>"``
|
|
392
|
+
if the key/alias is not found.
|
|
393
|
+
"""
|
|
394
|
+
opts = options or CheckOptions()
|
|
395
|
+
harness = _find_harness_definition(input)
|
|
396
|
+
if harness is None:
|
|
397
|
+
raise HarnessError(f"Unknown harness: {input}")
|
|
398
|
+
|
|
399
|
+
base_env = _with_defaults(opts.env, opts.cwd)
|
|
400
|
+
env = _resolve_harness_roots(harness, base_env)
|
|
401
|
+
executable_path = _find_executable(harness.executables, env)
|
|
402
|
+
paths = _resolve_paths(harness, env)
|
|
403
|
+
matched_paths = [p for p in paths if p.exists]
|
|
404
|
+
reasons: list[str] = []
|
|
405
|
+
|
|
406
|
+
if executable_path is not None:
|
|
407
|
+
reasons.append(f"executable:{os.path.basename(executable_path)}")
|
|
408
|
+
|
|
409
|
+
for entry in matched_paths:
|
|
410
|
+
reasons.append(f"{entry.category}:{entry.id}")
|
|
411
|
+
|
|
412
|
+
return HarnessCheckResult(
|
|
413
|
+
key=harness.key,
|
|
414
|
+
name=harness.name,
|
|
415
|
+
installed=executable_path is not None or bool(matched_paths),
|
|
416
|
+
executable_path=executable_path,
|
|
417
|
+
harness=harness,
|
|
418
|
+
paths=paths,
|
|
419
|
+
matched_paths=matched_paths,
|
|
420
|
+
reasons=reasons,
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def detect_harnesses(
|
|
425
|
+
options: Optional[CheckOptions] = None,
|
|
426
|
+
) -> list[HarnessCheckResult]:
|
|
427
|
+
"""Check every harness in the registry, returning one result per entry."""
|
|
428
|
+
opts = options or CheckOptions()
|
|
429
|
+
return [check_harness(h.key, opts) for h in _matrix().harnesses]
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def detect_installed_harnesses(
|
|
433
|
+
options: Optional[CheckOptions] = None,
|
|
434
|
+
) -> list[HarnessCheckResult]:
|
|
435
|
+
"""Return the subset of :func:`detect_harnesses` results whose ``installed``
|
|
436
|
+
field is ``True``."""
|
|
437
|
+
return [r for r in detect_harnesses(options) if r.installed]
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
# ---------------------------------------------------------------------------
|
|
441
|
+
# Internal: lookup
|
|
442
|
+
# ---------------------------------------------------------------------------
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _find_harness_definition(input: str) -> Optional[HarnessDefinition]:
|
|
446
|
+
key = input.strip().lower()
|
|
447
|
+
for harness in _matrix().harnesses:
|
|
448
|
+
if harness.key.lower() == key:
|
|
449
|
+
return harness
|
|
450
|
+
if any(a.lower() == key for a in harness.aliases):
|
|
451
|
+
return harness
|
|
452
|
+
return None
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
# ---------------------------------------------------------------------------
|
|
456
|
+
# Internal: environment defaults
|
|
457
|
+
# ---------------------------------------------------------------------------
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _with_defaults(
|
|
461
|
+
env: Optional[dict[str, str]],
|
|
462
|
+
cwd: Optional[str],
|
|
463
|
+
) -> dict[str, str]:
|
|
464
|
+
"""Compute the universal base-variable map (HOME, XDG_*, TMPDIR, CWD)
|
|
465
|
+
plus any caller-supplied env vars. Harness-specific derived roots are
|
|
466
|
+
resolved separately by :func:`_resolve_harness_roots`.
|
|
467
|
+
"""
|
|
468
|
+
out: dict[str, str] = dict(env) if env is not None else dict(os.environ)
|
|
469
|
+
|
|
470
|
+
home = out.get("HOME", "") or os.path.expanduser("~") or "/"
|
|
471
|
+
userprofile = out.get("USERPROFILE", "") or home
|
|
472
|
+
|
|
473
|
+
xdg_config_home = out.get("XDG_CONFIG_HOME", "") or os.path.join(home, ".config")
|
|
474
|
+
xdg_data_home = out.get("XDG_DATA_HOME", "") or os.path.join(
|
|
475
|
+
home, ".local", "share"
|
|
476
|
+
)
|
|
477
|
+
xdg_state_home = out.get("XDG_STATE_HOME", "") or os.path.join(
|
|
478
|
+
home, ".local", "state"
|
|
479
|
+
)
|
|
480
|
+
xdg_cache_home = out.get("XDG_CACHE_HOME", "") or os.path.join(home, ".cache")
|
|
481
|
+
|
|
482
|
+
if cwd:
|
|
483
|
+
cwd_val = cwd
|
|
484
|
+
else:
|
|
485
|
+
cwd_val = os.getcwd() or "."
|
|
486
|
+
|
|
487
|
+
tmpdir = out.get("TMPDIR", "") or tempfile_gettempdir()
|
|
488
|
+
|
|
489
|
+
out["HOME"] = home
|
|
490
|
+
out["USERPROFILE"] = userprofile
|
|
491
|
+
out["XDG_CONFIG_HOME"] = xdg_config_home
|
|
492
|
+
out["XDG_DATA_HOME"] = xdg_data_home
|
|
493
|
+
out["XDG_STATE_HOME"] = xdg_state_home
|
|
494
|
+
out["XDG_CACHE_HOME"] = xdg_cache_home
|
|
495
|
+
out["TMPDIR"] = tmpdir
|
|
496
|
+
out["CWD"] = cwd_val
|
|
497
|
+
|
|
498
|
+
return out
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def tempfile_gettempdir() -> str:
|
|
502
|
+
import tempfile
|
|
503
|
+
|
|
504
|
+
return tempfile.gettempdir()
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
# ---------------------------------------------------------------------------
|
|
508
|
+
# Internal: harness root resolution
|
|
509
|
+
# ---------------------------------------------------------------------------
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _resolve_harness_roots(
|
|
513
|
+
harness: HarnessDefinition,
|
|
514
|
+
base_env: dict[str, str],
|
|
515
|
+
) -> dict[str, str]:
|
|
516
|
+
"""Resolve the harness-specific derived template variables declared in
|
|
517
|
+
``harness.roots``. Resolution order follows declaration order so that later
|
|
518
|
+
roots can reference earlier roots in their ``fallback`` and ``use``
|
|
519
|
+
templates.
|
|
520
|
+
"""
|
|
521
|
+
out = dict(base_env)
|
|
522
|
+
|
|
523
|
+
for root in harness.roots:
|
|
524
|
+
env_val = base_env.get(root.env, "") if root.env else ""
|
|
525
|
+
env_val_nonempty = env_val if env_val else ""
|
|
526
|
+
|
|
527
|
+
if root.env and env_val_nonempty:
|
|
528
|
+
if root.use:
|
|
529
|
+
# Resolve "use" against base env + resolved roots + the env var itself.
|
|
530
|
+
tmp = dict(out)
|
|
531
|
+
tmp[root.env] = env_val_nonempty
|
|
532
|
+
value = _resolve_template(root.use, tmp)
|
|
533
|
+
else:
|
|
534
|
+
value = env_val_nonempty
|
|
535
|
+
else:
|
|
536
|
+
value = _resolve_template(root.fallback, out)
|
|
537
|
+
|
|
538
|
+
if value is not None and value != "":
|
|
539
|
+
out[root.name] = os.path.normpath(value)
|
|
540
|
+
|
|
541
|
+
return out
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
# ---------------------------------------------------------------------------
|
|
545
|
+
# Internal: template resolution
|
|
546
|
+
# ---------------------------------------------------------------------------
|
|
547
|
+
|
|
548
|
+
_TEMPLATE_VAR = re.compile(r"\$\{([^}]+)\}")
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _resolve_template(template: str, env: dict[str, str]) -> Optional[str]:
|
|
552
|
+
"""Replace all ``${VAR}`` placeholders with values from ``env``.
|
|
553
|
+
|
|
554
|
+
If any placeholder value is missing or empty, the **entire template
|
|
555
|
+
resolves to ``None``** (matching the TypeScript ``null`` / Go empty-string /
|
|
556
|
+
Rust ``None`` semantics). The resolved string is normalized via
|
|
557
|
+
:func:`os.path.normpath`.
|
|
558
|
+
"""
|
|
559
|
+
if not template:
|
|
560
|
+
return None
|
|
561
|
+
|
|
562
|
+
unresolved = False
|
|
563
|
+
|
|
564
|
+
def repl(m: re.Match[str]) -> str:
|
|
565
|
+
nonlocal unresolved
|
|
566
|
+
name = m.group(1)
|
|
567
|
+
value = env.get(name)
|
|
568
|
+
if value is None or value == "":
|
|
569
|
+
unresolved = True
|
|
570
|
+
return ""
|
|
571
|
+
return value
|
|
572
|
+
|
|
573
|
+
resolved = _TEMPLATE_VAR.sub(repl, template)
|
|
574
|
+
|
|
575
|
+
if unresolved:
|
|
576
|
+
return None
|
|
577
|
+
|
|
578
|
+
return os.path.normpath(resolved)
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
# ---------------------------------------------------------------------------
|
|
582
|
+
# Internal: platform + filesystem checks
|
|
583
|
+
# ---------------------------------------------------------------------------
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _current_platform() -> str:
|
|
587
|
+
"""Return the current platform identifier using the Node.js convention
|
|
588
|
+
(``darwin``, ``linux``, ``win32``, ...) so it matches the registry's
|
|
589
|
+
``platforms[]`` values.
|
|
590
|
+
"""
|
|
591
|
+
if sys.platform == "darwin":
|
|
592
|
+
return "darwin"
|
|
593
|
+
if sys.platform.startswith("linux"):
|
|
594
|
+
return "linux"
|
|
595
|
+
if sys.platform == "win32":
|
|
596
|
+
return "win32"
|
|
597
|
+
if sys.platform.startswith("freebsd"):
|
|
598
|
+
return "freebsd"
|
|
599
|
+
return sys.platform
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _platform_matches(platforms: tuple[str, ...]) -> bool:
|
|
603
|
+
if not platforms:
|
|
604
|
+
return True
|
|
605
|
+
plat = _current_platform()
|
|
606
|
+
return plat in platforms
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _path_type_matches(kind: str, candidate_path: str) -> bool:
|
|
610
|
+
p = Path(candidate_path)
|
|
611
|
+
try:
|
|
612
|
+
if kind == "dir":
|
|
613
|
+
return p.is_dir()
|
|
614
|
+
return p.is_file()
|
|
615
|
+
except OSError:
|
|
616
|
+
return False
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _executable_file_matches(candidate_path: str) -> bool:
|
|
620
|
+
if not _path_type_matches("file", candidate_path):
|
|
621
|
+
return False
|
|
622
|
+
if sys.platform == "win32":
|
|
623
|
+
return True
|
|
624
|
+
# Non-Windows: check the executable bit (any of owner/group/other).
|
|
625
|
+
try:
|
|
626
|
+
st = os.stat(candidate_path)
|
|
627
|
+
except OSError:
|
|
628
|
+
return False
|
|
629
|
+
return bool(st.st_mode & 0o111)
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _resolve_paths(
|
|
633
|
+
harness: HarnessDefinition,
|
|
634
|
+
env: dict[str, str],
|
|
635
|
+
) -> list[ResolvedHarnessPath]:
|
|
636
|
+
out: list[ResolvedHarnessPath] = []
|
|
637
|
+
for entry in harness.paths:
|
|
638
|
+
if not _platform_matches(entry.platforms):
|
|
639
|
+
continue
|
|
640
|
+
resolved = _resolve_template(entry.template, env)
|
|
641
|
+
exists = bool(resolved) and _path_type_matches(entry.kind, resolved)
|
|
642
|
+
out.append(
|
|
643
|
+
ResolvedHarnessPath(
|
|
644
|
+
id=entry.id,
|
|
645
|
+
category=entry.category,
|
|
646
|
+
kind=entry.kind,
|
|
647
|
+
template=entry.template,
|
|
648
|
+
platforms=entry.platforms,
|
|
649
|
+
path=resolved,
|
|
650
|
+
exists=exists,
|
|
651
|
+
)
|
|
652
|
+
)
|
|
653
|
+
return out
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _find_executable(
|
|
657
|
+
executables: tuple[str, ...],
|
|
658
|
+
env: dict[str, str],
|
|
659
|
+
) -> Optional[str]:
|
|
660
|
+
if not executables:
|
|
661
|
+
return None
|
|
662
|
+
|
|
663
|
+
path_value = env.get("PATH", "")
|
|
664
|
+
delimiter = ";" if sys.platform == "win32" else os.pathsep
|
|
665
|
+
path_parts = [p for p in path_value.split(delimiter) if p]
|
|
666
|
+
|
|
667
|
+
if sys.platform == "win32":
|
|
668
|
+
pathext = env.get("PATHEXT", ".EXE;.CMD;.BAT;.COM")
|
|
669
|
+
exts = pathext.split(";")
|
|
670
|
+
else:
|
|
671
|
+
exts = [""]
|
|
672
|
+
|
|
673
|
+
for executable in executables:
|
|
674
|
+
for d in path_parts:
|
|
675
|
+
for ext in exts:
|
|
676
|
+
candidate = os.path.join(d, executable + ext)
|
|
677
|
+
if _executable_file_matches(candidate):
|
|
678
|
+
return os.path.normpath(candidate)
|
|
679
|
+
|
|
680
|
+
return None
|