nexus-cli 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,388 @@
1
+ """Course management for Nexus CLI."""
2
+
3
+ import re
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+
7
+ import yaml
8
+
9
+
10
+ @dataclass
11
+ class CourseStatus:
12
+ """Parsed .STATUS file for a course."""
13
+
14
+ status: str = "unknown"
15
+ priority: str = "--"
16
+ progress: int = 0
17
+ next: str = ""
18
+ course_type: str = "teaching"
19
+ week: int | None = None
20
+ target: str = ""
21
+
22
+ @classmethod
23
+ def from_file(cls, path: Path) -> "CourseStatus":
24
+ """Parse a .STATUS file."""
25
+ if not path.exists():
26
+ return cls()
27
+
28
+ content = path.read_text()
29
+ result = cls()
30
+
31
+ # Parse YAML-like frontmatter
32
+ for line in content.split("\n"):
33
+ line = line.strip()
34
+ if line.startswith("status:"):
35
+ result.status = line.split(":", 1)[1].strip()
36
+ elif line.startswith("priority:"):
37
+ result.priority = line.split(":", 1)[1].strip()
38
+ elif line.startswith("progress:"):
39
+ try:
40
+ result.progress = int(line.split(":", 1)[1].strip())
41
+ except ValueError:
42
+ pass
43
+ elif line.startswith("next:"):
44
+ result.next = line.split(":", 1)[1].strip()
45
+ elif line.startswith("type:"):
46
+ result.course_type = line.split(":", 1)[1].strip()
47
+ elif line.startswith("week:"):
48
+ try:
49
+ result.week = int(line.split(":", 1)[1].strip())
50
+ except ValueError:
51
+ pass
52
+ elif line.startswith("target:"):
53
+ result.target = line.split(":", 1)[1].strip()
54
+
55
+ return result
56
+
57
+
58
+ @dataclass
59
+ class QuartoConfig:
60
+ """Parsed _quarto.yml configuration."""
61
+
62
+ title: str = ""
63
+ subtitle: str = ""
64
+ author: str = ""
65
+ formats: list[str] = field(default_factory=list)
66
+
67
+ @classmethod
68
+ def from_file(cls, path: Path) -> "QuartoConfig":
69
+ """Parse a _quarto.yml file."""
70
+ if not path.exists():
71
+ return cls()
72
+
73
+ try:
74
+ with open(path) as f:
75
+ data = yaml.safe_load(f) or {}
76
+ except Exception:
77
+ return cls()
78
+
79
+ result = cls()
80
+
81
+ # Get project metadata
82
+ project = data.get("project", {})
83
+ book = data.get("book", {})
84
+ website = data.get("website", {})
85
+
86
+ result.title = book.get("title") or website.get("title") or project.get("title") or data.get("title", "")
87
+ result.subtitle = book.get("subtitle") or data.get("subtitle", "")
88
+ result.author = book.get("author") or data.get("author", "")
89
+
90
+ # Get format types
91
+ if "format" in data:
92
+ fmt = data["format"]
93
+ if isinstance(fmt, dict):
94
+ result.formats = list(fmt.keys())
95
+ elif isinstance(fmt, str):
96
+ result.formats = [fmt]
97
+
98
+ return result
99
+
100
+
101
+ @dataclass
102
+ class Course:
103
+ """A teaching course."""
104
+
105
+ name: str
106
+ path: str
107
+ title: str = ""
108
+ status: str = "unknown"
109
+ progress: int = 0
110
+ week: int | None = None
111
+ next_action: str = ""
112
+ formats: list[str] = field(default_factory=list)
113
+ lecture_count: int = 0
114
+ assignment_count: int = 0
115
+
116
+ def to_dict(self) -> dict:
117
+ """Convert to dictionary."""
118
+ return {
119
+ "name": self.name,
120
+ "path": self.path,
121
+ "title": self.title or self.name,
122
+ "status": self.status,
123
+ "progress": self.progress,
124
+ "week": self.week,
125
+ "next_action": self.next_action,
126
+ "formats": self.formats,
127
+ "lecture_count": self.lecture_count,
128
+ "assignment_count": self.assignment_count,
129
+ }
130
+
131
+
132
+ @dataclass
133
+ class Lecture:
134
+ """A course lecture."""
135
+
136
+ name: str
137
+ path: str
138
+ course: str
139
+ week: int | None = None
140
+ title: str = ""
141
+ format: str = "qmd"
142
+
143
+ def to_dict(self) -> dict:
144
+ """Convert to dictionary."""
145
+ return {
146
+ "name": self.name,
147
+ "path": self.path,
148
+ "course": self.course,
149
+ "week": self.week,
150
+ "title": self.title or self.name,
151
+ "format": self.format,
152
+ }
153
+
154
+
155
+ class CourseManager:
156
+ """Manage teaching courses."""
157
+
158
+ def __init__(self, courses_dir: Path, materials_dir: Path | None = None):
159
+ """Initialize course manager.
160
+
161
+ Args:
162
+ courses_dir: Path to courses directory (e.g., ~/projects/teaching)
163
+ materials_dir: Path to teaching materials (e.g., ~/Documents/Teaching)
164
+ """
165
+ self.courses_dir = Path(courses_dir).expanduser()
166
+ self.materials_dir = Path(materials_dir).expanduser() if materials_dir else None
167
+
168
+ def exists(self) -> bool:
169
+ """Check if courses directory exists."""
170
+ return self.courses_dir.exists()
171
+
172
+ def list_courses(self) -> list[Course]:
173
+ """List all courses in the courses directory."""
174
+ if not self.exists():
175
+ return []
176
+
177
+ courses = []
178
+ for course_path in sorted(self.courses_dir.iterdir()):
179
+ if not course_path.is_dir():
180
+ continue
181
+ if course_path.name.startswith("."):
182
+ continue
183
+
184
+ course = self._load_course(course_path)
185
+ if course:
186
+ courses.append(course)
187
+
188
+ return courses
189
+
190
+ def get_course(self, name: str) -> Course | None:
191
+ """Get a specific course by name."""
192
+ course_path = self.courses_dir / name
193
+ if not course_path.exists():
194
+ # Try case-insensitive match
195
+ for p in self.courses_dir.iterdir():
196
+ if p.name.lower() == name.lower():
197
+ course_path = p
198
+ break
199
+ else:
200
+ return None
201
+
202
+ return self._load_course(course_path)
203
+
204
+ def _load_course(self, course_path: Path) -> Course | None:
205
+ """Load course data from a directory."""
206
+ if not course_path.is_dir():
207
+ return None
208
+
209
+ # Parse .STATUS file
210
+ status_file = course_path / ".STATUS"
211
+ status = CourseStatus.from_file(status_file)
212
+
213
+ # Parse _quarto.yml
214
+ quarto_file = course_path / "_quarto.yml"
215
+ quarto = QuartoConfig.from_file(quarto_file)
216
+
217
+ # Count lectures
218
+ lecture_count = self._count_lectures(course_path)
219
+
220
+ # Count assignments
221
+ assignment_count = self._count_assignments(course_path)
222
+
223
+ return Course(
224
+ name=course_path.name,
225
+ path=str(course_path),
226
+ title=quarto.title or course_path.name,
227
+ status=status.status,
228
+ progress=status.progress,
229
+ week=status.week,
230
+ next_action=status.next,
231
+ formats=quarto.formats,
232
+ lecture_count=lecture_count,
233
+ assignment_count=assignment_count,
234
+ )
235
+
236
+ def _count_lectures(self, course_path: Path) -> int:
237
+ """Count lecture files in a course."""
238
+ count = 0
239
+ for pattern in ["lectures/*.qmd", "slides/*.qmd", "weeks/*.qmd"]:
240
+ count += len(list(course_path.glob(pattern)))
241
+ # Also count week-* files in root
242
+ count += len(list(course_path.glob("week-*.qmd")))
243
+ return count
244
+
245
+ def _count_assignments(self, course_path: Path) -> int:
246
+ """Count assignment files in a course."""
247
+ count = 0
248
+ for pattern in ["assignments/*.qmd", "homework/*.qmd", "labs/*.qmd"]:
249
+ count += len(list(course_path.glob(pattern)))
250
+ return count
251
+
252
+ def list_lectures(self, course_name: str) -> list[Lecture]:
253
+ """List all lectures for a course."""
254
+ course = self.get_course(course_name)
255
+ if not course:
256
+ return []
257
+
258
+ course_path = Path(course.path)
259
+ lectures = []
260
+
261
+ # Find lecture files in various locations
262
+ lecture_patterns = [
263
+ ("lectures", "lectures/*.qmd"),
264
+ ("slides", "slides/*.qmd"),
265
+ ("weeks", "weeks/*.qmd"),
266
+ ("root", "week-*.qmd"),
267
+ ("root", "_week-*.qmd"),
268
+ ]
269
+
270
+ for location, pattern in lecture_patterns:
271
+ for qmd_file in sorted(course_path.glob(pattern)):
272
+ lecture = self._parse_lecture(qmd_file, course_name)
273
+ if lecture:
274
+ lectures.append(lecture)
275
+
276
+ return lectures
277
+
278
+ def _parse_lecture(self, qmd_path: Path, course_name: str) -> Lecture | None:
279
+ """Parse a lecture file."""
280
+ name = qmd_path.stem
281
+
282
+ # Try to extract week number from filename
283
+ week = None
284
+ week_match = re.search(r"week[-_]?(\d+)", name, re.IGNORECASE)
285
+ if week_match:
286
+ week = int(week_match.group(1))
287
+
288
+ # Try to extract title from file
289
+ title = ""
290
+ try:
291
+ content = qmd_path.read_text()
292
+ # Check YAML frontmatter
293
+ if content.startswith("---"):
294
+ end = content.find("---", 3)
295
+ if end > 0:
296
+ frontmatter = content[3:end]
297
+ for line in frontmatter.split("\n"):
298
+ if line.strip().startswith("title:"):
299
+ title = line.split(":", 1)[1].strip().strip("\"'")
300
+ break
301
+ except Exception:
302
+ pass
303
+
304
+ return Lecture(
305
+ name=name,
306
+ path=str(qmd_path),
307
+ course=course_name,
308
+ week=week,
309
+ title=title or name.replace("-", " ").replace("_", " ").title(),
310
+ format="qmd",
311
+ )
312
+
313
+ def get_current_week_lectures(self, course_name: str) -> list[Lecture]:
314
+ """Get lectures for the current week."""
315
+ course = self.get_course(course_name)
316
+ if not course or not course.week:
317
+ return []
318
+
319
+ lectures = self.list_lectures(course_name)
320
+ return [l for l in lectures if l.week == course.week]
321
+
322
+ def search_lectures(self, query: str) -> list[Lecture]:
323
+ """Search for lectures across all courses."""
324
+ results = []
325
+ pattern = re.compile(query, re.IGNORECASE)
326
+
327
+ for course in self.list_courses():
328
+ for lecture in self.list_lectures(course.name):
329
+ if pattern.search(lecture.name) or pattern.search(lecture.title):
330
+ results.append(lecture)
331
+
332
+ return results
333
+
334
+ def get_syllabus(self, course_name: str) -> str | None:
335
+ """Get the syllabus content for a course."""
336
+ course = self.get_course(course_name)
337
+ if not course:
338
+ return None
339
+
340
+ course_path = Path(course.path)
341
+
342
+ # Try common syllabus locations
343
+ syllabus_files = [
344
+ "syllabus.qmd",
345
+ "syllabus.md",
346
+ "_syllabus.qmd",
347
+ "00-syllabus.qmd",
348
+ "docs/syllabus.qmd",
349
+ ]
350
+
351
+ for filename in syllabus_files:
352
+ syllabus_path = course_path / filename
353
+ if syllabus_path.exists():
354
+ return syllabus_path.read_text()
355
+
356
+ return None
357
+
358
+ def get_materials(self, course_name: str) -> list[dict]:
359
+ """List teaching materials for a course."""
360
+ course = self.get_course(course_name)
361
+ if not course:
362
+ return []
363
+
364
+ materials = []
365
+ course_path = Path(course.path)
366
+
367
+ # Collect different types of materials
368
+ material_types = {
369
+ "lectures": ["lectures/*.qmd", "slides/*.qmd"],
370
+ "assignments": ["assignments/*.qmd", "homework/*.qmd"],
371
+ "labs": ["labs/*.qmd"],
372
+ "exams": ["exams/*.qmd", "midterm*.qmd", "final*.qmd"],
373
+ "resources": ["resources/*", "handouts/*"],
374
+ }
375
+
376
+ for mat_type, patterns in material_types.items():
377
+ for pattern in patterns:
378
+ for file_path in course_path.glob(pattern):
379
+ materials.append(
380
+ {
381
+ "type": mat_type,
382
+ "name": file_path.stem,
383
+ "path": str(file_path),
384
+ "format": file_path.suffix[1:] if file_path.suffix else "unknown",
385
+ }
386
+ )
387
+
388
+ return materials