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/machine.py ADDED
@@ -0,0 +1,254 @@
1
+ """What the machine remembers, and which company's key a repo uses.
2
+
3
+ The machine keeps, outside every repo, the companies it is connected to (their addresses) and
4
+ which company each remote was found to belong to, with the repo's GitHub id; each company's key
5
+ lives in the operating system's keychain (client.system), never in a file. Nothing is written
6
+ into a repo.
7
+
8
+ A repo's company is found by matching its remotes against each connected company's managed
9
+ list (client.repos), read from the service when a session starts; a repo found before is
10
+ found again from what the machine remembers. A repo that is not managed, or whose company is
11
+ not connected, has no company: the client says nothing there unless a person asked.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import subprocess
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+ from urllib.parse import urlsplit
22
+
23
+ from client import repos
24
+ from client.service import Service, ServiceError
25
+ from client.system import System
26
+
27
+ # Where the machine keeps what it remembers; the tests point it elsewhere.
28
+ HOME_VARIABLE = "GROUNDCONTROL_HOME"
29
+ # How long one call to the service may take, in seconds.
30
+ TIMEOUT = 30
31
+
32
+
33
+ def home() -> Path:
34
+ """The folder the machine keeps the client's state in: ~/.groundcontrol.
35
+
36
+ Company: assumes none.
37
+ """
38
+ return Path(os.environ.get(HOME_VARIABLE) or Path.home() / ".groundcontrol")
39
+
40
+
41
+ def _state_path() -> Path:
42
+ """The file of addresses and remotes; no key is ever in it."""
43
+ return home() / "machine.json"
44
+
45
+
46
+ def load() -> dict:
47
+ """What the machine remembers: {companies: [address], remotes: {remote: {...}}, door}.
48
+
49
+ Company: assumes none.
50
+ """
51
+ try:
52
+ found = json.loads(_state_path().read_text())
53
+ except (OSError, json.JSONDecodeError):
54
+ found = {}
55
+ return {
56
+ "companies": list(found.get("companies", [])),
57
+ "remotes": dict(found.get("remotes", {})),
58
+ "door": found.get("door", ""),
59
+ }
60
+
61
+
62
+ def save(state: dict) -> None:
63
+ """Keep what the machine remembers.
64
+
65
+ Company: assumes none.
66
+ """
67
+ path = _state_path()
68
+ path.parent.mkdir(parents=True, exist_ok=True)
69
+ path.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n")
70
+
71
+
72
+ def address_of(given: str) -> str:
73
+ """A company's address as the machine keeps it: an origin, https unless this machine.
74
+
75
+ Args:
76
+ given: The address as typed or carried by the install line, with or without a scheme.
77
+
78
+ Returns:
79
+ Such as https://acme.ground-control.io.
80
+
81
+ Raises:
82
+ ValueError: If it is no address.
83
+
84
+ Company: the one it names.
85
+ """
86
+ given = given.strip()
87
+ if "://" not in given:
88
+ given = f"https://{given}"
89
+ parts = urlsplit(given)
90
+ if not parts.hostname or parts.path.strip("/") or parts.query:
91
+ raise ValueError(f"{given}: give the company's address, such as https://acme.example.com")
92
+ return f"{parts.scheme}://{parts.netloc}".lower()
93
+
94
+
95
+ def door_of(address: str) -> str:
96
+ """The door beside a company's address, as the service has it: login.<its domain>.
97
+
98
+ Args:
99
+ address: The company's origin, such as https://acme.ground-control.io.
100
+
101
+ Returns:
102
+ Such as https://login.ground-control.io.
103
+
104
+ Company: assumes none.
105
+ """
106
+ parts = urlsplit(address)
107
+ domain = (parts.hostname or "").split(".", 1)[-1]
108
+ port = f":{parts.port}" if parts.port else ""
109
+ return f"{parts.scheme}://login.{domain}{port}"
110
+
111
+
112
+ def remember_company(address: str, door: str) -> None:
113
+ """Add a connected company, and the door the machine connects at, to what it remembers.
114
+
115
+ Company: the one at the address.
116
+ """
117
+ state = load()
118
+ if address not in state["companies"]:
119
+ state["companies"].append(address)
120
+ state["door"] = door
121
+ save(state)
122
+
123
+
124
+ def forget_company(address: str) -> None:
125
+ """Forget a company, and every remote found to be one of its repos.
126
+
127
+ Company: the one at the address.
128
+ """
129
+ state = load()
130
+ state["companies"] = [a for a in state["companies"] if a != address]
131
+ state["remotes"] = {r: f for r, f in state["remotes"].items() if f.get("address") != address}
132
+ save(state)
133
+
134
+
135
+ def remotes(root: Path) -> list[str]:
136
+ """A clone's remote addresses, origin first.
137
+
138
+ Company: assumes none.
139
+ """
140
+ try:
141
+ listed = subprocess.run(
142
+ ["git", "-C", str(root), "remote", "-v"], capture_output=True, text=True, timeout=10
143
+ ).stdout
144
+ except (OSError, subprocess.TimeoutExpired):
145
+ return []
146
+ found: dict[str, str] = {}
147
+ for line in listed.splitlines():
148
+ parts = line.split()
149
+ if len(parts) >= 2:
150
+ found.setdefault(parts[1], parts[0])
151
+ return sorted(found, key=lambda url: (found[url] != "origin", found[url]))
152
+
153
+
154
+ def service(address: str, system: System) -> Service | None:
155
+ """The service at a company's address, with this machine's key for it; None without one.
156
+
157
+ Company: the one at the address.
158
+ """
159
+ key = system.get_key(address)
160
+ return Service(address, key, TIMEOUT) if key else None
161
+
162
+
163
+ @dataclass(frozen=True)
164
+ class Found:
165
+ """A repo's company: where to call, with which key, and the repo as the service names it.
166
+
167
+ Attributes:
168
+ service: The company's service, with its key.
169
+ repo: The repo's full name, as owner/name.
170
+ github_id: GitHub's id for it, when the service knows it.
171
+ """
172
+
173
+ service: Service
174
+ repo: str
175
+ github_id: int | None
176
+
177
+
178
+ @dataclass(frozen=True)
179
+ class Missing:
180
+ """Why a repo has no company here: one line, for a person who asked.
181
+
182
+ Attributes:
183
+ why: The line.
184
+ """
185
+
186
+ why: str
187
+
188
+
189
+ NOT_CONNECTED = "this machine is connected to no company: run `groundcontrol init <its address>`"
190
+ NO_REMOTE = "this repo has no remote to find its company by"
191
+
192
+
193
+ def find(root: Path, system: System, refresh: bool = False) -> Found | Missing:
194
+ """The company of the repo at root, with its key, from the managed lists.
195
+
196
+ A remote the machine found before is found again without asking, unless `refresh`; then
197
+ every connected company's managed list is read (with nothing left running) and what the
198
+ machine remembers is set from them.
199
+
200
+ Args:
201
+ root: The repo.
202
+ system: The operating system's side, which holds the keys.
203
+ refresh: Whether to read the lists again even for a remote found before.
204
+
205
+ Returns:
206
+ Found, or Missing with the line that says what is missing.
207
+
208
+ Company: each connected one, until the repo's is found.
209
+ """
210
+ state = load()
211
+ if not state["companies"]:
212
+ return Missing(NOT_CONNECTED)
213
+ here = remotes(root)
214
+ if not here:
215
+ return Missing(NO_REMOTE)
216
+ if not refresh:
217
+ for remote in here:
218
+ known = state["remotes"].get(remote)
219
+ if known and known.get("address") in state["companies"]:
220
+ found = service(known["address"], system)
221
+ if found:
222
+ return Found(found, known["name"], known.get("github_id"))
223
+ said = []
224
+ known_ids = {r: f["github_id"] for r, f in state["remotes"].items() if f.get("github_id")}
225
+ for address in state["companies"]:
226
+ called = service(address, system)
227
+ if called is None:
228
+ said.append(
229
+ f"the key for {address} is not in the keychain: run `groundcontrol init {address}`"
230
+ )
231
+ continue
232
+ try:
233
+ listed = called.managed()
234
+ except ServiceError as e:
235
+ said.append(f"{address}: {e.detail}")
236
+ continue
237
+ matched = repos.match(here, listed, known_ids)
238
+ if matched:
239
+ remote, repo = matched
240
+ state["remotes"] = {r: f for r, f in state["remotes"].items() if r not in here}
241
+ state["remotes"][remote] = {
242
+ "address": address,
243
+ "name": repo["name"],
244
+ "github_id": repo.get("github_id"),
245
+ }
246
+ save(state)
247
+ return Found(called, repo["name"], repo.get("github_id"))
248
+ if any(r in state["remotes"] for r in here):
249
+ state["remotes"] = {r: f for r, f in state["remotes"].items() if r not in here}
250
+ save(state)
251
+ if said:
252
+ return Missing("; ".join(said))
253
+ companies = ", ".join(state["companies"])
254
+ return Missing(f"this repo is not managed by {companies}: ask a manager there to manage it")
@@ -0,0 +1,57 @@
1
+ """The probes the client runs on the developer's machine: generic, deciding nothing.
2
+
3
+ A rule on the service names the probes it needs (app/method/facts.py, PROBES); the client runs
4
+ each in the repo and returns what it found, a fact, as plain values with the fields of the
5
+ probe's facts. A fact is a path, a hash, a count, a line number, a commit or a status code,
6
+ never a file's contents or a command's output; a probe that cannot run says which of
7
+ errors.ERRORS stopped it.
8
+
9
+ The client is its own package: it imports nothing of the server.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Callable
15
+
16
+ from . import code, gh, git, health, tests
17
+
18
+ # Every probe the client runs: its name -> the function of (params, root) that runs it.
19
+ RUNS: dict[str, Callable[[dict, object], dict]] = {
20
+ "git.branch": git.branch,
21
+ "git.commit": git.commit,
22
+ "git.merge_base": git.merge_base,
23
+ "git.changed": git.changed,
24
+ "git.lines": git.lines,
25
+ "git.conflicts": git.conflicts,
26
+ "tests.collect": tests.collect_probe,
27
+ "tests.run": tests.run_probe,
28
+ "tests.coverage": tests.coverage_probe,
29
+ "tests.fingerprints": tests.fingerprints_probe,
30
+ "code.symbols": code.symbols_probe,
31
+ "code.callers": code.callers_probe,
32
+ "code.languages": code.languages_probe,
33
+ "gh.pr": gh.pr,
34
+ "gh.checks": gh.checks,
35
+ "health": health.health,
36
+ }
37
+
38
+
39
+ def run(name: str, params: dict, root) -> dict:
40
+ """Run one probe in the repo and return its fact as plain values.
41
+
42
+ Args:
43
+ name: The probe, one of RUNS.
44
+ params: Its parameters as plain values.
45
+ root: The repo it runs in.
46
+
47
+ Returns:
48
+ The fact: the fields of the probe's facts, as plain values.
49
+
50
+ Raises:
51
+ ValueError: If the client runs no probe of that name.
52
+
53
+ Company: assumes none.
54
+ """
55
+ if name not in RUNS:
56
+ raise ValueError(f"the client runs no probe `{name}`; it runs {', '.join(RUNS)}")
57
+ return RUNS[name](dict(params or {}), root)