monolynx-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.
- monolynx_cli/__init__.py +11 -0
- monolynx_cli/__main__.py +8 -0
- monolynx_cli/client.py +225 -0
- monolynx_cli/commands/__init__.py +1 -0
- monolynx_cli/commands/_common.py +157 -0
- monolynx_cli/commands/auth.py +169 -0
- monolynx_cli/commands/completion.py +44 -0
- monolynx_cli/commands/config.py +128 -0
- monolynx_cli/commands/member.py +76 -0
- monolynx_cli/commands/project.py +103 -0
- monolynx_cli/commands/sprint.py +143 -0
- monolynx_cli/commands/ticket.py +342 -0
- monolynx_cli/commands/wiki.py +275 -0
- monolynx_cli/config.py +157 -0
- monolynx_cli/main.py +149 -0
- monolynx_cli/oauth.py +411 -0
- monolynx_cli/options.py +44 -0
- monolynx_cli/output.py +186 -0
- monolynx_cli/version.py +9 -0
- monolynx_cli-0.1.0.dist-info/METADATA +536 -0
- monolynx_cli-0.1.0.dist-info/RECORD +24 -0
- monolynx_cli-0.1.0.dist-info/WHEEL +4 -0
- monolynx_cli-0.1.0.dist-info/entry_points.txt +3 -0
- monolynx_cli-0.1.0.dist-info/licenses/LICENSE +7 -0
monolynx_cli/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Pakiet monolynx-cli: instalowalny klient linii komend platformy Monolynx.
|
|
2
|
+
|
|
3
|
+
Niezależny od pakietu backendu `monolynx` -- nie importuje z niego niczego (patrz
|
|
4
|
+
tests/test_main.py::test_package_never_imports_backend_monolynx_package).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from monolynx_cli.version import __version__
|
|
10
|
+
|
|
11
|
+
__all__ = ["__version__"]
|
monolynx_cli/__main__.py
ADDED
monolynx_cli/client.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Klient HTTP monolynx-cli: MonolynxClient, wyjątki i mapowanie błędów `/api/v2` (RFC 7807).
|
|
2
|
+
|
|
3
|
+
Jeden klient dla wszystkich komend: nagłówki (Authorization Bearer, User-Agent, Accept),
|
|
4
|
+
retry z backoffem, jednorazowy hook odświeżenia tokenu po 401 i tłumaczenie odpowiedzi
|
|
5
|
+
błędu na wyjątki z deterministycznym `exit_code`. Globalny handler w `monolynx_cli.main`
|
|
6
|
+
zamienia każdy `MonolynxError` na komunikat na stderr i kod wyjścia procesu:
|
|
7
|
+
|
|
8
|
+
1 MonolynxAPIError błąd API (4xx poza 401/403, 429/5xx po wyczerpaniu prób)
|
|
9
|
+
3 MonolynxAuthError 401/403, nieudany refresh, brak tokenu wykryty przed wysłaniem
|
|
10
|
+
4 MonolynxNetworkError timeout, odmowa połączenia, DNS (bez retry)
|
|
11
|
+
|
|
12
|
+
Retry: maks. 4 próby. GET/DELETE ponawiane na 429 i 5xx, POST/PATCH tylko na 429 --
|
|
13
|
+
ponowienie po 5xx mogłoby zdublować utworzony zasób. Czekanie idzie wyłącznie przez
|
|
14
|
+
wstrzyknięte `sleep`, dzięki czemu testy nie śpią naprawdę.
|
|
15
|
+
|
|
16
|
+
Token nie ma stałego prefiksu (legacy `osk_`, access token OAuth to goły `token_urlsafe`),
|
|
17
|
+
więc do logów `--debug` trafia wyłącznie `config.mask_token()`.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
import time
|
|
24
|
+
from collections.abc import Callable
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
import httpx
|
|
28
|
+
from rich.console import Console
|
|
29
|
+
|
|
30
|
+
from monolynx_cli.config import mask_token
|
|
31
|
+
from monolynx_cli.version import __version__
|
|
32
|
+
|
|
33
|
+
BACKOFF_DELAYS: tuple[float, ...] = (0.5, 1.0, 2.0)
|
|
34
|
+
MAX_RETRY_AFTER: float = 60.0
|
|
35
|
+
DEFAULT_TIMEOUT: float = 30.0
|
|
36
|
+
|
|
37
|
+
_ERROR_BODY_LIMIT = 200
|
|
38
|
+
_DEBUG_BODY_LIMIT = 500
|
|
39
|
+
_RETRY_AFTER_SECONDS = re.compile(r"\d+(?:\.\d+)?")
|
|
40
|
+
_NO_TOKEN_HINT = "Zaloguj się: monolynx auth login"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class MonolynxError(Exception):
|
|
44
|
+
exit_code: int = 1
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class MonolynxAPIError(MonolynxError):
|
|
48
|
+
exit_code = 1
|
|
49
|
+
|
|
50
|
+
def __init__(self, status: int | None, title: str, detail: str | None = None, type_: str = "about:blank") -> None:
|
|
51
|
+
head = f"{status} {title}" if status is not None else title
|
|
52
|
+
super().__init__(f"{head}: {detail}" if detail else head)
|
|
53
|
+
self.status = status
|
|
54
|
+
self.title = title
|
|
55
|
+
self.detail = detail
|
|
56
|
+
self.type_ = type_
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class MonolynxAuthError(MonolynxAPIError):
|
|
60
|
+
exit_code = 3
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class MonolynxNetworkError(MonolynxError):
|
|
64
|
+
exit_code = 4
|
|
65
|
+
|
|
66
|
+
def __init__(self, message: str) -> None:
|
|
67
|
+
super().__init__(message)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _parse_retry_after(value: str | None) -> float | None:
|
|
71
|
+
# Data HTTP, wartość ujemna i nienumeryczna celowo wracają do zwykłego backoffu.
|
|
72
|
+
if value is None:
|
|
73
|
+
return None
|
|
74
|
+
text = value.strip()
|
|
75
|
+
if not _RETRY_AFTER_SECONDS.fullmatch(text):
|
|
76
|
+
return None
|
|
77
|
+
return min(float(text), MAX_RETRY_AFTER)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _build_error(response: httpx.Response) -> MonolynxAPIError:
|
|
81
|
+
status = response.status_code
|
|
82
|
+
body = response.text.strip()
|
|
83
|
+
title = response.reason_phrase or "Błąd HTTP"
|
|
84
|
+
detail: str | None = body[:_ERROR_BODY_LIMIT] or None
|
|
85
|
+
type_ = "about:blank"
|
|
86
|
+
|
|
87
|
+
content_type = response.headers.get("content-type", "").split(";")[0].strip().lower()
|
|
88
|
+
if content_type == "application/problem+json":
|
|
89
|
+
try:
|
|
90
|
+
payload = response.json()
|
|
91
|
+
except ValueError:
|
|
92
|
+
payload = None
|
|
93
|
+
if isinstance(payload, dict):
|
|
94
|
+
problem_title = payload.get("title")
|
|
95
|
+
problem_detail = payload.get("detail")
|
|
96
|
+
problem_type = payload.get("type")
|
|
97
|
+
title = problem_title if isinstance(problem_title, str) and problem_title else title
|
|
98
|
+
detail = problem_detail if isinstance(problem_detail, str) and problem_detail else None
|
|
99
|
+
type_ = problem_type if isinstance(problem_type, str) and problem_type else type_
|
|
100
|
+
|
|
101
|
+
error_class = MonolynxAuthError if status in (401, 403) else MonolynxAPIError
|
|
102
|
+
return error_class(status, title, detail, type_)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class MonolynxClient:
|
|
106
|
+
def __init__(
|
|
107
|
+
self,
|
|
108
|
+
endpoint: str,
|
|
109
|
+
token: str | None,
|
|
110
|
+
*,
|
|
111
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
112
|
+
debug: bool = False,
|
|
113
|
+
refresh: Callable[[], str | None] | None = None,
|
|
114
|
+
transport: httpx.BaseTransport | None = None,
|
|
115
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
116
|
+
) -> None:
|
|
117
|
+
self._token = token
|
|
118
|
+
self._debug = debug
|
|
119
|
+
self._refresh = refresh
|
|
120
|
+
self._sleep = sleep
|
|
121
|
+
try:
|
|
122
|
+
self._client = httpx.Client(
|
|
123
|
+
base_url=endpoint,
|
|
124
|
+
timeout=timeout,
|
|
125
|
+
transport=transport,
|
|
126
|
+
headers={"User-Agent": f"monolynx-cli/{__version__}", "Accept": "application/json"},
|
|
127
|
+
)
|
|
128
|
+
except httpx.InvalidURL as exc:
|
|
129
|
+
raise MonolynxNetworkError(f"Nieprawidłowy adres API: {endpoint} ({exc})") from exc
|
|
130
|
+
|
|
131
|
+
def __enter__(self) -> MonolynxClient:
|
|
132
|
+
return self
|
|
133
|
+
|
|
134
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
135
|
+
self.close()
|
|
136
|
+
|
|
137
|
+
def close(self) -> None:
|
|
138
|
+
self._client.close()
|
|
139
|
+
|
|
140
|
+
def get(self, path: str, *, params: dict[str, Any] | None = None) -> Any:
|
|
141
|
+
return self._request("GET", path, params=params)
|
|
142
|
+
|
|
143
|
+
def delete(self, path: str, *, params: dict[str, Any] | None = None) -> Any:
|
|
144
|
+
return self._request("DELETE", path, params=params)
|
|
145
|
+
|
|
146
|
+
def post(self, path: str, *, json: Any = None, params: dict[str, Any] | None = None) -> Any:
|
|
147
|
+
return self._request("POST", path, json=json, params=params)
|
|
148
|
+
|
|
149
|
+
def patch(self, path: str, *, json: Any = None, params: dict[str, Any] | None = None) -> Any:
|
|
150
|
+
return self._request("PATCH", path, json=json, params=params)
|
|
151
|
+
|
|
152
|
+
def _request(self, method: str, path: str, *, json: Any = None, params: dict[str, Any] | None = None) -> Any:
|
|
153
|
+
if not self._token:
|
|
154
|
+
raise MonolynxAuthError(None, "Brak tokenu", _NO_TOKEN_HINT)
|
|
155
|
+
|
|
156
|
+
retry_5xx = method in ("GET", "DELETE")
|
|
157
|
+
refreshed = False
|
|
158
|
+
retries = 0
|
|
159
|
+
while True:
|
|
160
|
+
response = self._send(method, path, json, params)
|
|
161
|
+
status = response.status_code
|
|
162
|
+
|
|
163
|
+
if status == 401 and self._refresh is not None and not refreshed:
|
|
164
|
+
refreshed = True
|
|
165
|
+
new_token = self._refresh()
|
|
166
|
+
if new_token is None:
|
|
167
|
+
raise _build_error(response)
|
|
168
|
+
self._token = new_token
|
|
169
|
+
continue
|
|
170
|
+
|
|
171
|
+
if status < 400:
|
|
172
|
+
return self._parse_success(response)
|
|
173
|
+
|
|
174
|
+
if retries < len(BACKOFF_DELAYS) and (status == 429 or (retry_5xx and status >= 500)):
|
|
175
|
+
delay = BACKOFF_DELAYS[retries]
|
|
176
|
+
if status == 429:
|
|
177
|
+
retry_after = _parse_retry_after(response.headers.get("retry-after"))
|
|
178
|
+
if retry_after is not None:
|
|
179
|
+
delay = retry_after
|
|
180
|
+
self._sleep(delay)
|
|
181
|
+
retries += 1
|
|
182
|
+
continue
|
|
183
|
+
|
|
184
|
+
raise _build_error(response)
|
|
185
|
+
|
|
186
|
+
def _send(self, method: str, path: str, json: Any, params: dict[str, Any] | None) -> httpx.Response:
|
|
187
|
+
token = self._token or ""
|
|
188
|
+
try:
|
|
189
|
+
request = self._client.build_request(method, path, json=json, params=params, headers={"Authorization": f"Bearer {token}"})
|
|
190
|
+
self._log_request(request, token)
|
|
191
|
+
response = self._client.send(request)
|
|
192
|
+
except httpx.InvalidURL as exc:
|
|
193
|
+
raise MonolynxNetworkError(f"Nieprawidłowy adres żądania: {path} ({exc})") from exc
|
|
194
|
+
except httpx.TransportError as exc:
|
|
195
|
+
raise MonolynxNetworkError(f"Błąd połączenia z {self._client.base_url} ({type(exc).__name__}): {exc}") from exc
|
|
196
|
+
self._log_response(response)
|
|
197
|
+
return response
|
|
198
|
+
|
|
199
|
+
@staticmethod
|
|
200
|
+
def _parse_success(response: httpx.Response) -> Any:
|
|
201
|
+
if response.status_code == 204 or not response.content:
|
|
202
|
+
return None
|
|
203
|
+
try:
|
|
204
|
+
return response.json()
|
|
205
|
+
except ValueError as exc:
|
|
206
|
+
body = response.text.strip()[:_ERROR_BODY_LIMIT] or None
|
|
207
|
+
raise MonolynxAPIError(response.status_code, "Nieprawidłowa odpowiedź serwera", body) from exc
|
|
208
|
+
|
|
209
|
+
def _log_request(self, request: httpx.Request, token: str) -> None:
|
|
210
|
+
if not self._debug:
|
|
211
|
+
return
|
|
212
|
+
console = Console(stderr=True, markup=False, highlight=False, emoji=False)
|
|
213
|
+
console.print(f"> {request.method} {request.url}", soft_wrap=True)
|
|
214
|
+
console.print(f"> Authorization: Bearer {mask_token(token)}", soft_wrap=True)
|
|
215
|
+
console.print(f"> User-Agent: monolynx-cli/{__version__}", soft_wrap=True)
|
|
216
|
+
console.print("> Accept: application/json", soft_wrap=True)
|
|
217
|
+
|
|
218
|
+
def _log_response(self, response: httpx.Response) -> None:
|
|
219
|
+
if not self._debug:
|
|
220
|
+
return
|
|
221
|
+
console = Console(stderr=True, markup=False, highlight=False, emoji=False)
|
|
222
|
+
console.print(f"< {response.status_code}", soft_wrap=True)
|
|
223
|
+
body = response.text.strip()[:_DEBUG_BODY_LIMIT]
|
|
224
|
+
if body:
|
|
225
|
+
console.print(body, soft_wrap=True)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Moduly komend monolynx-cli, kazdy z wlasnym typer.Typer() rejestrowanym w main.py."""
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Wspólne helpery komend: ustawienia, klient projektu, potwierdzenia, paginacja, teksty z pliku/stdin.
|
|
2
|
+
|
|
3
|
+
Każda grupa komend otwiera klienta przez `new_client(ctx, settings)` (albo `open_project(ctx)`,
|
|
4
|
+
które dodaje sprawdzenie tokenu i projektu i zwraca (MonolynxClient, slug)). Fabryka tworzy
|
|
5
|
+
klienta przez nazwę modułową `MonolynxClient` w TYM module, którą testy podmieniają przez
|
|
6
|
+
monkeypatch (`monolynx_cli.commands._common.MonolynxClient`), więc grupy nie importują klasy
|
|
7
|
+
klienta do tworzenia instancji. Fabryka podpina odświeżanie tokenu OAuth: proaktywne przed
|
|
8
|
+
pierwszym żądaniem (gdy access token wygasa w ciągu doby) i reaktywne po 401.
|
|
9
|
+
|
|
10
|
+
Odpowiedź serwera i plik konfiguracyjny to granice systemu: `fetch_all` sprawdza kształt
|
|
11
|
+
koperty każdej strony przed użyciem, a `load_settings` zamienia uszkodzoną konfigurację na
|
|
12
|
+
komunikat i kod 1. Każda wartość użytkownika wstawiana do ścieżki URL przechodzi przez
|
|
13
|
+
`path_segment`. Błędy lokalne (brak projektu, sprzeczne flagi, brak pliku) kończą się
|
|
14
|
+
typer.Exit z komunikatem na stderr, bez żadnego żądania HTTP. Brak tokenu to
|
|
15
|
+
MonolynxAuthError (kod 3), tak jak w `auth whoami`. Błędy odpowiedzi serwera propagują do
|
|
16
|
+
globalnego handlera w monolynx_cli.main.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
from urllib.parse import quote
|
|
25
|
+
|
|
26
|
+
import typer
|
|
27
|
+
|
|
28
|
+
from monolynx_cli import oauth
|
|
29
|
+
from monolynx_cli.client import MonolynxAPIError, MonolynxAuthError, MonolynxClient
|
|
30
|
+
from monolynx_cli.config import ConfigError, Settings, resolve_settings
|
|
31
|
+
from monolynx_cli.options import get_options
|
|
32
|
+
|
|
33
|
+
PER_PAGE = 100
|
|
34
|
+
STDIN_MARKER = "-"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _stdin_is_tty() -> bool:
|
|
38
|
+
# Osobna funkcja, żeby testy mogły ją podmienić: CliRunner nie ma prawdziwego TTY.
|
|
39
|
+
return sys.stdin.isatty()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def usage_error(message: str) -> typer.Exit:
|
|
43
|
+
"""Wypisz komunikat na stderr i zwróć typer.Exit(2) do rzucenia (błąd użycia)."""
|
|
44
|
+
typer.echo(message, err=True)
|
|
45
|
+
return typer.Exit(2)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_settings(ctx: typer.Context, project: str | None = None) -> Settings:
|
|
49
|
+
opts = get_options(ctx)
|
|
50
|
+
try:
|
|
51
|
+
return resolve_settings({"profile": opts.profile, "project": project or opts.project})
|
|
52
|
+
except ConfigError as exc:
|
|
53
|
+
typer.echo(str(exc), err=True)
|
|
54
|
+
raise typer.Exit(1) from exc
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def require_project(settings: Settings) -> str:
|
|
58
|
+
if not settings.project:
|
|
59
|
+
typer.echo("Brak projektu: podaj --project, ustaw MONOLYNX_PROJECT albo project w profilu.", err=True)
|
|
60
|
+
raise typer.Exit(2)
|
|
61
|
+
return settings.project
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def new_client(ctx: typer.Context, settings: Settings) -> MonolynxClient:
|
|
65
|
+
"""Klient API z odświeżaniem tokenu OAuth; dla tokenu statycznego i MONOLYNX_TOKEN bez odświeżania."""
|
|
66
|
+
opts = get_options(ctx)
|
|
67
|
+
bearer = oauth.refresh_if_expiring(settings, timeout=opts.timeout) or settings.bearer
|
|
68
|
+
refresh = oauth.make_refresh_hook(settings, timeout=opts.timeout)
|
|
69
|
+
return MonolynxClient(settings.endpoint, bearer, timeout=opts.timeout, debug=opts.debug, refresh=refresh)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def open_project(ctx: typer.Context) -> tuple[MonolynxClient, str]:
|
|
73
|
+
"""Zwróć klienta API i slug projektu z flag globalnych, zmiennych środowiskowych i profilu."""
|
|
74
|
+
settings = load_settings(ctx)
|
|
75
|
+
if settings.bearer is None:
|
|
76
|
+
raise MonolynxAuthError(None, "Brak tokenu", "Brak zapisanego tokenu. Zaloguj się: monolynx auth login")
|
|
77
|
+
slug = require_project(settings)
|
|
78
|
+
path_segment(slug) # odrzuca `..` zanim new_client wykona choćby odświeżenie tokenu
|
|
79
|
+
return new_client(ctx, settings), slug
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def project_path(slug: str, suffix: str = "") -> str:
|
|
83
|
+
"""Ścieżka endpointu API v2 w obrębie projektu, np. project_path("mon", "/tickets")."""
|
|
84
|
+
return f"/api/v2/projects/{path_segment(slug)}{suffix}"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def confirm_destructive(message: str, *, yes: bool) -> None:
|
|
88
|
+
if yes:
|
|
89
|
+
return
|
|
90
|
+
if not _stdin_is_tty():
|
|
91
|
+
typer.echo("Operacja wymaga potwierdzenia: dodaj --yes.", err=True)
|
|
92
|
+
raise typer.Exit(2)
|
|
93
|
+
if not typer.confirm(message, default=False):
|
|
94
|
+
typer.echo("Anulowano.", err=True)
|
|
95
|
+
raise typer.Exit(1)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def path_segment(value: str) -> str:
|
|
99
|
+
"""Zakoduj wartość użytkownika jako jeden segment ścieżki URL.
|
|
100
|
+
|
|
101
|
+
`.` i `..` (oraz pusty tekst) kodowanie zostawia bez zmian, a klient HTTP normalizuje je
|
|
102
|
+
w ścieżce, więc wyszłyby poza zamierzony zasób; to błąd użycia, bez żądania HTTP.
|
|
103
|
+
"""
|
|
104
|
+
if value in ("", ".", ".."):
|
|
105
|
+
raise usage_error(f"Niedozwolony identyfikator w ścieżce: {value!r}.")
|
|
106
|
+
return quote(value, safe="")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def read_text_option(value: str | None, file_path: Path | None, *, option: str, file_option: str | None = None) -> str | None:
|
|
110
|
+
"""Rozwiąż tekst z flagi (`-` = stdin) albo z pliku; None gdy żadne nie podane.
|
|
111
|
+
|
|
112
|
+
Podanie obu źródeł naraz, brak pliku i pusty stdin to błąd użycia (exit 2).
|
|
113
|
+
"""
|
|
114
|
+
if value is not None and file_path is not None:
|
|
115
|
+
raise usage_error(f"Podaj {option} albo {file_option}, nie oba naraz.")
|
|
116
|
+
if file_path is not None:
|
|
117
|
+
try:
|
|
118
|
+
return file_path.read_text(encoding="utf-8")
|
|
119
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
120
|
+
detail = exc.strerror if isinstance(exc, OSError) else "plik nie jest poprawnym UTF-8"
|
|
121
|
+
raise usage_error(f"Nie można odczytać pliku {file_path}: {detail or exc}") from exc
|
|
122
|
+
if value == STDIN_MARKER:
|
|
123
|
+
text = sys.stdin.read()
|
|
124
|
+
if not text.strip():
|
|
125
|
+
raise usage_error(f"Pusty tekst na standardowym wejściu dla {option} -.")
|
|
126
|
+
return text
|
|
127
|
+
return value
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def without_none(data: dict[str, Any]) -> dict[str, Any]:
|
|
131
|
+
"""Odrzuć klucze z wartością None; puste stringi i puste listy zostają (semantyka czyszczenia MCP)."""
|
|
132
|
+
return {key: value for key, value in data.items() if value is not None}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _invalid_page(detail: str) -> MonolynxAPIError:
|
|
136
|
+
return MonolynxAPIError(200, "Nieprawidłowa odpowiedź serwera", detail)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def fetch_all(client: MonolynxClient, path: str, params: dict[str, Any] | None = None) -> list[dict[str, Any]]:
|
|
140
|
+
"""Pobierz elementy ze wszystkich stron; `params` (np. filtr statusu) idą na każdą stronę."""
|
|
141
|
+
items: list[dict[str, Any]] = []
|
|
142
|
+
page = 1
|
|
143
|
+
total_pages = 1
|
|
144
|
+
while page <= total_pages:
|
|
145
|
+
payload = client.get(path, params={**(params or {}), "page": page, "per_page": PER_PAGE})
|
|
146
|
+
if not isinstance(payload, dict):
|
|
147
|
+
raise _invalid_page("Odpowiedź nie jest obiektem JSON.")
|
|
148
|
+
page_items = payload.get("items")
|
|
149
|
+
if not isinstance(page_items, list) or not all(isinstance(item, dict) for item in page_items):
|
|
150
|
+
raise _invalid_page("Pole items nie jest listą obiektów.")
|
|
151
|
+
pages = payload.get("total_pages")
|
|
152
|
+
if isinstance(pages, bool) or not isinstance(pages, int) or pages < 1:
|
|
153
|
+
raise _invalid_page("Pole total_pages nie jest liczbą całkowitą >= 1.")
|
|
154
|
+
items.extend(page_items)
|
|
155
|
+
total_pages = pages
|
|
156
|
+
page += 1
|
|
157
|
+
return items
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Komendy monolynx_cli.commands.auth: login/logout/whoami.
|
|
2
|
+
|
|
3
|
+
Login domyślnie robi OAuth 2.1 w przeglądarce (monolynx_cli.oauth), z --token zapisuje
|
|
4
|
+
token API bez przeglądarki. Po zalogowaniu i we whoami komenda bije w GET /api/v2/me
|
|
5
|
+
backendu Monolynx (autoryzacja Bearer) przez monolynx_cli.client.MonolynxClient -- nazwę
|
|
6
|
+
modułową `MonolynxClient` podmieniają testy przez monkeypatch; dla profilu oauth klient
|
|
7
|
+
dostaje hook odświeżania tokenu po 401 (oauth.make_refresh_hook). Błędy HTTP i sieci to wyjątki klienta (MonolynxError), które
|
|
8
|
+
propaguje do globalnego handlera w monolynx_cli.main (kody wyjścia 1/3/4), więc komendy
|
|
9
|
+
nie mają własnych typer.Exit dla odpowiedzi serwera.
|
|
10
|
+
Token nigdy nie trafia na stdout/stderr w pełnej postaci, tylko przez config.mask_token.
|
|
11
|
+
Plik konfiguracyjny może być ręcznie edytowany przez człowieka, a odpowiedź serwera
|
|
12
|
+
może nie być tym, czego oczekujemy (zły --endpoint, HTML zamiast JSON) -- obie granice
|
|
13
|
+
są sprawdzane przed użyciem, nigdy nie kończą się nieobsłużonym wyjątkiem.
|
|
14
|
+
|
|
15
|
+
Lokalna flaga `--profile` każdej komendy wygrywa z globalną `--profile` z roota
|
|
16
|
+
(monolynx_cli.main), globalna wygrywa z MONOLYNX_PROFILE/MONOLYNX_PROJECT -- kolejność
|
|
17
|
+
pierwszeństwa poza tym bez zmian, patrz config.resolve_settings. whoami wypisuje wynik
|
|
18
|
+
przez output.emit() (konwencja modułu monolynx_cli.output), nie przez typer.echo.
|
|
19
|
+
Komunikaty stanu (login, logout, brak profilu) idą na stderr przez output.echo_stderr i
|
|
20
|
+
są wyciszane przez --quiet; stdout zostaje czysty dla potoków.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from collections.abc import Callable
|
|
26
|
+
|
|
27
|
+
import typer
|
|
28
|
+
|
|
29
|
+
from monolynx_cli import oauth
|
|
30
|
+
from monolynx_cli.client import MonolynxAPIError, MonolynxAuthError, MonolynxClient
|
|
31
|
+
from monolynx_cli.config import (
|
|
32
|
+
DEFAULT_PROFILE,
|
|
33
|
+
LOGOUT_CLEARED_FIELDS,
|
|
34
|
+
ConfigError,
|
|
35
|
+
get_profile,
|
|
36
|
+
load_config,
|
|
37
|
+
mask_token,
|
|
38
|
+
resolve_settings,
|
|
39
|
+
save_config,
|
|
40
|
+
)
|
|
41
|
+
from monolynx_cli.options import GlobalOptions, get_options
|
|
42
|
+
from monolynx_cli.output import echo_stderr, emit
|
|
43
|
+
|
|
44
|
+
app = typer.Typer(no_args_is_help=True)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _fetch_email(endpoint: str, token: str, opts: GlobalOptions, refresh: Callable[[], str | None] | None = None) -> str:
|
|
48
|
+
with MonolynxClient(endpoint, token, timeout=opts.timeout, debug=opts.debug, refresh=refresh) as client:
|
|
49
|
+
payload = client.get("/api/v2/me")
|
|
50
|
+
email = payload.get("email") if isinstance(payload, dict) else None
|
|
51
|
+
if not isinstance(email, str):
|
|
52
|
+
raise MonolynxAPIError(200, "Nieprawidłowa odpowiedź serwera", "Odpowiedź nie zawiera pola email.")
|
|
53
|
+
return email
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _save_token_profile(profile: str, endpoint: str, token: str) -> None:
|
|
57
|
+
data = load_config()
|
|
58
|
+
profiles = data.setdefault("profile", {})
|
|
59
|
+
target = dict(profiles.get(profile, {}))
|
|
60
|
+
target["endpoint"] = endpoint
|
|
61
|
+
target["auth_type"] = "token"
|
|
62
|
+
target["token"] = token
|
|
63
|
+
profiles[profile] = target
|
|
64
|
+
data.setdefault("general", {}).setdefault("active_profile", DEFAULT_PROFILE)
|
|
65
|
+
save_config(data)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@app.command("login")
|
|
69
|
+
def login(
|
|
70
|
+
ctx: typer.Context,
|
|
71
|
+
endpoint: str | None = typer.Option(None, "--endpoint", help="Adres API Monolynx."),
|
|
72
|
+
token: str | None = typer.Option(None, "--token", help="Token API zamiast logowania w przeglądarce (bez OAuth)."),
|
|
73
|
+
profile: str | None = typer.Option(None, "--profile", help="Nazwa profilu docelowego."),
|
|
74
|
+
no_browser: bool = typer.Option(False, "--no-browser", help="Nie otwieraj przeglądarki, wypisz adres autoryzacji na stderr."),
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Zaloguj się przez OAuth w przeglądarce (albo tokenem API z --token) i zapisz sesję w profilu konfiguracji."""
|
|
77
|
+
opts = get_options(ctx)
|
|
78
|
+
try:
|
|
79
|
+
settings = resolve_settings({"endpoint": endpoint, "profile": profile or opts.profile, "project": opts.project})
|
|
80
|
+
except ConfigError as exc:
|
|
81
|
+
typer.echo(str(exc), err=True)
|
|
82
|
+
raise typer.Exit(1) from exc
|
|
83
|
+
|
|
84
|
+
tokens: oauth.TokenSet | None = None
|
|
85
|
+
if token is None:
|
|
86
|
+
tokens = oauth.run_login(settings.endpoint, no_browser=no_browser, timeout=opts.timeout, quiet=opts.quiet, no_color=opts.no_color)
|
|
87
|
+
email = _fetch_email(settings.endpoint, tokens.access_token, opts)
|
|
88
|
+
else:
|
|
89
|
+
email = _fetch_email(settings.endpoint, token, opts)
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
if tokens is not None:
|
|
93
|
+
oauth.save_tokens(settings.profile, settings.endpoint, tokens)
|
|
94
|
+
else:
|
|
95
|
+
_save_token_profile(settings.profile, settings.endpoint, str(token))
|
|
96
|
+
except ConfigError as exc:
|
|
97
|
+
typer.echo(str(exc), err=True)
|
|
98
|
+
raise typer.Exit(1) from exc
|
|
99
|
+
|
|
100
|
+
echo_stderr(f"Zalogowano jako {email} (profil {settings.profile})", quiet=opts.quiet, no_color=opts.no_color)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@app.command("logout")
|
|
104
|
+
def logout(
|
|
105
|
+
ctx: typer.Context,
|
|
106
|
+
profile: str | None = typer.Option(None, "--profile", help="Nazwa profilu do wylogowania."),
|
|
107
|
+
) -> None:
|
|
108
|
+
"""Wyczyść zapisane sekrety (token/access_token/refresh_token/expires_at) profilu."""
|
|
109
|
+
opts = get_options(ctx)
|
|
110
|
+
try:
|
|
111
|
+
settings = resolve_settings({"profile": profile or opts.profile, "project": opts.project})
|
|
112
|
+
except ConfigError as exc:
|
|
113
|
+
typer.echo(str(exc), err=True)
|
|
114
|
+
raise typer.Exit(1) from exc
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
data = load_config()
|
|
118
|
+
except ConfigError as exc:
|
|
119
|
+
typer.echo(str(exc), err=True)
|
|
120
|
+
raise typer.Exit(1) from exc
|
|
121
|
+
|
|
122
|
+
profiles = data.get("profile", {})
|
|
123
|
+
if settings.profile not in profiles:
|
|
124
|
+
echo_stderr(f"Profil {settings.profile} nie istnieje.", quiet=opts.quiet, no_color=opts.no_color)
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
target = dict(profiles[settings.profile])
|
|
128
|
+
for field in LOGOUT_CLEARED_FIELDS:
|
|
129
|
+
target.pop(field, None)
|
|
130
|
+
profiles[settings.profile] = target
|
|
131
|
+
save_config(data)
|
|
132
|
+
|
|
133
|
+
echo_stderr(f"Wylogowano z profilu {settings.profile}", quiet=opts.quiet, no_color=opts.no_color)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@app.command("whoami")
|
|
137
|
+
def whoami(
|
|
138
|
+
ctx: typer.Context,
|
|
139
|
+
profile: str | None = typer.Option(None, "--profile", help="Nazwa profilu do sprawdzenia."),
|
|
140
|
+
) -> None:
|
|
141
|
+
"""Wyświetl dane zalogowanego użytkownika."""
|
|
142
|
+
opts = get_options(ctx)
|
|
143
|
+
try:
|
|
144
|
+
settings = resolve_settings({"profile": profile or opts.profile, "project": opts.project})
|
|
145
|
+
except ConfigError as exc:
|
|
146
|
+
typer.echo(str(exc), err=True)
|
|
147
|
+
raise typer.Exit(1) from exc
|
|
148
|
+
|
|
149
|
+
if settings.bearer is None:
|
|
150
|
+
raise MonolynxAuthError(None, "Brak tokenu", "Brak zapisanego tokenu. Zaloguj się: monolynx auth login")
|
|
151
|
+
|
|
152
|
+
hook = oauth.make_refresh_hook(settings, timeout=opts.timeout)
|
|
153
|
+
email = _fetch_email(settings.endpoint, str(settings.bearer), opts, refresh=hook)
|
|
154
|
+
|
|
155
|
+
result: dict[str, str | None] = {"email": email, "profile": settings.profile, "auth_type": settings.auth_type}
|
|
156
|
+
if settings.auth_type != "oauth":
|
|
157
|
+
result["token"] = mask_token(str(settings.bearer))
|
|
158
|
+
emit(ctx, result)
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
# Po odświeżeniu w profilu leży nowy access token i nowy expires_at, a settings trzyma stary.
|
|
162
|
+
try:
|
|
163
|
+
stored = get_profile(settings.profile)
|
|
164
|
+
except ConfigError as exc:
|
|
165
|
+
typer.echo(str(exc), err=True)
|
|
166
|
+
raise typer.Exit(1) from exc
|
|
167
|
+
result["token"] = mask_token(str(stored.get("access_token") or settings.bearer))
|
|
168
|
+
result["expires_at"] = stored.get("expires_at")
|
|
169
|
+
emit(ctx, result)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Komenda `monolynx completion <shell>`: wypisuje skrypt uzupełniania poleceń na stdout.
|
|
2
|
+
|
|
3
|
+
Skrypt generuje `typer.completion.get_completion_script` -- ta sama funkcja, której używa
|
|
4
|
+
wbudowane `--show-completion`, więc wynik jest identyczny; różnica to jawna powłoka
|
|
5
|
+
zamiast autodetekcji i wybór nazwy programu. Moduł `typer.completion` jest publiczny i od
|
|
6
|
+
typer 0.12 do 0.27 ma tę samą sygnaturę (keyword-only `prog_name`, `complete_var`, `shell`);
|
|
7
|
+
nowsze typer wendorują Clicka, więc `click.shell_completion` nie jest dostępne i nie ma
|
|
8
|
+
innej publicznej drogi. Komenda nie czyta konfiguracji ani tokenu.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
from typer.completion import get_completion_script
|
|
15
|
+
|
|
16
|
+
from monolynx_cli.commands._common import usage_error
|
|
17
|
+
|
|
18
|
+
SHELLS = ("bash", "zsh", "fish")
|
|
19
|
+
# Skrypt jest wiązany z nazwą wywołania (zmienna _<PROG>_COMPLETE), a pakiet instaluje
|
|
20
|
+
# tylko te dwie; inna nazwa (np. `python -m monolynx_cli`) dostaje skrypt dla `monolynx`.
|
|
21
|
+
PROG_NAMES = ("monolynx", "mnx")
|
|
22
|
+
DEFAULT_PROG_NAME = "monolynx"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def completion(
|
|
26
|
+
ctx: typer.Context,
|
|
27
|
+
shell: str = typer.Argument(..., metavar="POWŁOKA", help="Powłoka: bash, zsh lub fish."),
|
|
28
|
+
) -> None:
|
|
29
|
+
"""Wypisz skrypt uzupełniania poleceń (bash, zsh, fish) na stdout.
|
|
30
|
+
|
|
31
|
+
Skrypt dotyczy nazwy, pod którą uruchomiono program:
|
|
32
|
+
|
|
33
|
+
monolynx completion zsh - skrypt dla monolynx,
|
|
34
|
+
|
|
35
|
+
mnx completion zsh - skrypt dla mnx.
|
|
36
|
+
|
|
37
|
+
Nie wymaga logowania ani konfiguracji.
|
|
38
|
+
"""
|
|
39
|
+
if shell not in SHELLS:
|
|
40
|
+
raise usage_error(f"Nieznana powłoka: {shell}. Dostępne: {', '.join(SHELLS)}.")
|
|
41
|
+
prog_name = ctx.find_root().info_name
|
|
42
|
+
if prog_name not in PROG_NAMES:
|
|
43
|
+
prog_name = DEFAULT_PROG_NAME
|
|
44
|
+
typer.echo(get_completion_script(prog_name=prog_name, complete_var=f"_{prog_name.upper()}_COMPLETE", shell=shell))
|