edcli 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.
- edcli-0.1.0/PKG-INFO +14 -0
- edcli-0.1.0/README.md +0 -0
- edcli-0.1.0/pyproject.toml +30 -0
- edcli-0.1.0/src/edcli/__init__.py +1 -0
- edcli-0.1.0/src/edcli/cli.py +240 -0
- edcli-0.1.0/src/edcli/client.py +106 -0
- edcli-0.1.0/src/edcli/config.py +68 -0
- edcli-0.1.0/src/edcli/content.py +33 -0
- edcli-0.1.0/src/edcli/display.py +182 -0
- edcli-0.1.0/src/edcli/exceptions.py +21 -0
- edcli-0.1.0/src/edcli/models.py +192 -0
- edcli-0.1.0/src/edcli/py.typed +0 -0
edcli-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: edcli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI for EdStem/EdDiscussion
|
|
5
|
+
Author: gluck
|
|
6
|
+
Author-email: gluck <gluck@kelliher.info>
|
|
7
|
+
Requires-Dist: click>=8.1
|
|
8
|
+
Requires-Dist: httpx>=0.27
|
|
9
|
+
Requires-Dist: rich>=13.0
|
|
10
|
+
Requires-Dist: platformdirs>=4.0
|
|
11
|
+
Requires-Dist: tomli-w>=1.0
|
|
12
|
+
Requires-Python: >=3.13
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
edcli-0.1.0/README.md
ADDED
|
File without changes
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "edcli"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "CLI for EdStem/EdDiscussion"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "gluck", email = "gluck@kelliher.info" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.13"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"click>=8.1",
|
|
12
|
+
"httpx>=0.27",
|
|
13
|
+
"rich>=13.0",
|
|
14
|
+
"platformdirs>=4.0",
|
|
15
|
+
"tomli-w>=1.0",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
edcli = "edcli.cli:cli"
|
|
20
|
+
|
|
21
|
+
[build-system]
|
|
22
|
+
requires = ["uv_build>=0.9.26,<0.10.0"]
|
|
23
|
+
build-backend = "uv_build"
|
|
24
|
+
|
|
25
|
+
[dependency-groups]
|
|
26
|
+
dev = [
|
|
27
|
+
"pytest>=8.0",
|
|
28
|
+
"pytest-httpx>=0.35",
|
|
29
|
+
"ruff>=0.4",
|
|
30
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""EdStem Discussion CLI."""
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""CLI commands for edcli."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from edcli.config import Config
|
|
10
|
+
from edcli.display import (
|
|
11
|
+
console,
|
|
12
|
+
print_categories,
|
|
13
|
+
print_course_info,
|
|
14
|
+
print_courses,
|
|
15
|
+
print_json,
|
|
16
|
+
print_thread_detail,
|
|
17
|
+
print_threads,
|
|
18
|
+
)
|
|
19
|
+
from edcli.exceptions import EdCliError
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@click.group()
|
|
23
|
+
@click.option("--format", "output_format", type=click.Choice(["table", "json"]), default=None,
|
|
24
|
+
help="Output format (default: table)")
|
|
25
|
+
@click.pass_context
|
|
26
|
+
def cli(ctx: click.Context, output_format: str | None) -> None:
|
|
27
|
+
"""EdStem Discussion CLI."""
|
|
28
|
+
ctx.ensure_object(dict)
|
|
29
|
+
cfg = Config.load()
|
|
30
|
+
if output_format:
|
|
31
|
+
cfg.output_format = output_format
|
|
32
|
+
ctx.obj["config"] = cfg
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _client(ctx: click.Context):
|
|
36
|
+
from edcli.client import EdClient
|
|
37
|
+
cfg: Config = ctx.obj["config"]
|
|
38
|
+
return EdClient(cfg.require_token(), cfg.base_url)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# -- configure -------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
@cli.command()
|
|
44
|
+
@click.pass_context
|
|
45
|
+
def configure(ctx: click.Context) -> None:
|
|
46
|
+
"""Set API token and default course (interactive)."""
|
|
47
|
+
from edcli.client import EdClient
|
|
48
|
+
|
|
49
|
+
cfg: Config = ctx.obj["config"]
|
|
50
|
+
token = click.prompt("Ed API token (JWT)", hide_input=True)
|
|
51
|
+
cfg.token = token.strip()
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
with EdClient(cfg.token, cfg.base_url) as client:
|
|
55
|
+
user, courses = client.get_user()
|
|
56
|
+
console.print(f"Authenticated as [bold]{user.name}[/bold] ({user.email})")
|
|
57
|
+
except EdCliError as e:
|
|
58
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
59
|
+
sys.exit(1)
|
|
60
|
+
|
|
61
|
+
if courses:
|
|
62
|
+
console.print("\nAvailable courses:")
|
|
63
|
+
for i, c in enumerate(courses, 1):
|
|
64
|
+
console.print(f" {i}. [{c.id}] {c.code} - {c.name}")
|
|
65
|
+
choice = click.prompt(
|
|
66
|
+
"Default course number (or 0 for none)",
|
|
67
|
+
type=int,
|
|
68
|
+
default=1,
|
|
69
|
+
)
|
|
70
|
+
if 1 <= choice <= len(courses):
|
|
71
|
+
cfg.default_course_id = courses[choice - 1].id
|
|
72
|
+
|
|
73
|
+
cfg.save()
|
|
74
|
+
console.print("[green]Configuration saved.[/green]")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# -- courses ---------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
@cli.command()
|
|
80
|
+
@click.pass_context
|
|
81
|
+
def courses(ctx: click.Context) -> None:
|
|
82
|
+
"""List enrolled courses."""
|
|
83
|
+
cfg: Config = ctx.obj["config"]
|
|
84
|
+
with _client(ctx) as client:
|
|
85
|
+
_, course_list = client.get_user()
|
|
86
|
+
if cfg.output_format == "json":
|
|
87
|
+
print_json(course_list)
|
|
88
|
+
else:
|
|
89
|
+
print_courses(course_list)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# -- threads ---------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
@cli.command()
|
|
95
|
+
@click.option("-c", "--course", "course_id", type=int, default=None, help="Course ID")
|
|
96
|
+
@click.option("--type", "thread_type", multiple=True,
|
|
97
|
+
type=click.Choice(["announcement", "question", "post"]),
|
|
98
|
+
help="Filter by thread type (repeatable)")
|
|
99
|
+
@click.option("--category", multiple=True, help="Filter by category name (repeatable)")
|
|
100
|
+
@click.option("--pinned", is_flag=True, help="Show only pinned threads")
|
|
101
|
+
@click.option("--answered", is_flag=True, help="Show only answered threads")
|
|
102
|
+
@click.option("--unread", is_flag=True, help="Show only unread threads")
|
|
103
|
+
@click.option("--limit", "-n", type=int, default=30, help="Max threads to fetch (default 30)")
|
|
104
|
+
@click.option("--sort", type=click.Choice(["new"]), default="new", help="Sort order")
|
|
105
|
+
@click.pass_context
|
|
106
|
+
def threads(
|
|
107
|
+
ctx: click.Context,
|
|
108
|
+
course_id: int | None,
|
|
109
|
+
thread_type: tuple[str, ...],
|
|
110
|
+
category: tuple[str, ...],
|
|
111
|
+
pinned: bool,
|
|
112
|
+
answered: bool,
|
|
113
|
+
unread: bool,
|
|
114
|
+
limit: int,
|
|
115
|
+
sort: str,
|
|
116
|
+
) -> None:
|
|
117
|
+
"""List discussion threads."""
|
|
118
|
+
cfg: Config = ctx.obj["config"]
|
|
119
|
+
cid = cfg.require_course(course_id)
|
|
120
|
+
|
|
121
|
+
with _client(ctx) as client:
|
|
122
|
+
items = client.list_threads(cid, limit=limit, sort=sort)
|
|
123
|
+
|
|
124
|
+
# Client-side filtering
|
|
125
|
+
if thread_type:
|
|
126
|
+
items = [t for t in items if t.type in thread_type]
|
|
127
|
+
if category:
|
|
128
|
+
cat_lower = {c.lower() for c in category}
|
|
129
|
+
items = [t for t in items if t.category.lower() in cat_lower]
|
|
130
|
+
if pinned:
|
|
131
|
+
items = [t for t in items if t.is_pinned]
|
|
132
|
+
if answered:
|
|
133
|
+
items = [t for t in items if t.is_answered or t.is_staff_answered or t.is_student_answered]
|
|
134
|
+
if unread:
|
|
135
|
+
items = [t for t in items if not t.is_seen]
|
|
136
|
+
|
|
137
|
+
if cfg.output_format == "json":
|
|
138
|
+
print_json(items)
|
|
139
|
+
else:
|
|
140
|
+
print_threads(items)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# -- search ----------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
@cli.command()
|
|
146
|
+
@click.argument("query")
|
|
147
|
+
@click.option("-c", "--course", "course_id", type=int, default=None, help="Course ID")
|
|
148
|
+
@click.option("-n", "--limit", type=int, default=20, help="Max results (default 20)")
|
|
149
|
+
@click.option("--sort", type=click.Choice(["relevance", "new"]), default="relevance")
|
|
150
|
+
@click.pass_context
|
|
151
|
+
def search(ctx: click.Context, query: str, course_id: int | None, limit: int, sort: str) -> None:
|
|
152
|
+
"""Search discussion threads."""
|
|
153
|
+
cfg: Config = ctx.obj["config"]
|
|
154
|
+
cid = cfg.require_course(course_id)
|
|
155
|
+
|
|
156
|
+
with _client(ctx) as client:
|
|
157
|
+
results = client.search_threads(cid, query, limit=limit, sort=sort)
|
|
158
|
+
|
|
159
|
+
if cfg.output_format == "json":
|
|
160
|
+
print_json(results)
|
|
161
|
+
else:
|
|
162
|
+
print_threads(results)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# -- thread detail ---------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
@cli.command()
|
|
168
|
+
@click.argument("thread_id", type=int)
|
|
169
|
+
@click.pass_context
|
|
170
|
+
def thread(ctx: click.Context, thread_id: int) -> None:
|
|
171
|
+
"""Show full thread detail with answers and comments."""
|
|
172
|
+
cfg: Config = ctx.obj["config"]
|
|
173
|
+
with _client(ctx) as client:
|
|
174
|
+
t = client.get_thread(thread_id)
|
|
175
|
+
if cfg.output_format == "json":
|
|
176
|
+
print_json(t)
|
|
177
|
+
else:
|
|
178
|
+
print_thread_detail(t)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# -- categories ------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
@cli.command()
|
|
184
|
+
@click.option("-c", "--course", "course_id", type=int, default=None, help="Course ID")
|
|
185
|
+
@click.pass_context
|
|
186
|
+
def categories(ctx: click.Context, course_id: int | None) -> None:
|
|
187
|
+
"""List discussion categories for a course."""
|
|
188
|
+
cfg: Config = ctx.obj["config"]
|
|
189
|
+
cid = cfg.require_course(course_id)
|
|
190
|
+
|
|
191
|
+
with _client(ctx) as client:
|
|
192
|
+
_, course_list = client.get_user()
|
|
193
|
+
|
|
194
|
+
course = next((c for c in course_list if c.id == cid), None)
|
|
195
|
+
if not course:
|
|
196
|
+
console.print(f"[red]Course {cid} not found in your enrollments.[/red]")
|
|
197
|
+
sys.exit(1)
|
|
198
|
+
|
|
199
|
+
if cfg.output_format == "json":
|
|
200
|
+
print_json(course.categories)
|
|
201
|
+
else:
|
|
202
|
+
print_categories(course.categories)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# -- info ------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
@cli.command()
|
|
208
|
+
@click.option("-c", "--course", "course_id", type=int, default=None, help="Course ID")
|
|
209
|
+
@click.pass_context
|
|
210
|
+
def info(ctx: click.Context, course_id: int | None) -> None:
|
|
211
|
+
"""Show course info."""
|
|
212
|
+
cfg: Config = ctx.obj["config"]
|
|
213
|
+
cid = cfg.require_course(course_id)
|
|
214
|
+
|
|
215
|
+
with _client(ctx) as client:
|
|
216
|
+
_, course_list = client.get_user()
|
|
217
|
+
|
|
218
|
+
course = next((c for c in course_list if c.id == cid), None)
|
|
219
|
+
if not course:
|
|
220
|
+
console.print(f"[red]Course {cid} not found in your enrollments.[/red]")
|
|
221
|
+
sys.exit(1)
|
|
222
|
+
|
|
223
|
+
if cfg.output_format == "json":
|
|
224
|
+
print_json(course)
|
|
225
|
+
else:
|
|
226
|
+
print_course_info(course)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# -- renew -----------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
@cli.command()
|
|
232
|
+
@click.pass_context
|
|
233
|
+
def renew(ctx: click.Context) -> None:
|
|
234
|
+
"""Renew/refresh the JWT token."""
|
|
235
|
+
cfg: Config = ctx.obj["config"]
|
|
236
|
+
with _client(ctx) as client:
|
|
237
|
+
new_token = client.renew_token()
|
|
238
|
+
cfg.token = new_token
|
|
239
|
+
cfg.save()
|
|
240
|
+
console.print("[green]Token renewed and saved.[/green]")
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Synchronous HTTP client for the EdStem API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from edcli.exceptions import ApiError, AuthenticationError
|
|
10
|
+
from edcli.models import Course, Thread, User
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class EdClient:
|
|
14
|
+
"""Sync EdStem API client."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, token: str, base_url: str = "https://us.edstem.org"):
|
|
17
|
+
self.base_url = base_url.rstrip("/")
|
|
18
|
+
self._client = httpx.Client(
|
|
19
|
+
base_url=self.base_url,
|
|
20
|
+
headers={"X-Token": token},
|
|
21
|
+
timeout=30.0,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
def close(self) -> None:
|
|
25
|
+
self._client.close()
|
|
26
|
+
|
|
27
|
+
def __enter__(self):
|
|
28
|
+
return self
|
|
29
|
+
|
|
30
|
+
def __exit__(self, *args):
|
|
31
|
+
self.close()
|
|
32
|
+
|
|
33
|
+
# -- low-level ---------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
def _request(self, method: str, path: str, **kwargs) -> httpx.Response:
|
|
36
|
+
"""Make a request with retry on 429 and 5xx."""
|
|
37
|
+
last_exc: Exception | None = None
|
|
38
|
+
for attempt in range(3):
|
|
39
|
+
try:
|
|
40
|
+
resp = self._client.request(method, path, **kwargs)
|
|
41
|
+
except httpx.TransportError as exc:
|
|
42
|
+
last_exc = exc
|
|
43
|
+
time.sleep(2**attempt)
|
|
44
|
+
continue
|
|
45
|
+
|
|
46
|
+
if resp.status_code == 401:
|
|
47
|
+
raise AuthenticationError("Token expired or invalid. Run 'edcli renew' or 'edcli configure'.")
|
|
48
|
+
if resp.status_code == 429 or resp.status_code >= 500:
|
|
49
|
+
last_exc = ApiError(resp.status_code, resp.text)
|
|
50
|
+
time.sleep(2**attempt)
|
|
51
|
+
continue
|
|
52
|
+
if resp.status_code >= 400:
|
|
53
|
+
raise ApiError(resp.status_code, resp.text)
|
|
54
|
+
return resp
|
|
55
|
+
|
|
56
|
+
raise last_exc # type: ignore[misc]
|
|
57
|
+
|
|
58
|
+
def _get(self, path: str, **params) -> dict:
|
|
59
|
+
resp = self._request("GET", path, params=params)
|
|
60
|
+
return resp.json()
|
|
61
|
+
|
|
62
|
+
def _post(self, path: str, **kwargs) -> dict:
|
|
63
|
+
resp = self._request("POST", path, **kwargs)
|
|
64
|
+
if resp.headers.get("content-type", "").startswith("application/json"):
|
|
65
|
+
return resp.json()
|
|
66
|
+
return {}
|
|
67
|
+
|
|
68
|
+
# -- endpoints ---------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
def get_user(self) -> tuple[User, list[Course]]:
|
|
71
|
+
"""GET /api/user -> (user, courses)."""
|
|
72
|
+
data = self._get("/api/user")
|
|
73
|
+
user = User.from_dict(data["user"])
|
|
74
|
+
courses = [Course.from_dict(e) for e in data.get("courses", [])]
|
|
75
|
+
return user, courses
|
|
76
|
+
|
|
77
|
+
def list_threads(
|
|
78
|
+
self, course_id: int, limit: int = 30, sort: str = "new"
|
|
79
|
+
) -> list[Thread]:
|
|
80
|
+
"""GET /api/courses/{id}/threads."""
|
|
81
|
+
data = self._get(f"/api/courses/{course_id}/threads", limit=limit, sort=sort)
|
|
82
|
+
return [Thread.from_dict(t) for t in data.get("threads", [])]
|
|
83
|
+
|
|
84
|
+
def search_threads(
|
|
85
|
+
self, course_id: int, query: str, limit: int = 20, sort: str = "relevance"
|
|
86
|
+
) -> list[Thread]:
|
|
87
|
+
"""GET /api/courses/{id}/threads/search."""
|
|
88
|
+
data = self._get(
|
|
89
|
+
f"/api/courses/{course_id}/threads/search",
|
|
90
|
+
query=query,
|
|
91
|
+
limit=limit,
|
|
92
|
+
sort=sort,
|
|
93
|
+
)
|
|
94
|
+
return [Thread.from_dict(t) for t in data.get("threads", [])]
|
|
95
|
+
|
|
96
|
+
def get_thread(self, thread_id: int) -> Thread:
|
|
97
|
+
"""GET /api/threads/{id}?view=1 -> thread with answers/comments."""
|
|
98
|
+
data = self._get(f"/api/threads/{thread_id}", view=1)
|
|
99
|
+
users_list = data.get("users", [])
|
|
100
|
+
users = {u["id"]: User.from_dict(u) for u in users_list}
|
|
101
|
+
return Thread.from_dict(data["thread"], users)
|
|
102
|
+
|
|
103
|
+
def renew_token(self) -> str:
|
|
104
|
+
"""POST /api/renew_token -> new JWT string."""
|
|
105
|
+
data = self._post("/api/renew_token")
|
|
106
|
+
return data["token"]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Config management: ~/.config/edcli/config.toml"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
import tomllib
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import tomli_w
|
|
12
|
+
from platformdirs import user_config_dir
|
|
13
|
+
|
|
14
|
+
from edcli.exceptions import ConfigError
|
|
15
|
+
|
|
16
|
+
CONFIG_DIR = Path(user_config_dir("edcli"))
|
|
17
|
+
CONFIG_FILE = CONFIG_DIR / "config.toml"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Config:
|
|
22
|
+
token: str = ""
|
|
23
|
+
base_url: str = "https://us.edstem.org"
|
|
24
|
+
default_course_id: int | None = None
|
|
25
|
+
output_format: str = "table"
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def load(cls) -> Config:
|
|
29
|
+
"""Load config from disk, returning defaults if file doesn't exist."""
|
|
30
|
+
if not CONFIG_FILE.exists():
|
|
31
|
+
return cls()
|
|
32
|
+
with open(CONFIG_FILE, "rb") as f:
|
|
33
|
+
data = tomllib.load(f)
|
|
34
|
+
return cls(
|
|
35
|
+
token=data.get("token", ""),
|
|
36
|
+
base_url=data.get("base_url", "https://us.edstem.org"),
|
|
37
|
+
default_course_id=data.get("default_course_id"),
|
|
38
|
+
output_format=data.get("output_format", "table"),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def save(self) -> None:
|
|
42
|
+
"""Save config to disk with restricted permissions."""
|
|
43
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
data: dict = {
|
|
45
|
+
"token": self.token,
|
|
46
|
+
"base_url": self.base_url,
|
|
47
|
+
"output_format": self.output_format,
|
|
48
|
+
}
|
|
49
|
+
if self.default_course_id is not None:
|
|
50
|
+
data["default_course_id"] = self.default_course_id
|
|
51
|
+
with open(CONFIG_FILE, "wb") as f:
|
|
52
|
+
tomli_w.dump(data, f)
|
|
53
|
+
os.chmod(CONFIG_FILE, stat.S_IRUSR | stat.S_IWUSR)
|
|
54
|
+
|
|
55
|
+
def require_token(self) -> str:
|
|
56
|
+
if not self.token:
|
|
57
|
+
raise ConfigError(
|
|
58
|
+
"No token configured. Run 'edcli configure' first."
|
|
59
|
+
)
|
|
60
|
+
return self.token
|
|
61
|
+
|
|
62
|
+
def require_course(self, course_id: int | None) -> int:
|
|
63
|
+
cid = course_id or self.default_course_id
|
|
64
|
+
if cid is None:
|
|
65
|
+
raise ConfigError(
|
|
66
|
+
"No course specified. Use -c COURSE_ID or set a default with 'edcli configure'."
|
|
67
|
+
)
|
|
68
|
+
return cid
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""XML document to plaintext extraction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def strip_xml_tags(text: str) -> str:
|
|
10
|
+
"""Strip XML tags from EdStem document content, returning plaintext."""
|
|
11
|
+
if not text:
|
|
12
|
+
return ""
|
|
13
|
+
# Unescape HTML entities
|
|
14
|
+
text = html.unescape(text)
|
|
15
|
+
# Remove web-snippet blocks entirely
|
|
16
|
+
text = re.sub(r"<web-snippet[^>]*>.*?</web-snippet>", "", text, flags=re.DOTALL)
|
|
17
|
+
# Replace <break/> with newline
|
|
18
|
+
text = re.sub(r"<break\s*/?>", "\n", text)
|
|
19
|
+
# Replace block-level closing tags with newlines
|
|
20
|
+
text = re.sub(r"</(?:paragraph|heading|list-item)>", "\n", text)
|
|
21
|
+
# Strip remaining tags
|
|
22
|
+
text = re.sub(r"<[^>]+>", "", text)
|
|
23
|
+
# Collapse multiple blank lines
|
|
24
|
+
text = re.sub(r"\n{3,}", "\n\n", text)
|
|
25
|
+
return text.strip()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def truncate(text: str, max_len: int = 80) -> str:
|
|
29
|
+
"""Truncate text to max_len, adding ellipsis if needed."""
|
|
30
|
+
text = text.replace("\n", " ").strip()
|
|
31
|
+
if len(text) <= max_len:
|
|
32
|
+
return text
|
|
33
|
+
return text[: max_len - 1] + "\u2026"
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Rich terminal rendering for edcli."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import asdict
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
|
|
14
|
+
from edcli.content import strip_xml_tags, truncate
|
|
15
|
+
from edcli.models import Category, Comment, Course, Thread
|
|
16
|
+
|
|
17
|
+
console = Console()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _parse_ts(ts: str) -> str:
|
|
21
|
+
"""Parse ISO timestamp to a short display string."""
|
|
22
|
+
if not ts:
|
|
23
|
+
return ""
|
|
24
|
+
try:
|
|
25
|
+
dt = datetime.fromisoformat(ts)
|
|
26
|
+
return dt.strftime("%Y-%m-%d %H:%M")
|
|
27
|
+
except (ValueError, TypeError):
|
|
28
|
+
return ts[:16]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _type_badge(thread_type: str) -> Text:
|
|
32
|
+
colors = {"announcement": "bold magenta", "question": "bold cyan", "post": "bold green"}
|
|
33
|
+
return Text(thread_type, style=colors.get(thread_type, ""))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _status_icons(t: Thread) -> str:
|
|
37
|
+
parts = []
|
|
38
|
+
if t.is_pinned:
|
|
39
|
+
parts.append("pinned")
|
|
40
|
+
if t.is_answered or t.is_staff_answered:
|
|
41
|
+
parts.append("answered")
|
|
42
|
+
elif t.is_student_answered:
|
|
43
|
+
parts.append("student-answered")
|
|
44
|
+
if t.is_endorsed:
|
|
45
|
+
parts.append("endorsed")
|
|
46
|
+
if t.is_locked:
|
|
47
|
+
parts.append("locked")
|
|
48
|
+
if not t.is_seen:
|
|
49
|
+
parts.append("NEW")
|
|
50
|
+
return " ".join(parts)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# -- JSON output -----------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
def print_json(data) -> None:
|
|
56
|
+
"""Print any data as JSON."""
|
|
57
|
+
if hasattr(data, "__iter__") and not isinstance(data, dict):
|
|
58
|
+
items = [asdict(item) if hasattr(item, "__dataclass_fields__") else item for item in data]
|
|
59
|
+
console.print_json(json.dumps(items, default=str))
|
|
60
|
+
elif hasattr(data, "__dataclass_fields__"):
|
|
61
|
+
console.print_json(json.dumps(asdict(data), default=str))
|
|
62
|
+
else:
|
|
63
|
+
console.print_json(json.dumps(data, default=str))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# -- Courses ---------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
def print_courses(courses: list[Course]) -> None:
|
|
69
|
+
table = Table(title="Enrolled Courses")
|
|
70
|
+
table.add_column("ID", style="cyan", justify="right")
|
|
71
|
+
table.add_column("Code", style="bold")
|
|
72
|
+
table.add_column("Name")
|
|
73
|
+
table.add_column("Session")
|
|
74
|
+
table.add_column("Role", style="green")
|
|
75
|
+
table.add_column("Status")
|
|
76
|
+
for c in courses:
|
|
77
|
+
table.add_row(str(c.id), c.code, c.name, f"{c.session} {c.year}".strip(), c.role, c.status)
|
|
78
|
+
console.print(table)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# -- Threads list ----------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
def print_threads(threads: list[Thread]) -> None:
|
|
84
|
+
table = Table(title=f"Threads ({len(threads)})")
|
|
85
|
+
table.add_column("#", style="dim", justify="right")
|
|
86
|
+
table.add_column("Type", width=14)
|
|
87
|
+
table.add_column("Title", max_width=50)
|
|
88
|
+
table.add_column("Category", style="yellow", max_width=25)
|
|
89
|
+
table.add_column("Replies", justify="right")
|
|
90
|
+
table.add_column("Votes", justify="right")
|
|
91
|
+
table.add_column("Status", max_width=30)
|
|
92
|
+
table.add_column("Date", style="dim")
|
|
93
|
+
for t in threads:
|
|
94
|
+
table.add_row(
|
|
95
|
+
str(t.number),
|
|
96
|
+
_type_badge(t.type),
|
|
97
|
+
truncate(t.title, 50),
|
|
98
|
+
truncate(t.category, 25),
|
|
99
|
+
str(t.reply_count),
|
|
100
|
+
str(t.vote_count),
|
|
101
|
+
_status_icons(t),
|
|
102
|
+
_parse_ts(t.created_at),
|
|
103
|
+
)
|
|
104
|
+
console.print(table)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# -- Thread detail ---------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
def _render_comment(c: Comment, indent: int = 0) -> None:
|
|
110
|
+
prefix = " " * indent
|
|
111
|
+
style = "bold green" if c.is_endorsed else ""
|
|
112
|
+
endorsed = " [endorsed]" if c.is_endorsed else ""
|
|
113
|
+
resolved = " [resolved]" if c.is_resolved else ""
|
|
114
|
+
header = f"{prefix}{c.user_name or f'User {c.user_id}'} ({_parse_ts(c.created_at)}){endorsed}{resolved}"
|
|
115
|
+
console.print(header, style=style)
|
|
116
|
+
|
|
117
|
+
body = c.document or strip_xml_tags(c.content)
|
|
118
|
+
if body:
|
|
119
|
+
for line in body.splitlines():
|
|
120
|
+
console.print(f"{prefix} {line}")
|
|
121
|
+
if c.vote_count:
|
|
122
|
+
console.print(f"{prefix} [{c.vote_count} votes]", style="dim")
|
|
123
|
+
console.print()
|
|
124
|
+
|
|
125
|
+
for reply in c.comments:
|
|
126
|
+
_render_comment(reply, indent + 1)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def print_thread_detail(t: Thread) -> None:
|
|
130
|
+
# Header panel
|
|
131
|
+
status = _status_icons(t)
|
|
132
|
+
subtitle = f"#{t.number} | {t.type} | {t.category}"
|
|
133
|
+
if t.subcategory:
|
|
134
|
+
subtitle += f" > {t.subcategory}"
|
|
135
|
+
meta_lines = [
|
|
136
|
+
f"By: {t.user_name or f'User {t.user_id}'} | {_parse_ts(t.created_at)}",
|
|
137
|
+
f"Views: {t.view_count} | Votes: {t.vote_count} | Replies: {t.reply_count}",
|
|
138
|
+
]
|
|
139
|
+
if status:
|
|
140
|
+
meta_lines.append(f"Status: {status}")
|
|
141
|
+
body = t.document or strip_xml_tags(t.content)
|
|
142
|
+
content = "\n".join(meta_lines) + "\n\n" + body
|
|
143
|
+
console.print(Panel(content, title=t.title, subtitle=subtitle, expand=True))
|
|
144
|
+
|
|
145
|
+
# Answers
|
|
146
|
+
if t.answers:
|
|
147
|
+
console.print(f"\n[bold]Answers ({len(t.answers)}):[/bold]")
|
|
148
|
+
for a in t.answers:
|
|
149
|
+
_render_comment(a)
|
|
150
|
+
|
|
151
|
+
# Comments
|
|
152
|
+
if t.comments:
|
|
153
|
+
console.print(f"\n[bold]Comments ({len(t.comments)}):[/bold]")
|
|
154
|
+
for c in t.comments:
|
|
155
|
+
_render_comment(c)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# -- Categories ------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
def print_categories(categories: list[Category]) -> None:
|
|
161
|
+
table = Table(title="Discussion Categories")
|
|
162
|
+
table.add_column("Category", style="bold yellow")
|
|
163
|
+
table.add_column("Subcategories")
|
|
164
|
+
for cat in categories:
|
|
165
|
+
subs = ", ".join(cat.subcategories) if cat.subcategories else "-"
|
|
166
|
+
table.add_row(cat.name, subs)
|
|
167
|
+
console.print(table)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# -- Course info -----------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
def print_course_info(course: Course) -> None:
|
|
173
|
+
lines = [
|
|
174
|
+
f"[bold]ID:[/bold] {course.id}",
|
|
175
|
+
f"[bold]Code:[/bold] {course.code}",
|
|
176
|
+
f"[bold]Name:[/bold] {course.name}",
|
|
177
|
+
f"[bold]Session:[/bold] {course.session} {course.year}",
|
|
178
|
+
f"[bold]Status:[/bold] {course.status}",
|
|
179
|
+
f"[bold]Role:[/bold] {course.role}",
|
|
180
|
+
f"[bold]Categories:[/bold] {len(course.categories)}",
|
|
181
|
+
]
|
|
182
|
+
console.print(Panel("\n".join(lines), title=course.code))
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Exception hierarchy for edcli."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class EdCliError(Exception):
|
|
5
|
+
"""Base exception for edcli."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConfigError(EdCliError):
|
|
9
|
+
"""Configuration missing or invalid."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AuthenticationError(EdCliError):
|
|
13
|
+
"""Token invalid or expired."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ApiError(EdCliError):
|
|
17
|
+
"""API returned an error response."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, status_code: int, message: str = ""):
|
|
20
|
+
self.status_code = status_code
|
|
21
|
+
super().__init__(f"HTTP {status_code}: {message}" if message else f"HTTP {status_code}")
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Data models for EdStem API responses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class User:
|
|
10
|
+
id: int
|
|
11
|
+
name: str
|
|
12
|
+
email: str = ""
|
|
13
|
+
role: str = ""
|
|
14
|
+
course_role: str | None = None
|
|
15
|
+
avatar: str | None = None
|
|
16
|
+
|
|
17
|
+
@classmethod
|
|
18
|
+
def from_dict(cls, d: dict) -> User:
|
|
19
|
+
return cls(
|
|
20
|
+
id=d["id"],
|
|
21
|
+
name=d.get("name", ""),
|
|
22
|
+
email=d.get("email", ""),
|
|
23
|
+
role=d.get("role", ""),
|
|
24
|
+
course_role=d.get("course_role"),
|
|
25
|
+
avatar=d.get("avatar"),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Category:
|
|
31
|
+
name: str
|
|
32
|
+
subcategories: list[str] = field(default_factory=list)
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def from_dict(cls, d: dict) -> Category:
|
|
36
|
+
subs = []
|
|
37
|
+
for s in d.get("subcategories", []):
|
|
38
|
+
if isinstance(s, str):
|
|
39
|
+
subs.append(s)
|
|
40
|
+
elif isinstance(s, dict):
|
|
41
|
+
subs.append(s.get("name", ""))
|
|
42
|
+
return cls(name=d["name"], subcategories=subs)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class Course:
|
|
47
|
+
id: int
|
|
48
|
+
code: str
|
|
49
|
+
name: str
|
|
50
|
+
year: str = ""
|
|
51
|
+
session: str = ""
|
|
52
|
+
status: str = ""
|
|
53
|
+
role: str = ""
|
|
54
|
+
categories: list[Category] = field(default_factory=list)
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def from_dict(cls, enrollment: dict) -> Course:
|
|
58
|
+
c = enrollment["course"]
|
|
59
|
+
role_data = enrollment.get("role", {})
|
|
60
|
+
cats = []
|
|
61
|
+
try:
|
|
62
|
+
cat_list = c["settings"]["discussion"]["categories"]
|
|
63
|
+
cats = [Category.from_dict(cat) for cat in cat_list]
|
|
64
|
+
except (KeyError, TypeError):
|
|
65
|
+
pass
|
|
66
|
+
return cls(
|
|
67
|
+
id=c["id"],
|
|
68
|
+
code=c.get("code", ""),
|
|
69
|
+
name=c.get("name", ""),
|
|
70
|
+
year=c.get("year", ""),
|
|
71
|
+
session=c.get("session", ""),
|
|
72
|
+
status=c.get("status", ""),
|
|
73
|
+
role=role_data.get("role", "") if role_data else "",
|
|
74
|
+
categories=cats,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class Comment:
|
|
80
|
+
id: int
|
|
81
|
+
user_id: int
|
|
82
|
+
type: str # "comment" or "answer"
|
|
83
|
+
document: str = ""
|
|
84
|
+
content: str = ""
|
|
85
|
+
is_anonymous: bool = False
|
|
86
|
+
is_endorsed: bool = False
|
|
87
|
+
is_resolved: bool = False
|
|
88
|
+
vote_count: int = 0
|
|
89
|
+
created_at: str = ""
|
|
90
|
+
user_name: str = ""
|
|
91
|
+
comments: list[Comment] = field(default_factory=list)
|
|
92
|
+
|
|
93
|
+
@classmethod
|
|
94
|
+
def from_dict(cls, d: dict, users: dict[int, User] | None = None) -> Comment:
|
|
95
|
+
users = users or {}
|
|
96
|
+
user_name = ""
|
|
97
|
+
if d.get("is_anonymous"):
|
|
98
|
+
user_name = "Anonymous"
|
|
99
|
+
elif d.get("user_id") and d["user_id"] in users:
|
|
100
|
+
user_name = users[d["user_id"]].name
|
|
101
|
+
replies = [cls.from_dict(r, users) for r in d.get("comments", []) or []]
|
|
102
|
+
return cls(
|
|
103
|
+
id=d["id"],
|
|
104
|
+
user_id=d.get("user_id", 0),
|
|
105
|
+
type=d.get("type", "comment"),
|
|
106
|
+
document=d.get("document", ""),
|
|
107
|
+
content=d.get("content", ""),
|
|
108
|
+
is_anonymous=d.get("is_anonymous", False),
|
|
109
|
+
is_endorsed=d.get("is_endorsed", False),
|
|
110
|
+
is_resolved=d.get("is_resolved", False),
|
|
111
|
+
vote_count=d.get("vote_count", 0),
|
|
112
|
+
created_at=d.get("created_at", ""),
|
|
113
|
+
user_name=user_name,
|
|
114
|
+
comments=replies,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class Thread:
|
|
120
|
+
id: int
|
|
121
|
+
number: int
|
|
122
|
+
type: str # "announcement", "question", "post"
|
|
123
|
+
title: str
|
|
124
|
+
document: str = ""
|
|
125
|
+
content: str = ""
|
|
126
|
+
category: str = ""
|
|
127
|
+
subcategory: str = ""
|
|
128
|
+
user_id: int = 0
|
|
129
|
+
user_name: str = ""
|
|
130
|
+
vote_count: int = 0
|
|
131
|
+
reply_count: int = 0
|
|
132
|
+
view_count: int = 0
|
|
133
|
+
unique_view_count: int = 0
|
|
134
|
+
is_pinned: bool = False
|
|
135
|
+
is_locked: bool = False
|
|
136
|
+
is_private: bool = False
|
|
137
|
+
is_answered: bool = False
|
|
138
|
+
is_student_answered: bool = False
|
|
139
|
+
is_staff_answered: bool = False
|
|
140
|
+
is_endorsed: bool = False
|
|
141
|
+
is_anonymous: bool = False
|
|
142
|
+
is_seen: bool = False
|
|
143
|
+
is_starred: bool = False
|
|
144
|
+
new_reply_count: int = 0
|
|
145
|
+
unresolved_count: int = 0
|
|
146
|
+
created_at: str = ""
|
|
147
|
+
updated_at: str = ""
|
|
148
|
+
answers: list[Comment] = field(default_factory=list)
|
|
149
|
+
comments: list[Comment] = field(default_factory=list)
|
|
150
|
+
|
|
151
|
+
@classmethod
|
|
152
|
+
def from_dict(cls, d: dict, users: dict[int, User] | None = None) -> Thread:
|
|
153
|
+
users = users or {}
|
|
154
|
+
user_name = ""
|
|
155
|
+
if d.get("is_anonymous"):
|
|
156
|
+
user_name = "Anonymous"
|
|
157
|
+
elif d.get("user_id") and d["user_id"] in users:
|
|
158
|
+
user_name = users[d["user_id"]].name
|
|
159
|
+
answers = [Comment.from_dict(a, users) for a in d.get("answers", []) or []]
|
|
160
|
+
comments = [Comment.from_dict(c, users) for c in d.get("comments", []) or []]
|
|
161
|
+
return cls(
|
|
162
|
+
id=d["id"],
|
|
163
|
+
number=d.get("number", 0),
|
|
164
|
+
type=d.get("type", ""),
|
|
165
|
+
title=d.get("title", ""),
|
|
166
|
+
document=d.get("document", ""),
|
|
167
|
+
content=d.get("content", ""),
|
|
168
|
+
category=d.get("category", ""),
|
|
169
|
+
subcategory=d.get("subcategory", ""),
|
|
170
|
+
user_id=d.get("user_id", 0),
|
|
171
|
+
user_name=user_name,
|
|
172
|
+
vote_count=d.get("vote_count", 0),
|
|
173
|
+
reply_count=d.get("reply_count", 0),
|
|
174
|
+
view_count=d.get("view_count", 0),
|
|
175
|
+
unique_view_count=d.get("unique_view_count", 0),
|
|
176
|
+
is_pinned=d.get("is_pinned", False),
|
|
177
|
+
is_locked=d.get("is_locked", False),
|
|
178
|
+
is_private=d.get("is_private", False),
|
|
179
|
+
is_answered=d.get("is_answered", False),
|
|
180
|
+
is_student_answered=d.get("is_student_answered", False),
|
|
181
|
+
is_staff_answered=d.get("is_staff_answered", False),
|
|
182
|
+
is_endorsed=d.get("is_endorsed", False),
|
|
183
|
+
is_anonymous=d.get("is_anonymous", False),
|
|
184
|
+
is_seen=d.get("is_seen", False),
|
|
185
|
+
is_starred=d.get("is_starred", False),
|
|
186
|
+
new_reply_count=d.get("new_reply_count", 0),
|
|
187
|
+
unresolved_count=d.get("unresolved_count", 0),
|
|
188
|
+
created_at=d.get("created_at", ""),
|
|
189
|
+
updated_at=d.get("updated_at", ""),
|
|
190
|
+
answers=answers,
|
|
191
|
+
comments=comments,
|
|
192
|
+
)
|
|
File without changes
|