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 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