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 +0 -0
- cpms/cli/__init__.py +0 -0
- cpms/cli/client.py +126 -0
- cpms/cli/login.py +232 -0
- cpms/cli/main.py +982 -0
- cpms/manifest/__init__.py +0 -0
- cpms/manifest/documents.py +118 -0
- cpms/manifest/schema.py +374 -0
- leancpms_cli-0.1.0.dist-info/METADATA +211 -0
- leancpms_cli-0.1.0.dist-info/RECORD +13 -0
- leancpms_cli-0.1.0.dist-info/WHEEL +4 -0
- leancpms_cli-0.1.0.dist-info/entry_points.txt +2 -0
- leancpms_cli-0.1.0.dist-info/licenses/LICENSE +662 -0
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)
|