dimicheck-tui 0.1.0__tar.gz

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,106 @@
1
+ Metadata-Version: 2.4
2
+ Name: dimicheck-tui
3
+ Version: 0.1.0
4
+ Summary: DimiCheck terminal dashboard
5
+ Author: DimiCheck
6
+ Project-URL: Homepage, https://dimicheck.com
7
+ Project-URL: Repository, https://github.com/hjun1052/dimicheck4
8
+ Keywords: dimicheck,tui,textual,oauth,school
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: End Users/Desktop
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Education
17
+ Requires-Python: >=3.11
18
+ Description-Content-Type: text/markdown
19
+ Requires-Dist: keyring>=25.0.0
20
+ Requires-Dist: requests==2.31.0
21
+ Requires-Dist: textual>=0.89.0
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
24
+
25
+ # DimiCheck TUI
26
+
27
+ DimiCheck TUI is a terminal dashboard for DimiCheck students. It uses the public OAuth PKCE flow and calls only the student app API surface for v1.
28
+
29
+ ## Install
30
+
31
+ Use `pipx` for the public install path:
32
+
33
+ ```bash
34
+ pipx install dimicheck-tui
35
+ dimicheck
36
+ ```
37
+
38
+ For local development from this checkout:
39
+
40
+ ```bash
41
+ cd packages/dimicheck-tui
42
+ uv run dimicheck --help
43
+ uv run dimicheck
44
+ ```
45
+
46
+ Set `DIMICHECK_BASE_URL` when testing against a local or staging server:
47
+
48
+ ```bash
49
+ cd packages/dimicheck-tui
50
+ DIMICHECK_BASE_URL=http://127.0.0.1:5000 uv run dimicheck
51
+ ```
52
+
53
+ ## Authentication
54
+
55
+ The TUI never asks for a password in the terminal. On first launch it opens the browser, signs in through DimiCheck OAuth, and stores tokens through the OS credential store.
56
+
57
+ - macOS: Keychain
58
+ - Windows: Credential Manager
59
+ - Linux: Secret Service compatible keyring
60
+
61
+ If the OS keyring is unavailable, the app fails closed by default. For controlled environments you can explicitly allow a local token file:
62
+
63
+ ```bash
64
+ dimicheck --allow-plaintext-token
65
+ ```
66
+
67
+ That fallback writes a `0600` permission file. Do not use it on shared machines.
68
+
69
+ ## Commands
70
+
71
+ ```bash
72
+ dimicheck # open the TUI dashboard
73
+ dimicheck login # browser OAuth login
74
+ dimicheck logout # revoke refresh token and delete local tokens
75
+ dimicheck status get # print current status
76
+ dimicheck status set toilet
77
+ dimicheck status set etc --reason "상담"
78
+ dimicheck schoollife # print today's timetable and meal
79
+ ```
80
+
81
+ TUI shortcuts:
82
+
83
+ - `S`: change status
84
+ - `R`: refresh dashboard
85
+ - `L`: logout
86
+ - `Q`: quit
87
+
88
+ ## v1 Scope
89
+
90
+ The public v1 supports:
91
+
92
+ - current student status read/write
93
+ - today's schoollife summary
94
+ - OAuth PKCE login/logout
95
+
96
+ It intentionally does not expose routine editing, teacher/admin tools, browser session cookies, remember tokens, or `/api/mcp/state`.
97
+
98
+ ## Server Notes
99
+
100
+ The server seeds a dedicated public OAuth client:
101
+
102
+ - client id: `dimicheck-tui-public`
103
+ - redirect URI ports: `45831` through `45835`
104
+ - scopes: `openid basic student_info status.read status.write`
105
+
106
+ Server deployments should happen from the repository root. This package is intentionally separate so the public TUI release does not include Flask, database, or Google Sheets dependencies.
@@ -0,0 +1,82 @@
1
+ # DimiCheck TUI
2
+
3
+ DimiCheck TUI is a terminal dashboard for DimiCheck students. It uses the public OAuth PKCE flow and calls only the student app API surface for v1.
4
+
5
+ ## Install
6
+
7
+ Use `pipx` for the public install path:
8
+
9
+ ```bash
10
+ pipx install dimicheck-tui
11
+ dimicheck
12
+ ```
13
+
14
+ For local development from this checkout:
15
+
16
+ ```bash
17
+ cd packages/dimicheck-tui
18
+ uv run dimicheck --help
19
+ uv run dimicheck
20
+ ```
21
+
22
+ Set `DIMICHECK_BASE_URL` when testing against a local or staging server:
23
+
24
+ ```bash
25
+ cd packages/dimicheck-tui
26
+ DIMICHECK_BASE_URL=http://127.0.0.1:5000 uv run dimicheck
27
+ ```
28
+
29
+ ## Authentication
30
+
31
+ The TUI never asks for a password in the terminal. On first launch it opens the browser, signs in through DimiCheck OAuth, and stores tokens through the OS credential store.
32
+
33
+ - macOS: Keychain
34
+ - Windows: Credential Manager
35
+ - Linux: Secret Service compatible keyring
36
+
37
+ If the OS keyring is unavailable, the app fails closed by default. For controlled environments you can explicitly allow a local token file:
38
+
39
+ ```bash
40
+ dimicheck --allow-plaintext-token
41
+ ```
42
+
43
+ That fallback writes a `0600` permission file. Do not use it on shared machines.
44
+
45
+ ## Commands
46
+
47
+ ```bash
48
+ dimicheck # open the TUI dashboard
49
+ dimicheck login # browser OAuth login
50
+ dimicheck logout # revoke refresh token and delete local tokens
51
+ dimicheck status get # print current status
52
+ dimicheck status set toilet
53
+ dimicheck status set etc --reason "상담"
54
+ dimicheck schoollife # print today's timetable and meal
55
+ ```
56
+
57
+ TUI shortcuts:
58
+
59
+ - `S`: change status
60
+ - `R`: refresh dashboard
61
+ - `L`: logout
62
+ - `Q`: quit
63
+
64
+ ## v1 Scope
65
+
66
+ The public v1 supports:
67
+
68
+ - current student status read/write
69
+ - today's schoollife summary
70
+ - OAuth PKCE login/logout
71
+
72
+ It intentionally does not expose routine editing, teacher/admin tools, browser session cookies, remember tokens, or `/api/mcp/state`.
73
+
74
+ ## Server Notes
75
+
76
+ The server seeds a dedicated public OAuth client:
77
+
78
+ - client id: `dimicheck-tui-public`
79
+ - redirect URI ports: `45831` through `45835`
80
+ - scopes: `openid basic student_info status.read status.write`
81
+
82
+ Server deployments should happen from the repository root. This package is intentionally separate so the public TUI release does not include Flask, database, or Google Sheets dependencies.
@@ -0,0 +1,5 @@
1
+ """DimiCheck public terminal app."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.1.0"
@@ -0,0 +1,92 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Any
5
+
6
+ import requests
7
+
8
+ from .auth import AuthError, OAuthClient
9
+ from .config import TuiConfig
10
+ from .token_store import TokenSet, TokenStore
11
+
12
+
13
+ class ApiError(RuntimeError):
14
+ pass
15
+
16
+
17
+ class DimiCheckAPI:
18
+ def __init__(
19
+ self,
20
+ config: TuiConfig,
21
+ store: TokenStore,
22
+ *,
23
+ auth_client: OAuthClient | None = None,
24
+ session: requests.Session | None = None,
25
+ ) -> None:
26
+ self.config = config
27
+ self.store = store
28
+ self.session = session or requests.Session()
29
+ self.auth_client = auth_client or OAuthClient(config, session=self.session)
30
+
31
+ def ensure_tokens(self) -> TokenSet:
32
+ tokens = self.store.load()
33
+ if tokens is None:
34
+ self.store.ensure_can_save()
35
+ tokens = self.auth_client.login()
36
+ self.store.save(tokens)
37
+ return tokens
38
+ if tokens.expires_at <= time.time():
39
+ tokens = self.auth_client.refresh(tokens.refresh_token)
40
+ self.store.save(tokens)
41
+ return tokens
42
+
43
+ def logout(self) -> None:
44
+ tokens = self.store.load()
45
+ if tokens:
46
+ self.auth_client.revoke(tokens.refresh_token)
47
+ self.store.delete()
48
+
49
+ def get_status(self) -> dict[str, Any]:
50
+ return self._request_json("GET", "/api/app/status/me")
51
+
52
+ def set_status(self, status_code: str, *, reason: str | None = None) -> dict[str, Any]:
53
+ payload: dict[str, Any] = {"statusCode": status_code}
54
+ if reason:
55
+ payload["reason"] = reason
56
+ return self._request_json("PUT", "/api/app/status", json=payload)
57
+
58
+ def get_schoollife(self) -> dict[str, Any]:
59
+ return self._request_json("GET", "/api/app/schoollife/today")
60
+
61
+ def _request_json(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
62
+ tokens = self.ensure_tokens()
63
+ response = self.session.request(
64
+ method,
65
+ f"{self.config.base_url}{path}",
66
+ headers={"Authorization": f"Bearer {tokens.access_token}"},
67
+ timeout=15,
68
+ **kwargs,
69
+ )
70
+ if response.status_code == 401 and tokens.refresh_token:
71
+ try:
72
+ tokens = self.auth_client.refresh(tokens.refresh_token)
73
+ except AuthError as exc:
74
+ self.store.delete()
75
+ raise ApiError("로그인이 만료되었습니다. 다시 로그인해 주세요.") from exc
76
+ self.store.save(tokens)
77
+ response = self.session.request(
78
+ method,
79
+ f"{self.config.base_url}{path}",
80
+ headers={"Authorization": f"Bearer {tokens.access_token}"},
81
+ timeout=15,
82
+ **kwargs,
83
+ )
84
+ if response.status_code >= 400:
85
+ message = response.text
86
+ try:
87
+ payload = response.json()
88
+ message = payload.get("error") or payload.get("message") or message
89
+ except ValueError:
90
+ pass
91
+ raise ApiError(f"HTTP {response.status_code}: {message}")
92
+ return response.json()
@@ -0,0 +1,193 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from typing import Any
5
+
6
+ from textual.app import App, ComposeResult
7
+ from textual.containers import Container, Horizontal, Vertical
8
+ from textual.screen import ModalScreen
9
+ from textual.widgets import Button, Footer, Header, Input, Label, RadioButton, RadioSet, Static
10
+
11
+ from .api import ApiError, DimiCheckAPI
12
+
13
+
14
+ STATUS_CHOICES = [
15
+ ("section", "교실"),
16
+ ("toilet", "화장실(물)"),
17
+ ("hallway", "복도"),
18
+ ("club", "동아리"),
19
+ ("afterschool", "방과후"),
20
+ ("project", "프로젝트"),
21
+ ("early", "조기입실"),
22
+ ("etc", "기타"),
23
+ ("absence", "결석(조퇴)"),
24
+ ]
25
+
26
+
27
+ class StatusModal(ModalScreen[tuple[str, str | None] | None]):
28
+ CSS = """
29
+ StatusModal {
30
+ align: center middle;
31
+ }
32
+ #status-dialog {
33
+ width: 56;
34
+ padding: 1 2;
35
+ border: thick $accent;
36
+ background: $surface;
37
+ }
38
+ #reason-input {
39
+ margin-top: 1;
40
+ }
41
+ #status-actions {
42
+ margin-top: 1;
43
+ height: auto;
44
+ }
45
+ """
46
+
47
+ def compose(self) -> ComposeResult:
48
+ with Vertical(id="status-dialog"):
49
+ yield Label("상태 변경")
50
+ with RadioSet(id="status-radio"):
51
+ for code, label in STATUS_CHOICES:
52
+ yield RadioButton(label, id=f"status-{code}")
53
+ yield Input(placeholder="기타 사유", id="reason-input")
54
+ with Horizontal(id="status-actions"):
55
+ yield Button("적용", id="apply", variant="primary")
56
+ yield Button("취소", id="cancel")
57
+
58
+ def on_mount(self) -> None:
59
+ self.query_one("#status-section", RadioButton).value = True
60
+
61
+ def on_button_pressed(self, event: Button.Pressed) -> None:
62
+ if event.button.id == "cancel":
63
+ self.dismiss(None)
64
+ return
65
+ selected = self.query_one(RadioSet).pressed_button
66
+ status_code = "section"
67
+ if selected and selected.id:
68
+ status_code = selected.id.removeprefix("status-")
69
+ reason = self.query_one("#reason-input", Input).value.strip() or None
70
+ self.dismiss((status_code, reason))
71
+
72
+
73
+ class DashboardApp(App[None]):
74
+ TITLE = "DimiCheck"
75
+ BINDINGS = [
76
+ ("s", "change_status", "상태 변경"),
77
+ ("r", "refresh", "새로고침"),
78
+ ("l", "logout", "로그아웃"),
79
+ ("q", "quit", "종료"),
80
+ ]
81
+ CSS = """
82
+ Screen {
83
+ background: #08111f;
84
+ }
85
+ #page {
86
+ padding: 1 2;
87
+ }
88
+ .card {
89
+ border: round #5aa9ff;
90
+ padding: 1 2;
91
+ margin-bottom: 1;
92
+ background: #0f1f35;
93
+ }
94
+ #hero {
95
+ border: tall #77d970;
96
+ }
97
+ #status-label {
98
+ text-style: bold;
99
+ color: #77d970;
100
+ }
101
+ .muted {
102
+ color: #91a4bd;
103
+ }
104
+ #error {
105
+ color: #ff7b7b;
106
+ }
107
+ """
108
+
109
+ def __init__(self, api: DimiCheckAPI) -> None:
110
+ super().__init__()
111
+ self.api = api
112
+
113
+ def compose(self) -> ComposeResult:
114
+ yield Header()
115
+ with Container(id="page"):
116
+ yield Static("불러오는 중...", id="hero", classes="card")
117
+ yield Static("", id="status-card", classes="card")
118
+ yield Static("", id="schoollife-card", classes="card")
119
+ yield Static("", id="error")
120
+ yield Footer()
121
+
122
+ def on_mount(self) -> None:
123
+ self.refresh_dashboard()
124
+
125
+ def action_refresh(self) -> None:
126
+ self.refresh_dashboard()
127
+
128
+ def action_change_status(self) -> None:
129
+ def apply_status(result: tuple[str, str | None] | None) -> None:
130
+ if result is None:
131
+ return
132
+ code, reason = result
133
+ try:
134
+ self.api.set_status(code, reason=reason)
135
+ self.refresh_dashboard()
136
+ except Exception as exc: # pylint: disable=broad-except
137
+ self._show_error(str(exc))
138
+
139
+ self.push_screen(StatusModal(), apply_status)
140
+
141
+ def action_logout(self) -> None:
142
+ try:
143
+ self.api.logout()
144
+ finally:
145
+ self.exit()
146
+
147
+ def refresh_dashboard(self) -> None:
148
+ try:
149
+ status = self.api.get_status()
150
+ schoollife = self.api.get_schoollife()
151
+ except (ApiError, Exception) as exc: # pylint: disable=broad-except
152
+ self._show_error(str(exc))
153
+ return
154
+ self._show_error("")
155
+ self.query_one("#hero", Static).update(self._render_hero(status))
156
+ self.query_one("#status-card", Static).update(self._render_status(status))
157
+ self.query_one("#schoollife-card", Static).update(self._render_schoollife(schoollife))
158
+
159
+ def _render_hero(self, status: dict[str, Any]) -> str:
160
+ today = datetime.now().strftime("%Y년 %m월 %d일")
161
+ identity = f"{status.get('grade')}학년 {status.get('section')}반 {status.get('number')}번"
162
+ return f"DimiCheck\n{today} · {identity}"
163
+
164
+ def _render_status(self, status: dict[str, Any]) -> str:
165
+ label = status.get("statusLabel") or status.get("statusCode") or "-"
166
+ reason = status.get("reason")
167
+ favorite = status.get("favoriteStatus") or "없음"
168
+ lines = ["지금 상태", f"[b green]{label}[/]"]
169
+ if reason:
170
+ lines.append(f"사유 {reason}")
171
+ lines.append(f"즐겨찾기 {favorite}")
172
+ return "\n".join(lines)
173
+
174
+ def _render_schoollife(self, payload: dict[str, Any]) -> str:
175
+ timetable = payload.get("timetable") or {}
176
+ meal = payload.get("meal") or {}
177
+ lessons = timetable.get("lessons") or []
178
+ lines = ["오늘 학교생활"]
179
+ if lessons:
180
+ for lesson in lessons[:8]:
181
+ period = lesson.get("period") or lesson.get("periodName") or "-"
182
+ subject = lesson.get("subject") or lesson.get("name") or "-"
183
+ lines.append(f"{period}교시 {subject}")
184
+ else:
185
+ lines.append(str(timetable.get("message") or "시간표 정보가 없습니다."))
186
+ lunch = meal.get("lunch") or "급식 정보가 없습니다."
187
+ lines.append("")
188
+ lines.append("급식")
189
+ lines.append(str(lunch).replace("\n", " · "))
190
+ return "\n".join(lines)
191
+
192
+ def _show_error(self, message: str) -> None:
193
+ self.query_one("#error", Static).update(message)
@@ -0,0 +1,158 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+ import secrets
6
+ import threading
7
+ import time
8
+ import webbrowser
9
+ from dataclasses import dataclass
10
+ from http.server import BaseHTTPRequestHandler, HTTPServer
11
+ from typing import Any
12
+ from urllib.parse import parse_qs, urlencode, urlparse
13
+
14
+ import requests
15
+
16
+ from .config import CALLBACK_PATH, TuiConfig
17
+ from .token_store import TokenSet
18
+
19
+
20
+ class AuthError(RuntimeError):
21
+ pass
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class CallbackResult:
26
+ code: str
27
+ state: str
28
+
29
+
30
+ def build_pkce_pair() -> tuple[str, str]:
31
+ verifier = base64.urlsafe_b64encode(secrets.token_bytes(48)).rstrip(b"=").decode("ascii")
32
+ digest = hashlib.sha256(verifier.encode("ascii")).digest()
33
+ challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
34
+ return verifier, challenge
35
+
36
+
37
+ def _make_callback_handler(result: dict[str, Any]):
38
+ class CallbackHandler(BaseHTTPRequestHandler):
39
+ def do_GET(self) -> None: # noqa: N802 - stdlib callback name
40
+ parsed = urlparse(self.path)
41
+ if parsed.path != CALLBACK_PATH:
42
+ self.send_response(404)
43
+ self.end_headers()
44
+ return
45
+ params = parse_qs(parsed.query)
46
+ error = params.get("error", [""])[0]
47
+ if error:
48
+ result["error"] = error
49
+ else:
50
+ result["code"] = params.get("code", [""])[0]
51
+ result["state"] = params.get("state", [""])[0]
52
+ self.send_response(200)
53
+ self.send_header("Content-Type", "text/html; charset=utf-8")
54
+ self.end_headers()
55
+ self.wfile.write(
56
+ "<!doctype html><meta charset='utf-8'><title>DimiCheck</title>"
57
+ "<body>로그인이 완료되었습니다. 이 창을 닫고 터미널로 돌아가세요.</body>".encode("utf-8")
58
+ )
59
+
60
+ def log_message(self, _format: str, *_args: Any) -> None:
61
+ return
62
+
63
+ return CallbackHandler
64
+
65
+
66
+ class OAuthClient:
67
+ def __init__(self, config: TuiConfig, *, session: requests.Session | None = None) -> None:
68
+ self.config = config
69
+ self.session = session or requests.Session()
70
+
71
+ def login(self, *, open_browser: bool = True, timeout_seconds: int = 180) -> TokenSet:
72
+ server, port, result = self._start_callback_server()
73
+ verifier, challenge = build_pkce_pair()
74
+ state = secrets.token_urlsafe(32)
75
+ authorize_url = self._authorize_url(port=port, state=state, challenge=challenge)
76
+
77
+ thread = threading.Thread(target=server.handle_request, daemon=True)
78
+ thread.start()
79
+ try:
80
+ if open_browser and not webbrowser.open(authorize_url):
81
+ raise AuthError(f"브라우저를 열 수 없습니다. 다음 주소를 직접 여세요: {authorize_url}")
82
+ deadline = time.monotonic() + timeout_seconds
83
+ while thread.is_alive() and time.monotonic() < deadline:
84
+ thread.join(timeout=0.1)
85
+ if thread.is_alive():
86
+ raise AuthError("브라우저 로그인 대기 시간이 초과되었습니다.")
87
+ if result.get("error"):
88
+ raise AuthError(f"OAuth error: {result['error']}")
89
+ callback = CallbackResult(code=str(result.get("code") or ""), state=str(result.get("state") or ""))
90
+ if not callback.code:
91
+ raise AuthError("OAuth callback did not include an authorization code")
92
+ if callback.state != state:
93
+ raise AuthError("OAuth state mismatch")
94
+ return self.exchange_code(code=callback.code, verifier=verifier, redirect_uri=self.config.redirect_uri(port))
95
+ finally:
96
+ server.server_close()
97
+
98
+ def exchange_code(self, *, code: str, verifier: str, redirect_uri: str) -> TokenSet:
99
+ response = self.session.post(
100
+ f"{self.config.base_url}/oauth/token",
101
+ data={
102
+ "grant_type": "authorization_code",
103
+ "client_id": self.config.client_id,
104
+ "code": code,
105
+ "redirect_uri": redirect_uri,
106
+ "code_verifier": verifier,
107
+ },
108
+ timeout=15,
109
+ )
110
+ if response.status_code != 200:
111
+ raise AuthError(f"토큰 교환 실패: HTTP {response.status_code}")
112
+ return TokenSet.from_oauth_response(response.json(), now=time.time())
113
+
114
+ def refresh(self, refresh_token: str) -> TokenSet:
115
+ response = self.session.post(
116
+ f"{self.config.base_url}/oauth/token",
117
+ data={
118
+ "grant_type": "refresh_token",
119
+ "client_id": self.config.client_id,
120
+ "refresh_token": refresh_token,
121
+ },
122
+ timeout=15,
123
+ )
124
+ if response.status_code != 200:
125
+ raise AuthError(f"토큰 갱신 실패: HTTP {response.status_code}")
126
+ return TokenSet.from_oauth_response(response.json(), now=time.time())
127
+
128
+ def revoke(self, token: str) -> None:
129
+ self.session.post(
130
+ f"{self.config.base_url}/oauth/revoke",
131
+ data={"client_id": self.config.client_id, "token": token},
132
+ timeout=15,
133
+ )
134
+
135
+ def _authorize_url(self, *, port: int, state: str, challenge: str) -> str:
136
+ query = urlencode(
137
+ {
138
+ "response_type": "code",
139
+ "client_id": self.config.client_id,
140
+ "redirect_uri": self.config.redirect_uri(port),
141
+ "scope": self.config.scopes,
142
+ "state": state,
143
+ "code_challenge": challenge,
144
+ "code_challenge_method": "S256",
145
+ }
146
+ )
147
+ return f"{self.config.base_url}/oauth/authorize?{query}"
148
+
149
+ def _start_callback_server(self) -> tuple[HTTPServer, int, dict[str, Any]]:
150
+ result: dict[str, Any] = {}
151
+ handler = _make_callback_handler(result)
152
+ last_error: OSError | None = None
153
+ for port in self.config.redirect_ports:
154
+ try:
155
+ return HTTPServer(("127.0.0.1", port), handler), port, result
156
+ except OSError as exc:
157
+ last_error = exc
158
+ raise AuthError("사용 가능한 OAuth callback 포트를 찾지 못했습니다.") from last_error
@@ -0,0 +1,99 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+
7
+ from . import __version__
8
+ from .api import ApiError, DimiCheckAPI
9
+ from .config import TuiConfig
10
+ from .token_store import TokenStore, TokenStorageError
11
+
12
+
13
+ def build_api(*, allow_plaintext_token: bool) -> DimiCheckAPI:
14
+ config = TuiConfig.from_env()
15
+ store = TokenStore(account=config.base_url, allow_plaintext_file=allow_plaintext_token)
16
+ return DimiCheckAPI(config, store)
17
+
18
+
19
+ def main(argv: list[str] | None = None) -> int:
20
+ parser = argparse.ArgumentParser(prog="dimicheck", description="DimiCheck terminal dashboard")
21
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
22
+ parser.add_argument("--allow-plaintext-token", action="store_true", help="OS keyring 실패 시 권한 제한 파일 저장 허용")
23
+ subparsers = parser.add_subparsers(dest="command")
24
+
25
+ subparsers.add_parser("login", help="브라우저 OAuth 로그인")
26
+ subparsers.add_parser("logout", help="토큰 폐기 후 로그아웃")
27
+ subparsers.add_parser("schoollife", help="오늘 학교생활 출력")
28
+ status_parser = subparsers.add_parser("status", help="내 상태 조회/변경")
29
+ status_subparsers = status_parser.add_subparsers(dest="status_command")
30
+ status_subparsers.add_parser("get", help="현재 상태 조회")
31
+ set_parser = status_subparsers.add_parser("set", help="상태 변경")
32
+ set_parser.add_argument("status_code")
33
+ set_parser.add_argument("--reason")
34
+
35
+ args = parser.parse_args(argv)
36
+ api = build_api(allow_plaintext_token=args.allow_plaintext_token)
37
+
38
+ try:
39
+ if args.command == "login":
40
+ api.ensure_tokens()
41
+ print("로그인 완료")
42
+ return 0
43
+ if args.command == "logout":
44
+ api.logout()
45
+ print("로그아웃 완료")
46
+ return 0
47
+ if args.command == "schoollife":
48
+ print(format_schoollife(api.get_schoollife()))
49
+ return 0
50
+ if args.command == "status":
51
+ if args.status_command == "set":
52
+ payload = api.set_status(args.status_code, reason=args.reason)
53
+ print(format_status(payload.get("status") or payload))
54
+ return 0
55
+ print(format_status(api.get_status()))
56
+ return 0
57
+ from .app import DashboardApp
58
+
59
+ DashboardApp(api).run()
60
+ return 0
61
+ except (ApiError, TokenStorageError, Exception) as exc: # pylint: disable=broad-except
62
+ print(f"dimicheck: {exc}", file=sys.stderr)
63
+ return 1
64
+
65
+
66
+ def format_status(payload: dict) -> str:
67
+ label = payload.get("statusLabel") or payload.get("statusCode") or "-"
68
+ reason = payload.get("reason")
69
+ identity = f"{payload.get('grade')}학년 {payload.get('section')}반 {payload.get('number')}번"
70
+ lines = [f"{identity}", f"상태: {label}"]
71
+ if reason:
72
+ lines.append(f"사유: {reason}")
73
+ return "\n".join(lines)
74
+
75
+
76
+ def format_schoollife(payload: dict) -> str:
77
+ timetable = payload.get("timetable") or {}
78
+ meal = payload.get("meal") or {}
79
+ lines = [f"오늘 학교생활 ({payload.get('date') or '-'})"]
80
+ lessons = timetable.get("lessons") or []
81
+ if lessons:
82
+ for lesson in lessons:
83
+ period = lesson.get("period") or lesson.get("periodName") or "-"
84
+ subject = lesson.get("subject") or lesson.get("name") or "-"
85
+ lines.append(f"{period}교시 {subject}")
86
+ else:
87
+ lines.append(str(timetable.get("message") or "시간표 정보가 없습니다."))
88
+ lines.append("")
89
+ lines.append("급식")
90
+ lines.append(str(meal.get("lunch") or "급식 정보가 없습니다.").replace("\n", " · "))
91
+ errors = payload.get("errors") or {}
92
+ if errors:
93
+ lines.append("")
94
+ lines.append(f"일부 정보를 불러오지 못했습니다: {json.dumps(errors, ensure_ascii=False)}")
95
+ return "\n".join(lines)
96
+
97
+
98
+ if __name__ == "__main__":
99
+ raise SystemExit(main())
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+
6
+
7
+ CLIENT_ID = "dimicheck-tui-public"
8
+ SCOPES = "openid basic student_info status.read status.write"
9
+ REDIRECT_PORTS = (45831, 45832, 45833, 45834, 45835)
10
+ CALLBACK_PATH = "/oauth-callback"
11
+ DEFAULT_BASE_URL = "https://dimicheck.com"
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class TuiConfig:
16
+ base_url: str
17
+ client_id: str = CLIENT_ID
18
+ scopes: str = SCOPES
19
+ redirect_ports: tuple[int, ...] = REDIRECT_PORTS
20
+
21
+ @classmethod
22
+ def from_env(cls) -> "TuiConfig":
23
+ return cls(base_url=os.getenv("DIMICHECK_BASE_URL", DEFAULT_BASE_URL).rstrip("/"))
24
+
25
+ def redirect_uri(self, port: int) -> str:
26
+ return f"http://127.0.0.1:{port}{CALLBACK_PATH}"
@@ -0,0 +1,158 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import stat
6
+ from dataclasses import asdict, dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+
11
+ SERVICE_NAME = "dimicheck-tui"
12
+
13
+
14
+ class TokenStorageError(RuntimeError):
15
+ """Raised when tokens cannot be stored safely."""
16
+
17
+
18
+ @dataclass
19
+ class TokenSet:
20
+ access_token: str
21
+ refresh_token: str
22
+ expires_at: float
23
+ scope: str = ""
24
+ token_type: str = "Bearer"
25
+
26
+ @classmethod
27
+ def from_oauth_response(cls, payload: dict[str, Any], *, now: float) -> "TokenSet":
28
+ access_token = str(payload.get("access_token") or "")
29
+ refresh_token = str(payload.get("refresh_token") or "")
30
+ if not access_token or not refresh_token:
31
+ raise ValueError("OAuth response did not include access_token and refresh_token")
32
+ expires_in = int(payload.get("expires_in") or 3600)
33
+ return cls(
34
+ access_token=access_token,
35
+ refresh_token=refresh_token,
36
+ expires_at=now + max(expires_in - 30, 1),
37
+ scope=str(payload.get("scope") or ""),
38
+ token_type=str(payload.get("token_type") or "Bearer"),
39
+ )
40
+
41
+ @classmethod
42
+ def from_json(cls, raw: str) -> "TokenSet":
43
+ payload = json.loads(raw)
44
+ return cls(
45
+ access_token=str(payload["access_token"]),
46
+ refresh_token=str(payload["refresh_token"]),
47
+ expires_at=float(payload["expires_at"]),
48
+ scope=str(payload.get("scope") or ""),
49
+ token_type=str(payload.get("token_type") or "Bearer"),
50
+ )
51
+
52
+ def to_json(self) -> str:
53
+ return json.dumps(asdict(self), ensure_ascii=False)
54
+
55
+
56
+ def default_token_file() -> Path:
57
+ override = os.getenv("DIMICHECK_TOKEN_FILE")
58
+ if override:
59
+ return Path(override).expanduser()
60
+ if os.name == "nt":
61
+ base = Path(os.getenv("APPDATA") or Path.home() / "AppData" / "Roaming")
62
+ return base / "DimiCheck" / "tokens.json"
63
+ return Path(os.getenv("XDG_CONFIG_HOME", Path.home() / ".config")) / "dimicheck" / "tokens.json"
64
+
65
+
66
+ class TokenStore:
67
+ def __init__(
68
+ self,
69
+ *,
70
+ account: str,
71
+ allow_plaintext_file: bool = False,
72
+ token_file: Path | None = None,
73
+ keyring_module: Any | None = None,
74
+ ) -> None:
75
+ self.account = account
76
+ self.allow_plaintext_file = allow_plaintext_file
77
+ self.token_file = token_file or default_token_file()
78
+ self._keyring = keyring_module
79
+
80
+ def load(self) -> TokenSet | None:
81
+ try:
82
+ raw = self._keyring_get()
83
+ except Exception:
84
+ raw = None
85
+ if raw:
86
+ return TokenSet.from_json(raw)
87
+ if not self.allow_plaintext_file or not self.token_file.exists():
88
+ return None
89
+ self._assert_private_file(self.token_file)
90
+ return TokenSet.from_json(self.token_file.read_text(encoding="utf-8"))
91
+
92
+ def ensure_can_save(self) -> None:
93
+ try:
94
+ self._keyring_set("__dimicheck_tui_probe__")
95
+ self._keyring_delete()
96
+ return
97
+ except Exception as exc:
98
+ if not self.allow_plaintext_file:
99
+ raise TokenStorageError(
100
+ "OS keyring에 토큰을 저장할 수 없습니다. Keychain/Credential Manager/Secret Service를 설정하거나 "
101
+ "--allow-plaintext-token 옵션을 명시하세요."
102
+ ) from exc
103
+ self.token_file.parent.mkdir(parents=True, exist_ok=True)
104
+ if self.token_file.exists():
105
+ self._assert_private_file(self.token_file)
106
+ else:
107
+ fd = os.open(self.token_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
108
+ os.close(fd)
109
+ self.token_file.unlink()
110
+
111
+ def save(self, tokens: TokenSet) -> None:
112
+ raw = tokens.to_json()
113
+ try:
114
+ self._keyring_set(raw)
115
+ return
116
+ except Exception as exc:
117
+ if not self.allow_plaintext_file:
118
+ raise TokenStorageError(
119
+ "OS keyring에 토큰을 저장할 수 없습니다. Keychain/Credential Manager/Secret Service를 설정하거나 "
120
+ "--allow-plaintext-token 옵션을 명시하세요."
121
+ ) from exc
122
+ self.token_file.parent.mkdir(parents=True, exist_ok=True)
123
+ fd = os.open(self.token_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
124
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
125
+ handle.write(raw)
126
+ os.chmod(self.token_file, 0o600)
127
+
128
+ def delete(self) -> None:
129
+ try:
130
+ self._keyring_delete()
131
+ except Exception:
132
+ pass
133
+ if self.token_file.exists():
134
+ self.token_file.unlink()
135
+
136
+ def _keyring_module(self) -> Any:
137
+ if self._keyring is not None:
138
+ return self._keyring
139
+ import keyring # type: ignore
140
+
141
+ return keyring
142
+
143
+ def _keyring_get(self) -> str | None:
144
+ return self._keyring_module().get_password(SERVICE_NAME, self.account)
145
+
146
+ def _keyring_set(self, raw: str) -> None:
147
+ self._keyring_module().set_password(SERVICE_NAME, self.account, raw)
148
+
149
+ def _keyring_delete(self) -> None:
150
+ self._keyring_module().delete_password(SERVICE_NAME, self.account)
151
+
152
+ @staticmethod
153
+ def _assert_private_file(path: Path) -> None:
154
+ if os.name == "nt":
155
+ return
156
+ mode = stat.S_IMODE(path.stat().st_mode)
157
+ if mode & (stat.S_IRWXG | stat.S_IRWXO):
158
+ raise TokenStorageError(f"{path} 권한이 너무 넓습니다. chmod 600 후 다시 실행하세요.")
@@ -0,0 +1,106 @@
1
+ Metadata-Version: 2.4
2
+ Name: dimicheck-tui
3
+ Version: 0.1.0
4
+ Summary: DimiCheck terminal dashboard
5
+ Author: DimiCheck
6
+ Project-URL: Homepage, https://dimicheck.com
7
+ Project-URL: Repository, https://github.com/hjun1052/dimicheck4
8
+ Keywords: dimicheck,tui,textual,oauth,school
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: End Users/Desktop
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Education
17
+ Requires-Python: >=3.11
18
+ Description-Content-Type: text/markdown
19
+ Requires-Dist: keyring>=25.0.0
20
+ Requires-Dist: requests==2.31.0
21
+ Requires-Dist: textual>=0.89.0
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
24
+
25
+ # DimiCheck TUI
26
+
27
+ DimiCheck TUI is a terminal dashboard for DimiCheck students. It uses the public OAuth PKCE flow and calls only the student app API surface for v1.
28
+
29
+ ## Install
30
+
31
+ Use `pipx` for the public install path:
32
+
33
+ ```bash
34
+ pipx install dimicheck-tui
35
+ dimicheck
36
+ ```
37
+
38
+ For local development from this checkout:
39
+
40
+ ```bash
41
+ cd packages/dimicheck-tui
42
+ uv run dimicheck --help
43
+ uv run dimicheck
44
+ ```
45
+
46
+ Set `DIMICHECK_BASE_URL` when testing against a local or staging server:
47
+
48
+ ```bash
49
+ cd packages/dimicheck-tui
50
+ DIMICHECK_BASE_URL=http://127.0.0.1:5000 uv run dimicheck
51
+ ```
52
+
53
+ ## Authentication
54
+
55
+ The TUI never asks for a password in the terminal. On first launch it opens the browser, signs in through DimiCheck OAuth, and stores tokens through the OS credential store.
56
+
57
+ - macOS: Keychain
58
+ - Windows: Credential Manager
59
+ - Linux: Secret Service compatible keyring
60
+
61
+ If the OS keyring is unavailable, the app fails closed by default. For controlled environments you can explicitly allow a local token file:
62
+
63
+ ```bash
64
+ dimicheck --allow-plaintext-token
65
+ ```
66
+
67
+ That fallback writes a `0600` permission file. Do not use it on shared machines.
68
+
69
+ ## Commands
70
+
71
+ ```bash
72
+ dimicheck # open the TUI dashboard
73
+ dimicheck login # browser OAuth login
74
+ dimicheck logout # revoke refresh token and delete local tokens
75
+ dimicheck status get # print current status
76
+ dimicheck status set toilet
77
+ dimicheck status set etc --reason "상담"
78
+ dimicheck schoollife # print today's timetable and meal
79
+ ```
80
+
81
+ TUI shortcuts:
82
+
83
+ - `S`: change status
84
+ - `R`: refresh dashboard
85
+ - `L`: logout
86
+ - `Q`: quit
87
+
88
+ ## v1 Scope
89
+
90
+ The public v1 supports:
91
+
92
+ - current student status read/write
93
+ - today's schoollife summary
94
+ - OAuth PKCE login/logout
95
+
96
+ It intentionally does not expose routine editing, teacher/admin tools, browser session cookies, remember tokens, or `/api/mcp/state`.
97
+
98
+ ## Server Notes
99
+
100
+ The server seeds a dedicated public OAuth client:
101
+
102
+ - client id: `dimicheck-tui-public`
103
+ - redirect URI ports: `45831` through `45835`
104
+ - scopes: `openid basic student_info status.read status.write`
105
+
106
+ Server deployments should happen from the repository root. This package is intentionally separate so the public TUI release does not include Flask, database, or Google Sheets dependencies.
@@ -0,0 +1,16 @@
1
+ README.md
2
+ pyproject.toml
3
+ dimicheck_tui/__init__.py
4
+ dimicheck_tui/api.py
5
+ dimicheck_tui/app.py
6
+ dimicheck_tui/auth.py
7
+ dimicheck_tui/cli.py
8
+ dimicheck_tui/config.py
9
+ dimicheck_tui/token_store.py
10
+ dimicheck_tui.egg-info/PKG-INFO
11
+ dimicheck_tui.egg-info/SOURCES.txt
12
+ dimicheck_tui.egg-info/dependency_links.txt
13
+ dimicheck_tui.egg-info/entry_points.txt
14
+ dimicheck_tui.egg-info/requires.txt
15
+ dimicheck_tui.egg-info/top_level.txt
16
+ tests/test_dimicheck_tui.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ dimicheck = dimicheck_tui.cli:main
@@ -0,0 +1,6 @@
1
+ keyring>=25.0.0
2
+ requests==2.31.0
3
+ textual>=0.89.0
4
+
5
+ [dev]
6
+ pytest>=8.0.0
@@ -0,0 +1 @@
1
+ dimicheck_tui
@@ -0,0 +1,47 @@
1
+ [project]
2
+ name = "dimicheck-tui"
3
+ version = "0.1.0"
4
+ description = "DimiCheck terminal dashboard"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ authors = [
8
+ { name = "DimiCheck" },
9
+ ]
10
+ keywords = ["dimicheck", "tui", "textual", "oauth", "school"]
11
+ classifiers = [
12
+ "Development Status :: 3 - Alpha",
13
+ "Environment :: Console",
14
+ "Intended Audience :: End Users/Desktop",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.11",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Programming Language :: Python :: 3.13",
19
+ "Topic :: Education",
20
+ ]
21
+ dependencies = [
22
+ "keyring>=25.0.0",
23
+ "requests==2.31.0",
24
+ "textual>=0.89.0",
25
+ ]
26
+
27
+ [project.optional-dependencies]
28
+ dev = [
29
+ "pytest>=8.0.0",
30
+ ]
31
+
32
+ [project.scripts]
33
+ dimicheck = "dimicheck_tui.cli:main"
34
+
35
+ [project.urls]
36
+ Homepage = "https://dimicheck.com"
37
+ Repository = "https://github.com/hjun1052/dimicheck4"
38
+
39
+ [tool.pytest.ini_options]
40
+ testpaths = ["tests"]
41
+
42
+ [build-system]
43
+ requires = ["setuptools>=68"]
44
+ build-backend = "setuptools.build_meta"
45
+
46
+ [tool.setuptools]
47
+ packages = ["dimicheck_tui"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,205 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ import pytest
5
+
6
+ from dimicheck_tui.api import ApiError, DimiCheckAPI
7
+ from dimicheck_tui.cli import format_schoollife, format_status
8
+ from dimicheck_tui.config import TuiConfig
9
+ from dimicheck_tui.token_store import TokenSet, TokenStorageError, TokenStore
10
+
11
+
12
+ class FakeKeyring:
13
+ def __init__(self, *, fail: bool = False):
14
+ self.fail = fail
15
+ self.value = None
16
+
17
+ def get_password(self, _service, _account):
18
+ if self.fail:
19
+ raise RuntimeError("keyring unavailable")
20
+ return self.value
21
+
22
+ def set_password(self, _service, _account, value):
23
+ if self.fail:
24
+ raise RuntimeError("keyring unavailable")
25
+ self.value = value
26
+
27
+ def delete_password(self, _service, _account):
28
+ if self.fail:
29
+ raise RuntimeError("keyring unavailable")
30
+ self.value = None
31
+
32
+
33
+ def _tokens(access="access", refresh="refresh", expires_at=9999999999):
34
+ return TokenSet(access_token=access, refresh_token=refresh, expires_at=expires_at, scope="status.read")
35
+
36
+
37
+ def test_token_store_uses_keyring():
38
+ keyring = FakeKeyring()
39
+ store = TokenStore(account="https://dimicheck.com", keyring_module=keyring)
40
+ tokens = _tokens()
41
+
42
+ store.save(tokens)
43
+
44
+ loaded = store.load()
45
+ assert loaded == tokens
46
+
47
+ store.delete()
48
+ assert store.load() is None
49
+
50
+
51
+ def test_token_store_requires_explicit_plaintext_fallback(tmp_path):
52
+ store = TokenStore(
53
+ account="https://dimicheck.com",
54
+ keyring_module=FakeKeyring(fail=True),
55
+ token_file=tmp_path / "tokens.json",
56
+ )
57
+
58
+ with pytest.raises(TokenStorageError):
59
+ store.save(_tokens())
60
+
61
+
62
+ def test_token_store_preflight_requires_explicit_plaintext_fallback(tmp_path):
63
+ store = TokenStore(
64
+ account="https://dimicheck.com",
65
+ keyring_module=FakeKeyring(fail=True),
66
+ token_file=tmp_path / "tokens.json",
67
+ )
68
+
69
+ with pytest.raises(TokenStorageError):
70
+ store.ensure_can_save()
71
+
72
+
73
+ def test_token_store_plaintext_fallback_writes_private_file(tmp_path):
74
+ token_file = tmp_path / "tokens.json"
75
+ store = TokenStore(
76
+ account="https://dimicheck.com",
77
+ allow_plaintext_file=True,
78
+ keyring_module=FakeKeyring(fail=True),
79
+ token_file=token_file,
80
+ )
81
+
82
+ store.save(_tokens())
83
+
84
+ assert token_file.exists()
85
+ assert oct(token_file.stat().st_mode & 0o777) == "0o600"
86
+ assert json.loads(token_file.read_text(encoding="utf-8"))["access_token"] == "access"
87
+ assert store.load().refresh_token == "refresh"
88
+
89
+
90
+ def test_token_store_plaintext_preflight_does_not_leave_probe_file(tmp_path):
91
+ token_file = tmp_path / "tokens.json"
92
+ store = TokenStore(
93
+ account="https://dimicheck.com",
94
+ allow_plaintext_file=True,
95
+ keyring_module=FakeKeyring(fail=True),
96
+ token_file=token_file,
97
+ )
98
+
99
+ store.ensure_can_save()
100
+
101
+ assert not token_file.exists()
102
+
103
+
104
+ def test_cli_formatters_are_human_readable():
105
+ assert format_status({
106
+ "grade": 2,
107
+ "section": 4,
108
+ "number": 7,
109
+ "statusLabel": "화장실(물)",
110
+ }) == "2학년 4반 7번\n상태: 화장실(물)"
111
+
112
+ schoollife = format_schoollife({
113
+ "date": "2026-06-14",
114
+ "timetable": {"lessons": [{"period": 1, "subject": "국어"}]},
115
+ "meal": {"lunch": "김치볶음밥\n요구르트"},
116
+ "errors": {},
117
+ })
118
+ assert "오늘 학교생활 (2026-06-14)" in schoollife
119
+ assert "1교시 국어" in schoollife
120
+ assert "김치볶음밥 · 요구르트" in schoollife
121
+
122
+
123
+ class FakeStore:
124
+ def __init__(self, tokens):
125
+ self.tokens = tokens
126
+ self.deleted = False
127
+
128
+ def load(self):
129
+ return self.tokens
130
+
131
+ def save(self, tokens):
132
+ self.tokens = tokens
133
+
134
+ def delete(self):
135
+ self.deleted = True
136
+ self.tokens = None
137
+
138
+
139
+ class FakeAuth:
140
+ def __init__(self, refreshed=None, fail=False):
141
+ self.refreshed = refreshed
142
+ self.fail = fail
143
+
144
+ def login(self):
145
+ return self.refreshed
146
+
147
+ def refresh(self, _refresh_token):
148
+ if self.fail:
149
+ from dimicheck_tui.auth import AuthError
150
+
151
+ raise AuthError("refresh failed")
152
+ return self.refreshed
153
+
154
+ def revoke(self, _token):
155
+ return None
156
+
157
+
158
+ class FakeResponse:
159
+ def __init__(self, status_code, payload):
160
+ self.status_code = status_code
161
+ self.payload = payload
162
+ self.text = json.dumps(payload)
163
+
164
+ def json(self):
165
+ return self.payload
166
+
167
+
168
+ class FakeSession:
169
+ def __init__(self, responses):
170
+ self.responses = list(responses)
171
+ self.requests = []
172
+
173
+ def request(self, method, url, **kwargs):
174
+ self.requests.append((method, url, kwargs))
175
+ return self.responses.pop(0)
176
+
177
+
178
+ def test_api_refreshes_once_after_unauthorized_response():
179
+ config = TuiConfig(base_url="https://dimicheck.test")
180
+ store = FakeStore(_tokens(access="old", refresh="refresh", expires_at=9999999999))
181
+ refreshed = _tokens(access="new", refresh="new-refresh", expires_at=9999999999)
182
+ session = FakeSession([
183
+ FakeResponse(401, {"error": "invalid_token"}),
184
+ FakeResponse(200, {"statusCode": "section"}),
185
+ ])
186
+ api = DimiCheckAPI(config, store, auth_client=FakeAuth(refreshed=refreshed), session=session)
187
+
188
+ payload = api.get_status()
189
+
190
+ assert payload == {"statusCode": "section"}
191
+ assert store.tokens.access_token == "new"
192
+ assert session.requests[0][2]["headers"]["Authorization"] == "Bearer old"
193
+ assert session.requests[1][2]["headers"]["Authorization"] == "Bearer new"
194
+
195
+
196
+ def test_api_deletes_tokens_when_refresh_fails():
197
+ config = TuiConfig(base_url="https://dimicheck.test")
198
+ store = FakeStore(_tokens(access="old", refresh="refresh", expires_at=9999999999))
199
+ session = FakeSession([FakeResponse(401, {"error": "invalid_token"})])
200
+ api = DimiCheckAPI(config, store, auth_client=FakeAuth(fail=True), session=session)
201
+
202
+ with pytest.raises(ApiError):
203
+ api.get_status()
204
+
205
+ assert store.deleted is True