quaestor-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.
@@ -0,0 +1,7 @@
1
+ """Quaestor CLI — a thin HTTP client for the Quaestor API.
2
+
3
+ This package never imports the backend: it speaks HTTP only, so it can be
4
+ installed and shipped independently of the server.
5
+ """
6
+
7
+ __version__ = "0.1.0"
quaestor_cli/api.py ADDED
@@ -0,0 +1,224 @@
1
+ """Endpoint-level helpers shared by commands.
2
+
3
+ Keeping the URL shapes in one module means a contract change is a one-file edit,
4
+ and commands stay about presentation and validation.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Mapping
10
+ from dataclasses import dataclass
11
+ from datetime import date, datetime
12
+ from typing import Any
13
+
14
+ from quaestor_cli.client import ApiResponse, Probe, QuaestorClient
15
+ from quaestor_cli.models import DailyStat, Event, Identity, Streak, StreakReview, TimerSnapshot
16
+ from quaestor_cli.window import DateWindow
17
+
18
+ WHOAMI_PATH = "/auth/whoami"
19
+ STREAKS_PATH = "/streaks"
20
+ STREAKS_WITH_STATS_PATH = "/streaks/with-stats"
21
+ EVENTS_PATH = "/events"
22
+ STATS_DAILY_PATH = "/stats/daily"
23
+ TIMERS_PATH = "/timers"
24
+ TIMERS_STOP_PATH = "/timers/stop"
25
+ TIMERS_ACTIVE_PATH = "/timers/active"
26
+ HEALTHZ_PATH = "/healthz"
27
+
28
+ # The server caps this at 200; the CLI always wants the full set for name matching.
29
+ MAX_STREAKS = 200
30
+ # The server's own maximum, passed explicitly rather than relying on its default of 20.
31
+ EVENTS_FETCH_LIMIT = 1000
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class IdentityLookup:
36
+ """Outcome of `GET /auth/whoami`, including the "server is too old" case."""
37
+
38
+ probe: Probe
39
+ identity: Identity | None
40
+
41
+ @property
42
+ def is_success(self) -> bool:
43
+ """True when the identity and its scopes were read."""
44
+ return self.identity is not None
45
+
46
+ @property
47
+ def is_unsupported(self) -> bool:
48
+ """True when the endpoint is absent, so scopes are simply unknowable."""
49
+ return self.probe.is_unsupported
50
+
51
+
52
+ def fetch_identity(client: QuaestorClient) -> IdentityLookup:
53
+ """Read the caller's identity and scopes, returning failures as data.
54
+
55
+ Unscoped on the server, so it doubles as the cheapest "is my credential
56
+ accepted?" check.
57
+ """
58
+ probe = client.probe(WHOAMI_PATH)
59
+ payload = probe.payload if isinstance(probe.payload, Mapping) else None
60
+ identity = Identity.from_payload(payload) if probe.is_success and payload else None
61
+ return IdentityLookup(probe=probe, identity=identity)
62
+
63
+
64
+ def list_streaks(client: QuaestorClient, *, only_active: bool = True) -> list[Streak]:
65
+ """Fetch the streaks used for name resolution and the `streaks` listing."""
66
+ response = client.get(STREAKS_PATH, {"limit": MAX_STREAKS, "only_active": only_active})
67
+ return Streak.from_list(response.payload)
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class StreakDetail:
72
+ """One streak's full metadata plus the raw payload for --json."""
73
+
74
+ streak: Streak
75
+ raw: Any
76
+
77
+
78
+ def get_streak(client: QuaestorClient, streak_id: str) -> StreakDetail:
79
+ """Fetch a single streak's full record."""
80
+ response = client.get(f"{STREAKS_PATH}/{streak_id}")
81
+ payload = response.payload if isinstance(response.payload, Mapping) else {}
82
+ return StreakDetail(streak=Streak.from_payload(payload), raw=response.payload)
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class CreatedEvent:
87
+ """A created (or deduplicated) event plus the raw payload for --json."""
88
+
89
+ event: Event
90
+ raw: Any
91
+ already_logged: bool
92
+
93
+
94
+ def create_event(client: QuaestorClient, body: Mapping[str, Any]) -> CreatedEvent:
95
+ """POST an event; a 200 instead of 201 means the idempotency key already existed."""
96
+ response = client.post(EVENTS_PATH, body)
97
+ payload = response.payload if isinstance(response.payload, Mapping) else {}
98
+ return CreatedEvent(
99
+ event=Event.from_payload(payload),
100
+ raw=response.payload,
101
+ already_logged=response.status_code == 200,
102
+ )
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class EventPage:
107
+ """Events for a window, plus the raw payload and whether the fetch capped out."""
108
+
109
+ events: list[Event]
110
+ raw: Any
111
+ limit_reached: bool
112
+
113
+ @property
114
+ def count(self) -> int:
115
+ """How many events came back."""
116
+ return len(self.events)
117
+
118
+
119
+ def list_events(client: QuaestorClient, *, streak_id: str, window: DateWindow) -> EventPage:
120
+ """Fetch a streak's events in a window, newest event date first."""
121
+ response = client.get(
122
+ EVENTS_PATH,
123
+ {
124
+ "streak_id": streak_id,
125
+ "date_from": window.date_from.isoformat(),
126
+ "date_to": window.date_to.isoformat(),
127
+ # The server sorts this descending, so the newest events survive truncation.
128
+ "sort_by": "event_date",
129
+ "limit": EVENTS_FETCH_LIMIT,
130
+ },
131
+ )
132
+ events = Event.from_list(response.payload)
133
+ return EventPage(events=events, raw=response.payload, limit_reached=len(events) >= EVENTS_FETCH_LIMIT)
134
+
135
+
136
+ @dataclass(frozen=True)
137
+ class DailySeries:
138
+ """Per-day rows for a window, plus the raw payload for --json."""
139
+
140
+ rows: list[DailyStat]
141
+ raw: Any
142
+
143
+ @property
144
+ def active_days(self) -> int:
145
+ """Days with at least one event."""
146
+ return sum(1 for row in self.rows if row.is_active)
147
+
148
+ @property
149
+ def total(self) -> float:
150
+ """Window total in the streak's own unit (seconds for TIME)."""
151
+ return sum(row.total for row in self.rows)
152
+
153
+
154
+ def daily_stats(client: QuaestorClient, *, streak_id: str, window: DateWindow) -> DailySeries:
155
+ """Fetch zero-filled per-day totals, so missed days are visible."""
156
+ response = client.get(
157
+ STATS_DAILY_PATH,
158
+ {
159
+ "streak_id": streak_id,
160
+ "date_from": window.date_from.isoformat(),
161
+ "date_to": window.date_to.isoformat(),
162
+ },
163
+ )
164
+ return DailySeries(rows=DailyStat.from_list(response.payload), raw=response.payload)
165
+
166
+
167
+ @dataclass(frozen=True)
168
+ class TimerResult:
169
+ """A timer operation's snapshot plus the raw payload for --json."""
170
+
171
+ snapshot: TimerSnapshot
172
+ raw: Any
173
+
174
+
175
+ def start_timer(client: QuaestorClient, *, streak_id: str, now_local: datetime) -> TimerResult:
176
+ """Start a timer that begins recording at `now_local`."""
177
+ body = {
178
+ "streak_id": streak_id,
179
+ "started_at_local": now_local.isoformat(),
180
+ "now_local": now_local.isoformat(),
181
+ }
182
+ response = client.post(TIMERS_PATH, body)
183
+ return TimerResult(snapshot=_snapshot(response), raw=response.payload)
184
+
185
+
186
+ def stop_timer(client: QuaestorClient, *, now_local: datetime) -> TimerResult:
187
+ """Finalise the running block at `now_local`."""
188
+ response = client.post(TIMERS_STOP_PATH, {"now_local": now_local.isoformat()})
189
+ payload = response.payload if isinstance(response.payload, Mapping) else {}
190
+ return TimerResult(snapshot=TimerSnapshot.from_stop_payload(payload), raw=response.payload)
191
+
192
+
193
+ def active_timer(client: QuaestorClient) -> TimerResult:
194
+ """Read the currently running timer, if any."""
195
+ response = client.get(TIMERS_ACTIVE_PATH)
196
+ return TimerResult(snapshot=_snapshot(response), raw=response.payload)
197
+
198
+
199
+ @dataclass(frozen=True)
200
+ class ReviewResult:
201
+ """Review rows plus the raw payload for --json."""
202
+
203
+ rows: list[StreakReview]
204
+ raw: Any
205
+
206
+
207
+ def review(client: QuaestorClient, window: DateWindow, *, today: date) -> ReviewResult:
208
+ """Fetch streaks with stats for the window, using the caller's local today."""
209
+ response = client.get(
210
+ STREAKS_WITH_STATS_PATH,
211
+ {
212
+ "date_from": window.date_from.isoformat(),
213
+ "date_to": window.date_to.isoformat(),
214
+ "today": today.isoformat(),
215
+ "only_active": True,
216
+ },
217
+ )
218
+ return ReviewResult(rows=StreakReview.from_list(response.payload), raw=response.payload)
219
+
220
+
221
+ def _snapshot(response: ApiResponse) -> TimerSnapshot:
222
+ """Read a TimerStateOut body into a snapshot, tolerating an empty body."""
223
+ payload = response.payload if isinstance(response.payload, Mapping) else {}
224
+ return TimerSnapshot.from_payload(payload)
quaestor_cli/client.py ADDED
@@ -0,0 +1,187 @@
1
+ """HTTP access to the Quaestor API.
2
+
3
+ `httpx` is imported inside the request method on purpose: importing it at module
4
+ level adds ~18ms to every invocation, including `qst --help`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Mapping
10
+ from dataclasses import dataclass
11
+ from typing import Any
12
+
13
+ from quaestor_cli.config import assert_safe_for_credential
14
+ from quaestor_cli.errors import CliError, ExitCode
15
+
16
+ API_PREFIX = "/api/v1"
17
+ DEFAULT_TIMEOUT_SECONDS = 15.0
18
+
19
+ # Maps an HTTP failure onto the exit code an agent should act on.
20
+ _STATUS_EXIT_CODES: dict[int, ExitCode] = {
21
+ 400: ExitCode.USAGE,
22
+ 401: ExitCode.AUTH,
23
+ 403: ExitCode.AUTH,
24
+ 404: ExitCode.NOT_FOUND,
25
+ 422: ExitCode.USAGE,
26
+ }
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class ApiResponse:
31
+ """A decoded API response. `payload` is None for empty bodies."""
32
+
33
+ status_code: int
34
+ payload: Any
35
+
36
+ @property
37
+ def is_success(self) -> bool:
38
+ """True for any 2xx status."""
39
+ return 200 <= self.status_code < 300
40
+
41
+ @property
42
+ def detail(self) -> str:
43
+ """The server's `detail` string, falling back to a generic message."""
44
+ if isinstance(self.payload, Mapping):
45
+ detail = self.payload.get("detail")
46
+ if isinstance(detail, str) and detail:
47
+ return detail
48
+ if isinstance(self.payload, str) and self.payload:
49
+ return self.payload
50
+ return f"HTTP {self.status_code}"
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class Probe:
55
+ """A health check that never raises: transport failures are returned as data."""
56
+
57
+ reachable: bool
58
+ status_code: int | None
59
+ detail: str
60
+ payload: Any = None
61
+
62
+ @property
63
+ def is_success(self) -> bool:
64
+ """True when the server answered with a 2xx."""
65
+ return self.reachable and self.status_code is not None and 200 <= self.status_code < 300
66
+
67
+ @property
68
+ def is_unsupported(self) -> bool:
69
+ """True when the endpoint does not exist, i.e. the server predates it."""
70
+ return self.status_code == 404
71
+
72
+ @property
73
+ def rejected_credential(self) -> bool:
74
+ """True when the server refused the credential or its scopes."""
75
+ return self.status_code in (401, 403)
76
+
77
+
78
+ class QuaestorClient:
79
+ """Thin authenticated wrapper over the Quaestor REST API."""
80
+
81
+ def __init__(
82
+ self,
83
+ base_url: str,
84
+ api_key: str | None = None,
85
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
86
+ ) -> None:
87
+ self.base_url = base_url.rstrip("/")
88
+ if api_key is not None:
89
+ assert_safe_for_credential(self.base_url)
90
+ self._api_key = api_key
91
+ self._timeout = timeout
92
+
93
+ def url_for(self, path: str) -> str:
94
+ """Absolute URL for an API path such as `/streaks`."""
95
+ return f"{self.base_url}{API_PREFIX}{path}"
96
+
97
+ def get(
98
+ self,
99
+ path: str,
100
+ params: Mapping[str, Any] | None = None,
101
+ *,
102
+ check: bool = True,
103
+ ) -> ApiResponse:
104
+ """GET an API path; `check=False` returns failures instead of raising."""
105
+ return self.request("GET", path, params=params, check=check)
106
+
107
+ def post(
108
+ self,
109
+ path: str,
110
+ body: Mapping[str, Any] | None = None,
111
+ *,
112
+ check: bool = True,
113
+ ) -> ApiResponse:
114
+ """POST a JSON body to an API path."""
115
+ return self.request("POST", path, body=body, check=check)
116
+
117
+ def probe(self, path: str, params: Mapping[str, Any] | None = None) -> Probe:
118
+ """GET a path for diagnostics, reporting any failure instead of raising."""
119
+ try:
120
+ response = self.request("GET", path, params=params, check=False)
121
+ except CliError as exc:
122
+ return Probe(reachable=False, status_code=None, detail=exc.message)
123
+ return Probe(
124
+ reachable=True,
125
+ status_code=response.status_code,
126
+ detail="ok" if response.is_success else response.detail,
127
+ payload=response.payload,
128
+ )
129
+
130
+ def request(
131
+ self,
132
+ method: str,
133
+ path: str,
134
+ *,
135
+ params: Mapping[str, Any] | None = None,
136
+ body: Mapping[str, Any] | None = None,
137
+ check: bool = True,
138
+ ) -> ApiResponse:
139
+ """Send one request, mapping transport and HTTP failures to CliError."""
140
+ import httpx # Lazy: keeps `qst --help` fast.
141
+
142
+ url = self.url_for(path)
143
+ try:
144
+ raw = httpx.request(
145
+ method,
146
+ url,
147
+ params=_clean_params(params),
148
+ json=dict(body) if body is not None else None,
149
+ headers=self._headers(),
150
+ timeout=self._timeout,
151
+ )
152
+ except httpx.HTTPError as exc:
153
+ raise CliError(f"Cannot reach {url}: {exc}", ExitCode.SERVER) from exc
154
+
155
+ response = ApiResponse(status_code=raw.status_code, payload=_decode(raw))
156
+ if check and not response.is_success:
157
+ raise CliError(response.detail, _exit_code_for(response.status_code))
158
+ return response
159
+
160
+ def _headers(self) -> dict[str, str]:
161
+ """Build request headers, including bearer auth when a key is present."""
162
+ headers = {"Accept": "application/json"}
163
+ if self._api_key:
164
+ headers["Authorization"] = f"Bearer {self._api_key}"
165
+ return headers
166
+
167
+
168
+ def _exit_code_for(status_code: int) -> ExitCode:
169
+ """Translate an HTTP status into the CLI's exit-code contract."""
170
+ return _STATUS_EXIT_CODES.get(status_code, ExitCode.SERVER)
171
+
172
+
173
+ def _decode(raw: Any) -> Any:
174
+ """Decode a JSON body, falling back to text for non-JSON responses."""
175
+ if not raw.content:
176
+ return None
177
+ try:
178
+ return raw.json()
179
+ except ValueError:
180
+ return raw.text
181
+
182
+
183
+ def _clean_params(params: Mapping[str, Any] | None) -> dict[str, Any] | None:
184
+ """Drop None-valued query params so the server applies its own defaults."""
185
+ if not params:
186
+ return None
187
+ return {key: value for key, value in params.items() if value is not None}
@@ -0,0 +1 @@
1
+ """Command implementations, one module per command group."""
@@ -0,0 +1,152 @@
1
+ """`qst auth` — store, inspect and remove the API credential."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ import typer
8
+
9
+ from quaestor_cli import api
10
+ from quaestor_cli.config import (
11
+ ENV_API_KEY,
12
+ Credential,
13
+ credentials_path,
14
+ delete_credential,
15
+ key_label,
16
+ save_credential,
17
+ )
18
+ from quaestor_cli.errors import CliError, ExitCode, usage_error
19
+ from quaestor_cli.output import emit, emit_json, format_scopes, warn
20
+ from quaestor_cli.runtime import CliState, JsonFlag, cli_state, execute
21
+
22
+ app = typer.Typer(help="Manage the stored API credential.", no_args_is_help=True)
23
+
24
+ _LEGACY_SERVER_NOTE = "unknown (this server has no /auth/whoami)"
25
+
26
+
27
+ @app.command("login")
28
+ def login(ctx: typer.Context, json_output: JsonFlag = False) -> None:
29
+ """Read an API key from stdin and store it, so it never lands in shell history."""
30
+ state = cli_state(ctx, json_output)
31
+ execute(lambda: _login(state))
32
+
33
+
34
+ @app.command("whoami")
35
+ def whoami(ctx: typer.Context, json_output: JsonFlag = False) -> None:
36
+ """Show which credential is in use and whether the API accepts it."""
37
+ state = cli_state(ctx, json_output)
38
+ execute(lambda: _whoami(state))
39
+
40
+
41
+ @app.command("logout")
42
+ def logout(ctx: typer.Context, json_output: JsonFlag = False) -> None:
43
+ """Delete the stored credential file."""
44
+ state = cli_state(ctx, json_output)
45
+ execute(lambda: _logout(state))
46
+
47
+
48
+ def _login(state: CliState) -> None:
49
+ """Persist the key piped on stdin with 0600 permissions."""
50
+ if sys.stdin.isatty():
51
+ warn("Paste the API key and press Enter:")
52
+ key = _read_key_from_stdin()
53
+ path = save_credential(key)
54
+
55
+ label = key_label(key)
56
+ if state.json_output:
57
+ emit_json({"stored": str(path), "key_label": label, "mode": "0600"})
58
+ return
59
+ emit(f"Stored credential {label} in {path} (mode 0600).")
60
+
61
+
62
+ def _whoami(state: CliState) -> None:
63
+ """Report the credential in use plus the identity and scopes the server grants."""
64
+ credential = state.config.require_credential()
65
+ lookup = api.fetch_identity(state.client())
66
+ if lookup.is_unsupported:
67
+ _whoami_without_scopes(state, credential)
68
+ return
69
+ identity = lookup.identity
70
+ if identity is None:
71
+ code = ExitCode.AUTH if lookup.probe.rejected_credential else ExitCode.SERVER
72
+ raise CliError(lookup.probe.detail, code)
73
+
74
+ if state.json_output:
75
+ emit_json(
76
+ {
77
+ "base_url": state.config.base_url,
78
+ "credential_source": credential.source,
79
+ "key_label": credential.label,
80
+ "authenticated": True,
81
+ "user": identity.auth_user_id,
82
+ "auth_method": identity.auth_method,
83
+ "api_key_id": identity.api_key_id,
84
+ "scopes": list(identity.scopes),
85
+ }
86
+ )
87
+ return
88
+ emit(
89
+ f"base url: {state.config.base_url}",
90
+ f"credential: {credential.source} ({credential.label})",
91
+ "status: authenticated",
92
+ f"user: {identity.auth_user_id}",
93
+ f"method: {identity.auth_method}",
94
+ f"key id: {identity.api_key_id or '-'}",
95
+ f"scopes: {format_scopes(identity.scopes)}",
96
+ )
97
+
98
+
99
+ def _whoami_without_scopes(state: CliState, credential: Credential) -> None:
100
+ """Fall back to a plain authenticated read when the server has no whoami route."""
101
+ probe = state.client().probe(api.STREAKS_PATH, {"limit": 1})
102
+ if not probe.is_success:
103
+ code = ExitCode.AUTH if probe.rejected_credential else ExitCode.SERVER
104
+ raise CliError(probe.detail, code)
105
+
106
+ if state.json_output:
107
+ emit_json(
108
+ {
109
+ "base_url": state.config.base_url,
110
+ "credential_source": credential.source,
111
+ "key_label": credential.label,
112
+ "authenticated": True,
113
+ "user": None,
114
+ "auth_method": None,
115
+ "api_key_id": None,
116
+ "scopes": None,
117
+ }
118
+ )
119
+ return
120
+ emit(
121
+ f"base url: {state.config.base_url}",
122
+ f"credential: {credential.source} ({credential.label})",
123
+ "status: authenticated",
124
+ f"user: {_LEGACY_SERVER_NOTE}",
125
+ f"scopes: {_LEGACY_SERVER_NOTE}",
126
+ )
127
+
128
+
129
+ def _logout(state: CliState) -> None:
130
+ """Remove the credential file and warn if the environment still holds a key."""
131
+ path = credentials_path()
132
+ removed = delete_credential()
133
+ env_key_present = state.config.credential is not None and state.config.credential.source == "env"
134
+
135
+ if state.json_output:
136
+ emit_json({"removed": removed, "path": str(path), "env_key_still_set": env_key_present})
137
+ elif removed:
138
+ emit(f"Removed {path}.")
139
+ else:
140
+ emit(f"No stored credential at {path}.")
141
+
142
+ if env_key_present:
143
+ warn(f"{ENV_API_KEY} is still set in the environment and takes precedence.")
144
+
145
+
146
+ def _read_key_from_stdin() -> str:
147
+ """Take the first non-empty line of stdin as the key."""
148
+ for line in sys.stdin.read().splitlines():
149
+ candidate = line.strip()
150
+ if candidate:
151
+ return candidate
152
+ raise usage_error("No API key on stdin. Pipe it in, e.g. 'pbpaste | qst auth login'.")