ground-control-cli 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.
- client/__init__.py +1 -0
- client/claude.py +240 -0
- client/cli.py +314 -0
- client/connect.py +128 -0
- client/machine.py +254 -0
- client/probes/__init__.py +57 -0
- client/probes/code/__init__.py +435 -0
- client/probes/code/bash.py +163 -0
- client/probes/code/javascript.js +171 -0
- client/probes/code/javascript.py +193 -0
- client/probes/code/python.py +143 -0
- client/probes/errors.py +67 -0
- client/probes/gh.py +126 -0
- client/probes/git.py +280 -0
- client/probes/health.py +128 -0
- client/probes/settings.py +72 -0
- client/probes/tests.py +532 -0
- client/repos.py +68 -0
- client/service.py +398 -0
- client/system/__init__.py +73 -0
- client/system/macos.py +92 -0
- client/update.py +110 -0
- ground_control_cli-0.2.0.dist-info/METADATA +5 -0
- ground_control_cli-0.2.0.dist-info/RECORD +26 -0
- ground_control_cli-0.2.0.dist-info/WHEEL +4 -0
- ground_control_cli-0.2.0.dist-info/entry_points.txt +3 -0
client/probes/gh.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""The gh probes: a pull request, and its checks once none is pending or the wait ends.
|
|
2
|
+
|
|
3
|
+
Each runs the `gh` CLI in the repo and returns the fact as plain values, the fields of its facts
|
|
4
|
+
in app/method/facts.py. A gh that is missing or fails says only which error stopped it.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import subprocess
|
|
11
|
+
|
|
12
|
+
from . import errors
|
|
13
|
+
|
|
14
|
+
PR_FIELDS = "headRefOid,state,mergeable,mergeCommit"
|
|
15
|
+
CHECK_FIELDS = "name,bucket,startedAt,completedAt"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Failed(Exception):
|
|
19
|
+
"""A gh command failed; `error` is which of errors.ERRORS it stands for."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, error: str):
|
|
22
|
+
"""Keep the error."""
|
|
23
|
+
super().__init__(error)
|
|
24
|
+
self.error = error
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _gh(root, *args, timeout=None) -> subprocess.CompletedProcess:
|
|
28
|
+
"""Run gh in the repo; Failed with tool_missing when gh is not installed."""
|
|
29
|
+
try:
|
|
30
|
+
return subprocess.run(
|
|
31
|
+
["gh", *args], cwd=root, capture_output=True, text=True, timeout=timeout
|
|
32
|
+
)
|
|
33
|
+
except FileNotFoundError:
|
|
34
|
+
raise Failed(errors.TOOL_MISSING) from None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _json(out: subprocess.CompletedProcess):
|
|
38
|
+
"""Gh's JSON output; Failed with the error its failure stands for."""
|
|
39
|
+
if out.returncode != 0:
|
|
40
|
+
raise Failed(errors.failed(out.stderr))
|
|
41
|
+
try:
|
|
42
|
+
return json.loads(out.stdout or "null")
|
|
43
|
+
except json.JSONDecodeError:
|
|
44
|
+
raise Failed(errors.REFUSED) from None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def pr(params: dict, root) -> dict:
|
|
48
|
+
"""gh.pr: a pull request's head, state, mergeability and merge commit.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
params: {"url"}.
|
|
52
|
+
root: The repo, where gh runs.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
The PrFacts fields.
|
|
56
|
+
|
|
57
|
+
Company: assumes none.
|
|
58
|
+
"""
|
|
59
|
+
try:
|
|
60
|
+
found = _json(_gh(root, "pr", "view", params["url"], "--json", PR_FIELDS))
|
|
61
|
+
except Failed as e:
|
|
62
|
+
return {"error": e.error}
|
|
63
|
+
if not isinstance(found, dict):
|
|
64
|
+
return {"error": errors.REFUSED}
|
|
65
|
+
merged = found.get("mergeCommit")
|
|
66
|
+
return {
|
|
67
|
+
"head": found.get("headRefOid") or None,
|
|
68
|
+
"state": found.get("state") or None,
|
|
69
|
+
"mergeable": found.get("mergeable") or None,
|
|
70
|
+
"merge_commit": (merged.get("oid") if isinstance(merged, dict) else None) or None,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _listed(url: str, root, *flags) -> list:
|
|
75
|
+
"""The PR's checks as gh lists them; none when gh says there are none."""
|
|
76
|
+
out = _gh(root, "pr", "checks", url, *flags, "--json", CHECK_FIELDS)
|
|
77
|
+
if out.returncode != 0 and "no " in out.stderr and "checks reported" in out.stderr:
|
|
78
|
+
return []
|
|
79
|
+
found = _json(out)
|
|
80
|
+
if not isinstance(found, list):
|
|
81
|
+
raise Failed(errors.REFUSED)
|
|
82
|
+
return [c for c in found if isinstance(c, dict) and c.get("name")]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def checks(params: dict, root) -> dict:
|
|
86
|
+
"""gh.checks: the PR's checks, after waiting up to wait_seconds for none to be pending.
|
|
87
|
+
|
|
88
|
+
The wait is `gh pr checks --watch`, ended when the time is up; how it exits is not read,
|
|
89
|
+
since it exits non-zero when a check failed. Whether a check is required is read with
|
|
90
|
+
`--required`.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
params: {"url", "wait_seconds"}.
|
|
94
|
+
root: The repo, where gh runs.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
The ChecksFacts fields, the checks sorted by name; waited_out when checks were still
|
|
98
|
+
pending as the wait ended.
|
|
99
|
+
|
|
100
|
+
Company: assumes none.
|
|
101
|
+
"""
|
|
102
|
+
url, wait = params["url"], params.get("wait_seconds") or 0
|
|
103
|
+
waited_out = False
|
|
104
|
+
try:
|
|
105
|
+
if wait > 0:
|
|
106
|
+
try:
|
|
107
|
+
_gh(root, "pr", "checks", url, "--watch", timeout=wait)
|
|
108
|
+
except subprocess.TimeoutExpired:
|
|
109
|
+
waited_out = True
|
|
110
|
+
found = _listed(url, root)
|
|
111
|
+
required = {c["name"] for c in _listed(url, root, "--required")}
|
|
112
|
+
except Failed as e:
|
|
113
|
+
return {"waited_out": waited_out, "error": e.error}
|
|
114
|
+
return {
|
|
115
|
+
"checks": [
|
|
116
|
+
{
|
|
117
|
+
"name": c["name"],
|
|
118
|
+
"bucket": c.get("bucket") or "",
|
|
119
|
+
"required": c["name"] in required,
|
|
120
|
+
"started_at": c.get("startedAt") or None,
|
|
121
|
+
"completed_at": c.get("completedAt") or None,
|
|
122
|
+
}
|
|
123
|
+
for c in sorted(found, key=lambda c: c["name"])
|
|
124
|
+
],
|
|
125
|
+
"waited_out": waited_out,
|
|
126
|
+
}
|
client/probes/git.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""The git probes: a branch and its worktree, commits, what changed, and whether two commits merge.
|
|
2
|
+
|
|
3
|
+
Each runs git in the repo and returns the fact as plain values, the fields of its facts in
|
|
4
|
+
app/method/facts.py. A git that fails says only which error stopped it.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
import subprocess
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from . import errors, settings
|
|
15
|
+
|
|
16
|
+
CONFIG = ".claude/method.json"
|
|
17
|
+
# A hunk's header in `git diff -U0`: where its lines start on the new side, and how many.
|
|
18
|
+
HUNK_RE = re.compile(r"^@@ -\S+ \+(\d+)(?:,(\d+))? @@")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Failed(Exception):
|
|
22
|
+
"""A git command failed; `error` is which of errors.ERRORS it stands for."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, error: str):
|
|
25
|
+
"""Keep the error."""
|
|
26
|
+
super().__init__(error)
|
|
27
|
+
self.error = error
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _git(root, *args) -> subprocess.CompletedProcess:
|
|
31
|
+
"""Run git in the repo; Failed with tool_missing when git is not installed."""
|
|
32
|
+
try:
|
|
33
|
+
return subprocess.run(["git", *args], cwd=root, capture_output=True, text=True)
|
|
34
|
+
except FileNotFoundError:
|
|
35
|
+
raise Failed(errors.TOOL_MISSING) from None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _out(root, *args) -> str:
|
|
39
|
+
"""Git's output; Failed with the error its failure stands for."""
|
|
40
|
+
out = _git(root, *args)
|
|
41
|
+
if out.returncode != 0:
|
|
42
|
+
raise Failed(errors.failed(out.stderr))
|
|
43
|
+
return out.stdout
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def main_checkout(root) -> Path:
|
|
47
|
+
"""The repo's main checkout: the folder of git's common .git, wherever root is."""
|
|
48
|
+
root = Path(root).resolve()
|
|
49
|
+
out = _git(root, "rev-parse", "--git-common-dir")
|
|
50
|
+
common = out.stdout.strip() if out.returncode == 0 else ""
|
|
51
|
+
if not common:
|
|
52
|
+
return root
|
|
53
|
+
common = (root / common).resolve()
|
|
54
|
+
return common.parent if common.name == ".git" else root
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def logs_dir(root) -> Path:
|
|
58
|
+
"""The method's log folder: METHOD_LOGS; else `logs` in the repo's config; else _logs beside.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
root: A folder inside the repo, the main checkout or a linked worktree.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
The folder, resolved.
|
|
65
|
+
|
|
66
|
+
Company: assumes none.
|
|
67
|
+
"""
|
|
68
|
+
found = settings.logs()
|
|
69
|
+
if found:
|
|
70
|
+
return found
|
|
71
|
+
main = main_checkout(root)
|
|
72
|
+
try:
|
|
73
|
+
config = json.loads((main / CONFIG).read_text(encoding="utf-8"))
|
|
74
|
+
except (OSError, json.JSONDecodeError):
|
|
75
|
+
config = {}
|
|
76
|
+
logs = config.get("logs") if isinstance(config, dict) else None
|
|
77
|
+
if logs:
|
|
78
|
+
return (main / Path(logs).expanduser()).resolve()
|
|
79
|
+
return (main.parent / "_logs").resolve()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _inside(path: Path, folder: Path) -> bool:
|
|
83
|
+
return path == folder or folder in path.parents
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def worktree(root, branch: str) -> Path | None:
|
|
87
|
+
"""The folder where the branch is checked out, or None."""
|
|
88
|
+
folder = None
|
|
89
|
+
for line in _out(root, "worktree", "list", "--porcelain").splitlines():
|
|
90
|
+
if line.startswith("worktree "):
|
|
91
|
+
folder = Path(line[len("worktree ") :])
|
|
92
|
+
elif line == f"branch refs/heads/{branch}" and folder is not None:
|
|
93
|
+
return folder.resolve()
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def branch(params: dict, root) -> dict:
|
|
98
|
+
"""git.branch: whether the branch exists, its worktree, and its paths there.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
params: {"name", "paths"}.
|
|
102
|
+
root: The repo.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
The BranchFacts fields.
|
|
106
|
+
|
|
107
|
+
Company: assumes none.
|
|
108
|
+
"""
|
|
109
|
+
name, paths = params["name"], list(params.get("paths") or [])
|
|
110
|
+
try:
|
|
111
|
+
if _git(root, "rev-parse", "--verify", "-q", f"refs/heads/{name}").returncode != 0:
|
|
112
|
+
return {"exists": False}
|
|
113
|
+
tree = worktree(root, name)
|
|
114
|
+
if tree is None:
|
|
115
|
+
return {"exists": True}
|
|
116
|
+
found = []
|
|
117
|
+
for path in paths:
|
|
118
|
+
present = (tree / path).is_file()
|
|
119
|
+
last = (
|
|
120
|
+
_out(tree, "log", "-1", "--format=%H", name, "--", path).strip() if present else ""
|
|
121
|
+
)
|
|
122
|
+
found.append({"path": path, "present": present, "last_commit": last or None})
|
|
123
|
+
return {
|
|
124
|
+
"exists": True,
|
|
125
|
+
"worktree": str(tree),
|
|
126
|
+
"main_checkout": tree == main_checkout(root),
|
|
127
|
+
"paths": found,
|
|
128
|
+
}
|
|
129
|
+
except Failed as e:
|
|
130
|
+
return {"exists": False, "error": e.error}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _sha(root, *args) -> dict:
|
|
134
|
+
"""The sha git prints, or None when it names none."""
|
|
135
|
+
try:
|
|
136
|
+
out = _git(root, *args)
|
|
137
|
+
except Failed as e:
|
|
138
|
+
return {"sha": None, "error": e.error}
|
|
139
|
+
sha = out.stdout.strip() if out.returncode == 0 else ""
|
|
140
|
+
return {"sha": sha or None}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def commit(params: dict, root) -> dict:
|
|
144
|
+
"""git.commit: the full sha a ref names, or None.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
params: {"ref"}.
|
|
148
|
+
root: The repo.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
The CommitFacts fields.
|
|
152
|
+
|
|
153
|
+
Company: assumes none.
|
|
154
|
+
"""
|
|
155
|
+
return _sha(root, "rev-parse", "--verify", "-q", f"{params.get('ref') or 'HEAD'}^{{commit}}")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def merge_base(params: dict, root) -> dict:
|
|
159
|
+
"""git.merge_base: the best common ancestor of two commits, or None.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
params: {"a", "b"}.
|
|
163
|
+
root: The repo.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
The CommitFacts fields.
|
|
167
|
+
|
|
168
|
+
Company: assumes none.
|
|
169
|
+
"""
|
|
170
|
+
try:
|
|
171
|
+
out = _git(root, "merge-base", params.get("a") or "main", params.get("b") or "HEAD")
|
|
172
|
+
except Failed as e:
|
|
173
|
+
return {"sha": None, "error": e.error}
|
|
174
|
+
if out.returncode == 0:
|
|
175
|
+
return {"sha": out.stdout.strip() or None}
|
|
176
|
+
if out.returncode == 1 and not out.stderr.strip():
|
|
177
|
+
return {"sha": None}
|
|
178
|
+
return {"sha": None, "error": errors.failed(out.stderr)}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _untracked(root) -> list:
|
|
182
|
+
return [n for n in _out(root, "ls-files", "--others", "--exclude-standard").splitlines() if n]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def changed(params: dict, root) -> dict:
|
|
186
|
+
"""git.changed: the files that differ from a commit, and the untracked ones git keeps.
|
|
187
|
+
|
|
188
|
+
The method's own log folder is left out, wherever it is.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
params: {"since"}.
|
|
192
|
+
root: The repo.
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
The ChangedFacts fields.
|
|
196
|
+
|
|
197
|
+
Company: assumes none.
|
|
198
|
+
"""
|
|
199
|
+
try:
|
|
200
|
+
tracked = _out(root, "diff", "--name-only", params["since"], "--").splitlines()
|
|
201
|
+
untracked = _untracked(root)
|
|
202
|
+
except Failed as e:
|
|
203
|
+
return {"error": e.error}
|
|
204
|
+
logs = logs_dir(root)
|
|
205
|
+
keep = [n for n in dict.fromkeys(tracked + untracked) if n]
|
|
206
|
+
keep = [n for n in keep if not _inside((Path(root) / n).resolve(), logs)]
|
|
207
|
+
new = set(untracked)
|
|
208
|
+
return {
|
|
209
|
+
"changed": sorted(n for n in keep if n not in new),
|
|
210
|
+
"untracked": sorted(n for n in keep if n in new),
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def lines(params: dict, root) -> dict:
|
|
215
|
+
"""git.lines: the changed lines of each file against a commit, untracked files whole.
|
|
216
|
+
|
|
217
|
+
The lines are the working tree's. A deletion marks the lines on both sides of it.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
params: {"since"}.
|
|
221
|
+
root: The repo.
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
The LinesFacts fields.
|
|
225
|
+
|
|
226
|
+
Company: assumes none.
|
|
227
|
+
"""
|
|
228
|
+
found, current = {}, None
|
|
229
|
+
try:
|
|
230
|
+
diff = _out(root, "diff", "-U0", "--no-color", "--no-renames", params["since"], "--")
|
|
231
|
+
untracked = _untracked(root)
|
|
232
|
+
except Failed as e:
|
|
233
|
+
return {"error": e.error}
|
|
234
|
+
for line in diff.splitlines():
|
|
235
|
+
if line.startswith("+++ "):
|
|
236
|
+
current = line[6:] if line.startswith("+++ b/") else None
|
|
237
|
+
elif current and (hunk := HUNK_RE.match(line)):
|
|
238
|
+
start, count = int(hunk.group(1)), int(hunk.group(2) or 1)
|
|
239
|
+
marked = range(start, start + count) if count else (start, start + 1)
|
|
240
|
+
found.setdefault(current, set()).update(marked)
|
|
241
|
+
for name in untracked:
|
|
242
|
+
target = Path(root) / name
|
|
243
|
+
if target.is_file():
|
|
244
|
+
total = len(target.read_text(encoding="utf-8", errors="replace").splitlines())
|
|
245
|
+
found[name] = set(range(1, total + 1))
|
|
246
|
+
return {
|
|
247
|
+
"files": [
|
|
248
|
+
{"path": name, "lines": sorted(found[name])} for name in sorted(found) if found[name]
|
|
249
|
+
]
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def conflicts(params: dict, root) -> dict:
|
|
254
|
+
"""git.conflicts: whether two commits merge, tried in git's object store only.
|
|
255
|
+
|
|
256
|
+
`git merge-tree --write-tree` writes objects only: no working tree, branch or index is
|
|
257
|
+
touched. A conflict prints the merged tree, then the conflicting paths; a commit that is not
|
|
258
|
+
there prints nothing.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
params: {"a", "b"}.
|
|
262
|
+
root: The repo.
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
The ConflictFacts fields.
|
|
266
|
+
|
|
267
|
+
Company: assumes none.
|
|
268
|
+
"""
|
|
269
|
+
a, b = params.get("a") or "main", params.get("b") or "HEAD"
|
|
270
|
+
try:
|
|
271
|
+
out = _git(root, "merge-tree", "--write-tree", "--name-only", "--no-messages", a, b)
|
|
272
|
+
except Failed as e:
|
|
273
|
+
return {"error": e.error}
|
|
274
|
+
if out.returncode == 0:
|
|
275
|
+
return {"conflicts": False}
|
|
276
|
+
if out.returncode == 1 and out.stdout.strip():
|
|
277
|
+
return {"conflicts": True, "paths": sorted(set(out.stdout.splitlines()[1:]) - {""})}
|
|
278
|
+
if "not something we can merge" in out.stderr:
|
|
279
|
+
return {"error": errors.NOT_FOUND}
|
|
280
|
+
return {"error": errors.failed(out.stderr)}
|
client/probes/health.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""The health probe: what the repo's running environment answers, after bringing it up or down.
|
|
2
|
+
|
|
3
|
+
`environment` in the repo's .claude/method.json is `{"up", "down", "health", "timeout"}`: the
|
|
4
|
+
command that brings the branch up, the one that takes it down, a local URL that says the
|
|
5
|
+
environment is healthy, and how many seconds to wait (settings.ENVIRONMENT_TIMEOUT when not
|
|
6
|
+
given). The service names only what to do; the commands and the address are the repo's own.
|
|
7
|
+
Commands run through the shell in the repo root, in their own session, and are not waited on:
|
|
8
|
+
`up` may keep running as the environment itself.
|
|
9
|
+
|
|
10
|
+
Health answers when it gives any HTTP reply; it does not answer when there is no reply at all.
|
|
11
|
+
It is healthy when the reply is 2xx.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import subprocess
|
|
18
|
+
import time
|
|
19
|
+
import urllib.error
|
|
20
|
+
import urllib.request
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from . import errors, settings
|
|
24
|
+
|
|
25
|
+
CONFIG = ".claude/method.json"
|
|
26
|
+
KEY = "environment"
|
|
27
|
+
# The keys each way of bringing needs.
|
|
28
|
+
NEEDS = {"none": ("health",), "up": ("up", "health"), "down": ("down", "health")}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def status(health: str):
|
|
32
|
+
"""The HTTP status health replies with, or None when nothing replies."""
|
|
33
|
+
try:
|
|
34
|
+
with urllib.request.urlopen(health, timeout=settings.ENVIRONMENT_PROBE) as reply:
|
|
35
|
+
return reply.status
|
|
36
|
+
except urllib.error.HTTPError as e:
|
|
37
|
+
return e.code
|
|
38
|
+
except (urllib.error.URLError, OSError, ValueError):
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def healthy(code) -> bool:
|
|
43
|
+
"""Whether a status is 2xx; no reply is not healthy."""
|
|
44
|
+
return code is not None and 200 <= code < 300
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _config(root, keys) -> tuple:
|
|
48
|
+
"""`environment` from the repo's config, and the error that stops reading it, or None."""
|
|
49
|
+
try:
|
|
50
|
+
path = Path(root) / CONFIG
|
|
51
|
+
found = json.loads(path.read_text(encoding="utf-8")) if path.is_file() else {}
|
|
52
|
+
except (OSError, json.JSONDecodeError):
|
|
53
|
+
return {}, errors.UNREADABLE
|
|
54
|
+
env = found.get(KEY) if isinstance(found, dict) else None
|
|
55
|
+
if not isinstance(env, dict):
|
|
56
|
+
return {}, errors.NO_COMMAND
|
|
57
|
+
if not all(isinstance(env.get(k), str) and env[k].strip() for k in keys):
|
|
58
|
+
return env, errors.NO_COMMAND
|
|
59
|
+
timeout = env.get("timeout", settings.ENVIRONMENT_TIMEOUT)
|
|
60
|
+
if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or timeout <= 0:
|
|
61
|
+
return env, errors.UNREADABLE
|
|
62
|
+
return env, None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _start(command: str, root) -> subprocess.Popen:
|
|
66
|
+
"""Start a command through the shell in its own session, without waiting on it."""
|
|
67
|
+
return subprocess.Popen(
|
|
68
|
+
command,
|
|
69
|
+
shell=True,
|
|
70
|
+
cwd=root,
|
|
71
|
+
stdin=subprocess.DEVNULL,
|
|
72
|
+
stdout=subprocess.DEVNULL,
|
|
73
|
+
stderr=subprocess.DEVNULL,
|
|
74
|
+
start_new_session=True,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _wait(until, seconds: float, started) -> str | None:
|
|
79
|
+
"""Ask until `until()` holds; the error that ended the wait, or None when it held."""
|
|
80
|
+
end = time.monotonic() + seconds
|
|
81
|
+
while True:
|
|
82
|
+
if until():
|
|
83
|
+
return None
|
|
84
|
+
if started is not None and started.poll():
|
|
85
|
+
return errors.COMMAND_FAILED
|
|
86
|
+
if time.monotonic() >= end:
|
|
87
|
+
return errors.TIMED_OUT
|
|
88
|
+
time.sleep(settings.ENVIRONMENT_POLL)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def health(params: dict, root) -> dict:
|
|
92
|
+
"""health: what the health address answers, bringing the environment up or down first.
|
|
93
|
+
|
|
94
|
+
bring=up runs `up` unless something already answers, then waits for 2xx; bring=down runs
|
|
95
|
+
`down`, then waits until nothing answers; bring=none only asks.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
params: {"bring"}: none, up or down.
|
|
99
|
+
root: The repo, whose config names the commands and the address.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
The HealthFacts fields.
|
|
103
|
+
|
|
104
|
+
Company: assumes none.
|
|
105
|
+
"""
|
|
106
|
+
bring = params.get("bring") or "none"
|
|
107
|
+
env, error = _config(root, NEEDS[bring])
|
|
108
|
+
if error:
|
|
109
|
+
return {"error": error}
|
|
110
|
+
address = env["health"]
|
|
111
|
+
before = status(address)
|
|
112
|
+
if bring == "none":
|
|
113
|
+
return {"before": before, "after": before}
|
|
114
|
+
timeout = env.get("timeout", settings.ENVIRONMENT_TIMEOUT)
|
|
115
|
+
|
|
116
|
+
def until() -> bool:
|
|
117
|
+
code = status(address)
|
|
118
|
+
return healthy(code) if bring == "up" else code is None
|
|
119
|
+
|
|
120
|
+
if bring == "up":
|
|
121
|
+
started = _start(env["up"], root) if before is None else None
|
|
122
|
+
else:
|
|
123
|
+
started = _start(env["down"], root)
|
|
124
|
+
start = time.monotonic()
|
|
125
|
+
error = _wait(until, timeout, started)
|
|
126
|
+
waited = timeout if error == errors.TIMED_OUT else round(time.monotonic() - start, 2)
|
|
127
|
+
found = {"before": before, "after": status(address), "seconds": waited}
|
|
128
|
+
return dict(found, error=error) if error else found
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""The client's own technical numbers, each read once from the environment, with its default.
|
|
2
|
+
|
|
3
|
+
The same variables the server's settings name for the method (app/config.py, .env.example), read
|
|
4
|
+
here because the client imports nothing of the server. A value is a number of seconds or an ISO
|
|
5
|
+
8601 duration, such as PT60S, as .env.example writes them.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
DURATION_RE = re.compile(
|
|
15
|
+
r"^P(?:(?P<days>\d+(?:\.\d+)?)D)?"
|
|
16
|
+
r"(?:T(?:(?P<hours>\d+(?:\.\d+)?)H)?(?:(?P<minutes>\d+(?:\.\d+)?)M)?"
|
|
17
|
+
r"(?:(?P<seconds>\d+(?:\.\d+)?)S)?)?$"
|
|
18
|
+
)
|
|
19
|
+
SECONDS_IN = {"days": 86400, "hours": 3600, "minutes": 60, "seconds": 1}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def seconds(name: str, default: float) -> float:
|
|
23
|
+
"""A duration from the environment, in seconds.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
name: The variable, such as METHOD_ENVIRONMENT_TIMEOUT.
|
|
27
|
+
default: The seconds when it is unset, empty or not a duration.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
The seconds.
|
|
31
|
+
|
|
32
|
+
Company: assumes none.
|
|
33
|
+
"""
|
|
34
|
+
value = os.environ.get(name, "").strip()
|
|
35
|
+
if not value:
|
|
36
|
+
return default
|
|
37
|
+
try:
|
|
38
|
+
return float(value)
|
|
39
|
+
except ValueError:
|
|
40
|
+
pass
|
|
41
|
+
found = DURATION_RE.match(value.upper())
|
|
42
|
+
if not found or value.upper() in ("P", "PT"):
|
|
43
|
+
return default
|
|
44
|
+
return sum(float(v) * SECONDS_IN[k] for k, v in found.groupdict().items() if v)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def method_data() -> Path:
|
|
48
|
+
"""METHOD_DATA, the method's own folder on this machine, where the readers' tools are.
|
|
49
|
+
|
|
50
|
+
Read each time it is asked for, so a change to the environment counts.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
The folder, resolved; ~/.method when METHOD_DATA is unset, as app/config.py has it.
|
|
54
|
+
|
|
55
|
+
Company: assumes none.
|
|
56
|
+
"""
|
|
57
|
+
value = os.environ.get("METHOD_DATA", "").strip() or "~/.method"
|
|
58
|
+
return Path(value).expanduser().resolve()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def logs() -> Path | None:
|
|
62
|
+
"""METHOD_LOGS, the folder that holds the method's log, or None when unset."""
|
|
63
|
+
value = os.environ.get("METHOD_LOGS", "").strip()
|
|
64
|
+
return Path(value).expanduser().resolve() if value else None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# How long to wait for the environment to come up or down, unless the repo says otherwise.
|
|
68
|
+
ENVIRONMENT_TIMEOUT = seconds("METHOD_ENVIRONMENT_TIMEOUT", 60)
|
|
69
|
+
# How often to ask whether the environment is up.
|
|
70
|
+
ENVIRONMENT_POLL = seconds("METHOD_ENVIRONMENT_POLL", 0.25)
|
|
71
|
+
# The longest one health question may take.
|
|
72
|
+
ENVIRONMENT_PROBE = seconds("METHOD_ENVIRONMENT_PROBE", 2)
|