leancpms-cli 0.1.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.
cpms/__init__.py ADDED
File without changes
cpms/cli/__init__.py ADDED
File without changes
cpms/cli/client.py ADDED
@@ -0,0 +1,126 @@
1
+ """Configuration and the HTTP client of the `cpms` command. No framework.
2
+
3
+ The CLI is a thin client of the JSON API (architecture §3), so it works the
4
+ same against a local and a hosted instance. Credentials come from
5
+ `CPMS_URL` and `CPMS_API_KEY`, or from a profile in
6
+ `~/.config/cpms/config.toml`:
7
+
8
+ [default]
9
+ url = "http://localhost:8000"
10
+ api_key = "lcp_…"
11
+
12
+ [hosted]
13
+ url = "https://cpms.example.com"
14
+ api_key = "lcp_…"
15
+
16
+ `--profile NAME` (or `CPMS_PROFILE`) picks a profile; without one the
17
+ environment wins, then `[default]`. `cpms login` writes a profile
18
+ (`cpms.cli.login`).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ import tomllib
25
+ from dataclasses import dataclass
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ import httpx
30
+
31
+
32
+ class UsageError(Exception):
33
+ """Exit code 2: the command line or the configuration is wrong."""
34
+
35
+
36
+ class ApiError(Exception):
37
+ """Exit code 1: the server (or the network) said no."""
38
+
39
+ def __init__(self, message: str, status: int | None = None, detail: Any = None):
40
+ super().__init__(message)
41
+ self.status = status
42
+ self.detail = detail
43
+
44
+
45
+ def config_path() -> Path:
46
+ if os.environ.get("CPMS_CONFIG"):
47
+ return Path(os.environ["CPMS_CONFIG"])
48
+ base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
49
+ return Path(base) / "cpms" / "config.toml"
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class Config:
54
+ url: str
55
+ api_key: str
56
+
57
+
58
+ def load_config(profile: str | None) -> Config:
59
+ profile = profile or os.environ.get("CPMS_PROFILE") or None
60
+ url, key = os.environ.get("CPMS_URL", ""), os.environ.get("CPMS_API_KEY", "")
61
+ if profile or not (url and key):
62
+ path = config_path()
63
+ section: dict[str, Any] = {}
64
+ if path.exists():
65
+ try:
66
+ data = tomllib.loads(path.read_text())
67
+ except tomllib.TOMLDecodeError as e:
68
+ raise UsageError(f"{path}: {e}") from None
69
+ section = data.get(profile or "default") or {}
70
+ if profile and not section:
71
+ raise UsageError(f"no profile [{profile}] in {path}")
72
+ elif profile:
73
+ raise UsageError(f"no profile [{profile}]: {path} does not exist")
74
+ if profile:
75
+ url, key = str(section.get("url", "")), str(section.get("api_key", ""))
76
+ else:
77
+ url, key = url or str(section.get("url", "")), key or str(section.get("api_key", ""))
78
+ if not url or not key:
79
+ raise UsageError(f"set CPMS_URL and CPMS_API_KEY, or a profile in {config_path()}")
80
+ return Config(url.rstrip("/"), key)
81
+
82
+
83
+ class Api:
84
+ """The JSON API. Without a key (`cpms login` starting) only the routes that need none answer.
85
+ `transport` is for tests."""
86
+
87
+ def __init__(self, cfg: Config, timeout: float = 30.0, transport: httpx.BaseTransport | None = None):
88
+ self.config = cfg
89
+ headers = {"Authorization": f"Bearer {cfg.api_key}"} if cfg.api_key else {}
90
+ self._http = httpx.Client(base_url=f"{cfg.url}/api/v1", timeout=timeout, headers=headers, transport=transport)
91
+
92
+ def close(self) -> None:
93
+ self._http.close()
94
+
95
+ def call(self, method: str, path: str, json: Any = None, params: dict[str, Any] | None = None) -> Any:
96
+ clean = {k: v for k, v in (params or {}).items() if v is not None}
97
+ try:
98
+ r = self._http.request(method, path, json=json, params=clean)
99
+ except httpx.HTTPError as e:
100
+ raise ApiError(f"{method} {path}: {e}") from None
101
+ try:
102
+ body = r.json() if r.content else None
103
+ except ValueError:
104
+ body = None
105
+ if r.status_code >= 400:
106
+ msg = body.get("error") or body.get("detail") if isinstance(body, dict) else None
107
+ raise ApiError(f"{r.status_code} {msg or r.text[:200]}", r.status_code,
108
+ body.get("detail") if isinstance(body, dict) else None)
109
+ return body
110
+
111
+ def answer(self, method: str, path: str, json: Any = None) -> tuple[int, Any]:
112
+ """The status and the body, whatever the status (`cpms login` polling); only the network raises."""
113
+ try:
114
+ r = self._http.request(method, path, json=json)
115
+ except httpx.HTTPError as e:
116
+ raise ApiError(f"{method} {path}: {e}") from None
117
+ try:
118
+ return r.status_code, r.json() if r.content else None
119
+ except ValueError:
120
+ return r.status_code, None
121
+
122
+ def get(self, path: str, **params: Any) -> Any:
123
+ return self.call("GET", path, params=params)
124
+
125
+ def post(self, path: str, body: Any = None) -> Any:
126
+ return self.call("POST", path, json=body if body is not None else {})
cpms/cli/login.py ADDED
@@ -0,0 +1,232 @@
1
+ """`cpms login`: the device flow from the command line's side, and the profile it writes.
2
+
3
+ The server's side is `cpms.actions.login`. Here: start a login, poll until
4
+ the person has approved it in the portal (RFC 8628: every `interval`
5
+ seconds, five more after each `slow_down`), and save the key as a profile
6
+ in the config file (`client.config_path`).
7
+
8
+ The config file is edited as text, not rewritten from parsed data, so the
9
+ other profiles, their comments and their order stay as they were: in the
10
+ profile's own table, the `url` and `api_key` lines are replaced where they
11
+ stand and missing ones added after its last line; a new profile goes at the
12
+ end. The result is parsed back and checked before it is written, and it is
13
+ written whole (a temporary file, then a rename), mode 0600; a directory it
14
+ creates is 0700. No TOML writer is needed, so the CLI keeps its three
15
+ dependencies.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import os
22
+ import re
23
+ import socket
24
+ import sys
25
+ import tempfile
26
+ import time
27
+ import tomllib
28
+ import webbrowser
29
+ from pathlib import Path
30
+ from typing import Any, Callable
31
+
32
+ import httpx
33
+
34
+ from .client import Api, ApiError, Config, UsageError
35
+
36
+ PROFILE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
37
+ HEADER = re.compile(r"^\s*\[")
38
+ TABLE = re.compile(r"""^\s*\[\s*(?:"([^"]*)"|'([^']*)'|([A-Za-z0-9_.-]+))\s*\]\s*(?:#.*)?$""")
39
+ FIELD = re.compile(r"^\s*(url|api_key)\s*=")
40
+ CONTENT = re.compile(r"^\s*[^\s#]")
41
+ USER_CODE = re.compile(r"^[BCDFGHJKLMNPQRSTVWXZ]{4}-[BCDFGHJKLMNPQRSTVWXZ]{4}$")
42
+ PORTAL_PAGE = "/portal/cli/"
43
+ MAX_INTERVAL_S = 60
44
+
45
+ sleep: Callable[[float], None] = time.sleep # tests replace these two
46
+ clock: Callable[[], float] = time.monotonic
47
+ TRANSPORT: httpx.BaseTransport | None = None # tests: a fake server
48
+
49
+
50
+ # --- the config file -------------------------------------------------------------------------
51
+
52
+
53
+ def profile_name(name: str) -> str:
54
+ if not PROFILE.match(name):
55
+ raise UsageError(f"profile names are letters, digits, - and _: {name!r}")
56
+ return name
57
+
58
+
59
+ def _lines(text: str) -> list[str]:
60
+ lines = text.splitlines(keepends=True)
61
+ if lines and not lines[-1].endswith("\n"):
62
+ lines[-1] += "\n"
63
+ return lines
64
+
65
+
66
+ def _table(lines: list[str], name: str) -> tuple[int, int] | None:
67
+ """Where the table [name] is: its header's index and the index after its last line of content."""
68
+ for i, line in enumerate(lines):
69
+ m = TABLE.match(line)
70
+ if m and next(g for g in m.groups() if g is not None) == name:
71
+ end = next((j for j in range(i + 1, len(lines)) if HEADER.match(lines[j])), len(lines))
72
+ while end > i + 1 and not CONTENT.match(lines[end - 1]):
73
+ end -= 1 # blank lines and comments before the next table belong to it
74
+ return i, end
75
+ return None
76
+
77
+
78
+ def _parse(path: Path, text: str) -> dict[str, Any]:
79
+ try:
80
+ return tomllib.loads(text)
81
+ except tomllib.TOMLDecodeError as e:
82
+ raise UsageError(f"{path}: {e}") from None
83
+
84
+
85
+ def set_profile(path: Path, text: str, name: str, url: str, api_key: str) -> str:
86
+ """`text` with [name] holding `url` and `api_key`; everything else as it was."""
87
+ before = _parse(path, text)
88
+ lines = _lines(text)
89
+ values = {"url": f"url = {json.dumps(url)}\n", "api_key": f"api_key = {json.dumps(api_key)}\n"}
90
+ where = _table(lines, name)
91
+ if where is None:
92
+ lines += (["\n"] if lines and lines[-1].strip() else []) + [f"[{name}]\n", *values.values()]
93
+ else:
94
+ start, end = where
95
+ for i in range(start + 1, end):
96
+ m = FIELD.match(lines[i])
97
+ if m and m.group(1) in values:
98
+ lines[i] = values.pop(m.group(1))
99
+ lines[end:end] = list(values.values())
100
+ out = "".join(lines)
101
+ after = _parse(path, out)
102
+ if after.get(name, {}).get("url") != url or after[name].get("api_key") != api_key or \
103
+ {k: v for k, v in after.items() if k != name} != {k: v for k, v in before.items() if k != name}:
104
+ raise UsageError(f"{path}: could not update [{name}] safely; edit the file by hand")
105
+ return out
106
+
107
+
108
+ def drop_profile(path: Path, text: str, name: str) -> str | None:
109
+ """`text` without [name], or None if it has no such table."""
110
+ before = _parse(path, text)
111
+ lines = _lines(text)
112
+ where = _table(lines, name)
113
+ if where is None:
114
+ return None
115
+ start, end = where
116
+ while end < len(lines) and not lines[end].strip():
117
+ end += 1
118
+ del lines[start:end]
119
+ out = "".join(lines)
120
+ after = _parse(path, out)
121
+ if name in after or after != {k: v for k, v in before.items() if k != name}:
122
+ raise UsageError(f"{path}: could not remove [{name}] safely; edit the file by hand")
123
+ return out
124
+
125
+
126
+ def write(path: Path, text: str) -> None:
127
+ """Replace the file whole, readable by its owner only. The temporary file is new (mkstemp: a fresh
128
+ name, created exclusively, mode 0600), so nothing planted beside the config is written through."""
129
+ if not path.parent.exists():
130
+ path.parent.mkdir(mode=0o700, parents=True)
131
+ fd, name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
132
+ tmp = Path(name)
133
+ try:
134
+ with os.fdopen(fd, "w") as f:
135
+ f.write(text)
136
+ f.flush()
137
+ os.fsync(f.fileno())
138
+ os.replace(tmp, path)
139
+ except BaseException:
140
+ tmp.unlink(missing_ok=True)
141
+ raise
142
+
143
+
144
+ def read(path: Path) -> str:
145
+ try:
146
+ return path.read_text() if path.exists() else ""
147
+ except OSError as e:
148
+ raise UsageError(f"{path}: {e}") from None
149
+
150
+
151
+ def profile_value(path: Path, text: str, name: str, key: str) -> str:
152
+ section = _parse(path, text).get(name)
153
+ return str(section.get(key, "")) if isinstance(section, dict) else ""
154
+
155
+
156
+ # --- the flow ------------------------------------------------------------------------------
157
+
158
+
159
+ def client_name() -> str:
160
+ """What the key is called on the server: the command and the machine, which the person sees when
161
+ approving."""
162
+ return f"cpms on {socket.gethostname() or 'unknown host'}"[:200]
163
+
164
+
165
+ def can_open_browser() -> bool:
166
+ if sys.platform in ("darwin", "win32"):
167
+ return True
168
+ return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
169
+
170
+
171
+ def open_browser(url: str) -> bool:
172
+ try:
173
+ return webbrowser.open(url)
174
+ except Exception: # a browser that will not start is no reason to stop; the URL is printed
175
+ return False
176
+
177
+
178
+ def verification_url(url: str, user_code: str) -> str:
179
+ """The page to open, made here from the instance the person named and the code: never an address the
180
+ server sent, which a server that is not what it seems could point anywhere."""
181
+ if not USER_CODE.match(user_code):
182
+ raise ApiError(f"the server sent an odd code ({user_code[:20]!r}); not a LeanCPMS instance?")
183
+ return f"{url}{PORTAL_PAGE}?code={user_code}"
184
+
185
+
186
+ def start(url: str, scope: str) -> tuple[Api, dict[str, Any]]:
187
+ """Ask the instance for a login. The client it returns has no key: the token route needs none."""
188
+ api = Api(Config(url, ""), transport=TRANSPORT)
189
+ status, body = api.answer("POST", "/auth/device", {"client_name": client_name(), "scope": scope})
190
+ if status != 200 or not isinstance(body, dict):
191
+ api.close()
192
+ raise ApiError(f"{status} {body.get('error') if isinstance(body, dict) else 'not a LeanCPMS instance?'}",
193
+ status)
194
+ return api, body
195
+
196
+
197
+ def revoke(url: str, api_key: str) -> None:
198
+ """Best effort: a key that could not be saved."""
199
+ api = Api(Config(url, api_key), transport=TRANSPORT)
200
+ try:
201
+ api.answer("POST", "/auth/logout")
202
+ except ApiError:
203
+ pass
204
+ finally:
205
+ api.close()
206
+
207
+
208
+ def poll(api: Api, started: dict[str, Any]) -> dict[str, Any]:
209
+ """Until the person decides or the code expires. A network error or a busy server is waited out."""
210
+ interval = int(started.get("interval") or 5)
211
+ deadline = clock() + int(started["expires_in"])
212
+ while True:
213
+ sleep(interval)
214
+ if clock() > deadline:
215
+ raise ApiError("the code expired before anyone approved it; run cpms login again")
216
+ try:
217
+ status, body = api.answer("POST", "/auth/device/token", {"device_code": started["device_code"]})
218
+ except ApiError:
219
+ continue
220
+ error = body.get("error") if isinstance(body, dict) else None
221
+ if status == 200 and isinstance(body, dict):
222
+ return body
223
+ if error == "authorization_pending" or status >= 500:
224
+ continue
225
+ if error == "slow_down" or status == 429:
226
+ interval = min(interval + 5, MAX_INTERVAL_S)
227
+ continue
228
+ if error == "access_denied":
229
+ raise ApiError("denied in the portal; no key was issued", status)
230
+ if error == "expired_token":
231
+ raise ApiError("the code expired before anyone approved it; run cpms login again", status)
232
+ raise ApiError(f"{status} {error or body}", status)