course-setup 3.1.0__tar.gz → 3.2.1__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.
- {course_setup-3.1.0 → course_setup-3.2.1}/PKG-INFO +50 -19
- {course_setup-3.1.0 → course_setup-3.2.1}/README.md +49 -18
- {course_setup-3.1.0 → course_setup-3.2.1}/pyproject.toml +1 -1
- course_setup-3.2.1/src/setup_course_github/list_courses.py +254 -0
- course_setup-3.1.0/src/setup_course_github/list_courses.py +0 -104
- {course_setup-3.1.0 → course_setup-3.2.1}/src/setup_course_github/.DS_Store +0 -0
- {course_setup-3.1.0 → course_setup-3.2.1}/src/setup_course_github/.claude/settings.local.json +0 -0
- {course_setup-3.1.0 → course_setup-3.2.1}/src/setup_course_github/__init__.py +0 -0
- {course_setup-3.1.0 → course_setup-3.2.1}/src/setup_course_github/archive_course.py +0 -0
- {course_setup-3.1.0 → course_setup-3.2.1}/src/setup_course_github/config.py +0 -0
- {course_setup-3.1.0 → course_setup-3.2.1}/src/setup_course_github/generic/.gitignore +0 -0
- {course_setup-3.1.0 → course_setup-3.2.1}/src/setup_course_github/generic/.ipynb_checkpoints/Course notebook-checkpoint.ipynb +0 -0
- {course_setup-3.1.0 → course_setup-3.2.1}/src/setup_course_github/generic/Course notebook.ipynb +0 -0
- {course_setup-3.1.0 → course_setup-3.2.1}/src/setup_course_github/generic/README.md +0 -0
- {course_setup-3.1.0 → course_setup-3.2.1}/src/setup_course_github/init_config.py +0 -0
- {course_setup-3.1.0 → course_setup-3.2.1}/src/setup_course_github/notebooks.py +0 -0
- {course_setup-3.1.0 → course_setup-3.2.1}/src/setup_course_github/retire_course.py +0 -0
- {course_setup-3.1.0 → course_setup-3.2.1}/src/setup_course_github/setup_course.py +0 -0
- {course_setup-3.1.0 → course_setup-3.2.1}/src/setup_course_github/unretire_course.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: course-setup
|
|
3
|
-
Version: 3.1
|
|
3
|
+
Version: 3.2.1
|
|
4
4
|
Summary: CLI tools for setting up and retiring GitHub-backed course repositories
|
|
5
5
|
Author: Reuven Lerner
|
|
6
6
|
Author-email: Reuven Lerner <reuven@lernerpython.com>
|
|
@@ -74,7 +74,7 @@ notebook_type = "jupyter" # or "marimo"
|
|
|
74
74
|
| `[paths] archive` | Yes | Directory where retired courses are archived. |
|
|
75
75
|
| `[paths] readme_source` | No | Local path or URL to a custom README for new courses. Omit to use the bundled default. |
|
|
76
76
|
| `[paths] additional_files` | No | List of file/directory paths to copy into every new course (e.g. data files, exercise notebooks). |
|
|
77
|
-
| `[paths] course_dirs` | No | List of directories that `list-courses` scans for active courses (e.g. `["~/Courses/Current"]`). `~` is expanded. |
|
|
77
|
+
| `[paths] course_dirs` | No | List of directories that `list-courses` scans for active courses (e.g. `["~/Courses/Current"]`). `~` is expanded. Overridden by `--dir` on the `list-courses` command line. |
|
|
78
78
|
| `[defaults] notebook_type` | No | `"jupyter"` (default) or `"marimo"`. |
|
|
79
79
|
| `[defaults] verbose` | No | `true` or `false` (default). Sets the default verbosity for `setup-course`. |
|
|
80
80
|
| `[defaults] private` | No | `true` or `false` (default). When `true`, `setup-course` creates private GitHub repos by default. |
|
|
@@ -226,36 +226,67 @@ list-courses
|
|
|
226
226
|
```
|
|
227
227
|
|
|
228
228
|
A read-only command: nothing is modified, and no GitHub API calls are made (it
|
|
229
|
-
reads your config file but does not use the network).
|
|
229
|
+
reads your config file but does not use the network).
|
|
230
230
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
grouped by year, using your configured `[paths] archive` directory.
|
|
231
|
+
> **Breaking change (3.2.0):** the positional argument used to be a
|
|
232
|
+
> scan-directory override (`list-courses ~/Other`). It is now a **name
|
|
233
|
+
> filter** instead. To scan a different directory, use the repeatable
|
|
234
|
+
> `--dir PATH` flag: `list-courses --dir ~/Other`.
|
|
236
235
|
|
|
237
|
-
|
|
236
|
+
By default, `list-courses` prints the full list of **active courses** — a
|
|
237
|
+
directory qualifies as a course if it has a `.git` subdirectory and at least
|
|
238
|
+
one notebook (`.ipynb`, or a marimo `.py`) — followed by a one-line summary of
|
|
239
|
+
the **archived courses** found under your configured `[paths] archive`
|
|
240
|
+
directory (an archived course is any non-hidden, non-junk directory under a
|
|
241
|
+
4-digit-year folder that contains at least one notebook):
|
|
238
242
|
|
|
239
243
|
```
|
|
240
|
-
|
|
244
|
+
Active courses:
|
|
245
|
+
Acme-python-intro-2026-03 — 5 notebooks (2026-03-17 → 2026-03-21)
|
|
246
|
+
Beta-pandas-2026-02 — 3 notebooks (2026-02-02 → 2026-02-16)
|
|
247
|
+
|
|
248
|
+
Archived: 412 courses across 2018–2026 — use --archived to list them.
|
|
241
249
|
```
|
|
242
250
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
`No archived courses found` respectively.
|
|
251
|
+
Each course line is shown as `name — N notebooks (first-date → last-date)`,
|
|
252
|
+
with dates parsed from notebook filenames (`n/a` if none are found).
|
|
246
253
|
|
|
247
|
-
| Argument | Description |
|
|
254
|
+
| Argument / Option | Description |
|
|
248
255
|
|----------|-------------|
|
|
249
|
-
| `
|
|
256
|
+
| `NAME...` | Filter courses by case-insensitive name substring. Multiple names match as OR. Narrows whatever is shown; does not by itself expand the archive. |
|
|
257
|
+
| `--dir PATH` | Directory to scan for active courses (repeatable; replaces `course_dirs` from config for this run) |
|
|
258
|
+
| `--active` | Show only the active-courses section |
|
|
259
|
+
| `--archived` | Show only the archived-courses section, expanded and grouped by year |
|
|
260
|
+
| `--year YYYY` | Restrict archived courses to a year (repeatable, 4-digit years only); implies `--archived` unless `--active` is also given, in which case `--year` is ignored |
|
|
261
|
+
| `--count` | Print counts instead of course lines, honoring all other filters |
|
|
262
|
+
| `--version` | Show the version number, PyPI URL, author name, and email |
|
|
250
263
|
|
|
251
|
-
Directories to scan are resolved in this order:
|
|
264
|
+
Directories to scan for active courses are resolved in this order:
|
|
252
265
|
|
|
253
|
-
1.
|
|
266
|
+
1. `--dir PATH` values passed on the command line (repeatable)
|
|
254
267
|
2. Otherwise, `[paths] course_dirs` from your config file
|
|
255
268
|
3. Otherwise, the current directory
|
|
256
269
|
|
|
257
|
-
|
|
258
|
-
|
|
270
|
+
#### Examples
|
|
271
|
+
|
|
272
|
+
| Command | Result |
|
|
273
|
+
|---|---|
|
|
274
|
+
| `list-courses` | active courses in full + `Archived: N courses across 2018–2026 — use --archived …` |
|
|
275
|
+
| `list-courses --archived` | archive only, expanded and grouped by year |
|
|
276
|
+
| `list-courses --active` | active courses only |
|
|
277
|
+
| `list-courses cisco` | active courses matching "cisco" + a name-filtered archive summary line |
|
|
278
|
+
| `list-courses cisco --archived` | archived courses matching "cisco" (all years, expanded) |
|
|
279
|
+
| `list-courses --year 2024` | archive for 2024 only (active section suppressed) |
|
|
280
|
+
| `list-courses --year 2024 --year 2025` | archive for 2024 and 2025 only |
|
|
281
|
+
| `list-courses cisco --year 2024` | archived courses matching "cisco" from 2024 only |
|
|
282
|
+
| `list-courses --active --year 2024` | active only (`--year` is ignored for active) |
|
|
283
|
+
| `list-courses --count` | `Active courses: 3` + `Archived courses: 412` + per-year counts |
|
|
284
|
+
| `list-courses cisco --count` | counts of active + archived courses matching "cisco" |
|
|
285
|
+
| `list-courses --dir ~/Other` | scan `~/Other` for active courses instead of config `course_dirs` |
|
|
286
|
+
|
|
287
|
+
If there are no matching active or archived courses, it prints an empty-state
|
|
288
|
+
line (e.g. `No active courses found`, or `No active courses match: cisco` when
|
|
289
|
+
a name filter excludes everything).
|
|
259
290
|
|
|
260
291
|
### `unretire-course` — Restore a retired course
|
|
261
292
|
|
|
@@ -54,7 +54,7 @@ notebook_type = "jupyter" # or "marimo"
|
|
|
54
54
|
| `[paths] archive` | Yes | Directory where retired courses are archived. |
|
|
55
55
|
| `[paths] readme_source` | No | Local path or URL to a custom README for new courses. Omit to use the bundled default. |
|
|
56
56
|
| `[paths] additional_files` | No | List of file/directory paths to copy into every new course (e.g. data files, exercise notebooks). |
|
|
57
|
-
| `[paths] course_dirs` | No | List of directories that `list-courses` scans for active courses (e.g. `["~/Courses/Current"]`). `~` is expanded. |
|
|
57
|
+
| `[paths] course_dirs` | No | List of directories that `list-courses` scans for active courses (e.g. `["~/Courses/Current"]`). `~` is expanded. Overridden by `--dir` on the `list-courses` command line. |
|
|
58
58
|
| `[defaults] notebook_type` | No | `"jupyter"` (default) or `"marimo"`. |
|
|
59
59
|
| `[defaults] verbose` | No | `true` or `false` (default). Sets the default verbosity for `setup-course`. |
|
|
60
60
|
| `[defaults] private` | No | `true` or `false` (default). When `true`, `setup-course` creates private GitHub repos by default. |
|
|
@@ -206,36 +206,67 @@ list-courses
|
|
|
206
206
|
```
|
|
207
207
|
|
|
208
208
|
A read-only command: nothing is modified, and no GitHub API calls are made (it
|
|
209
|
-
reads your config file but does not use the network).
|
|
209
|
+
reads your config file but does not use the network).
|
|
210
210
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
grouped by year, using your configured `[paths] archive` directory.
|
|
211
|
+
> **Breaking change (3.2.0):** the positional argument used to be a
|
|
212
|
+
> scan-directory override (`list-courses ~/Other`). It is now a **name
|
|
213
|
+
> filter** instead. To scan a different directory, use the repeatable
|
|
214
|
+
> `--dir PATH` flag: `list-courses --dir ~/Other`.
|
|
216
215
|
|
|
217
|
-
|
|
216
|
+
By default, `list-courses` prints the full list of **active courses** — a
|
|
217
|
+
directory qualifies as a course if it has a `.git` subdirectory and at least
|
|
218
|
+
one notebook (`.ipynb`, or a marimo `.py`) — followed by a one-line summary of
|
|
219
|
+
the **archived courses** found under your configured `[paths] archive`
|
|
220
|
+
directory (an archived course is any non-hidden, non-junk directory under a
|
|
221
|
+
4-digit-year folder that contains at least one notebook):
|
|
218
222
|
|
|
219
223
|
```
|
|
220
|
-
|
|
224
|
+
Active courses:
|
|
225
|
+
Acme-python-intro-2026-03 — 5 notebooks (2026-03-17 → 2026-03-21)
|
|
226
|
+
Beta-pandas-2026-02 — 3 notebooks (2026-02-02 → 2026-02-16)
|
|
227
|
+
|
|
228
|
+
Archived: 412 courses across 2018–2026 — use --archived to list them.
|
|
221
229
|
```
|
|
222
230
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
`No archived courses found` respectively.
|
|
231
|
+
Each course line is shown as `name — N notebooks (first-date → last-date)`,
|
|
232
|
+
with dates parsed from notebook filenames (`n/a` if none are found).
|
|
226
233
|
|
|
227
|
-
| Argument | Description |
|
|
234
|
+
| Argument / Option | Description |
|
|
228
235
|
|----------|-------------|
|
|
229
|
-
| `
|
|
236
|
+
| `NAME...` | Filter courses by case-insensitive name substring. Multiple names match as OR. Narrows whatever is shown; does not by itself expand the archive. |
|
|
237
|
+
| `--dir PATH` | Directory to scan for active courses (repeatable; replaces `course_dirs` from config for this run) |
|
|
238
|
+
| `--active` | Show only the active-courses section |
|
|
239
|
+
| `--archived` | Show only the archived-courses section, expanded and grouped by year |
|
|
240
|
+
| `--year YYYY` | Restrict archived courses to a year (repeatable, 4-digit years only); implies `--archived` unless `--active` is also given, in which case `--year` is ignored |
|
|
241
|
+
| `--count` | Print counts instead of course lines, honoring all other filters |
|
|
242
|
+
| `--version` | Show the version number, PyPI URL, author name, and email |
|
|
230
243
|
|
|
231
|
-
Directories to scan are resolved in this order:
|
|
244
|
+
Directories to scan for active courses are resolved in this order:
|
|
232
245
|
|
|
233
|
-
1.
|
|
246
|
+
1. `--dir PATH` values passed on the command line (repeatable)
|
|
234
247
|
2. Otherwise, `[paths] course_dirs` from your config file
|
|
235
248
|
3. Otherwise, the current directory
|
|
236
249
|
|
|
237
|
-
|
|
238
|
-
|
|
250
|
+
#### Examples
|
|
251
|
+
|
|
252
|
+
| Command | Result |
|
|
253
|
+
|---|---|
|
|
254
|
+
| `list-courses` | active courses in full + `Archived: N courses across 2018–2026 — use --archived …` |
|
|
255
|
+
| `list-courses --archived` | archive only, expanded and grouped by year |
|
|
256
|
+
| `list-courses --active` | active courses only |
|
|
257
|
+
| `list-courses cisco` | active courses matching "cisco" + a name-filtered archive summary line |
|
|
258
|
+
| `list-courses cisco --archived` | archived courses matching "cisco" (all years, expanded) |
|
|
259
|
+
| `list-courses --year 2024` | archive for 2024 only (active section suppressed) |
|
|
260
|
+
| `list-courses --year 2024 --year 2025` | archive for 2024 and 2025 only |
|
|
261
|
+
| `list-courses cisco --year 2024` | archived courses matching "cisco" from 2024 only |
|
|
262
|
+
| `list-courses --active --year 2024` | active only (`--year` is ignored for active) |
|
|
263
|
+
| `list-courses --count` | `Active courses: 3` + `Archived courses: 412` + per-year counts |
|
|
264
|
+
| `list-courses cisco --count` | counts of active + archived courses matching "cisco" |
|
|
265
|
+
| `list-courses --dir ~/Other` | scan `~/Other` for active courses instead of config `course_dirs` |
|
|
266
|
+
|
|
267
|
+
If there are no matching active or archived courses, it prints an empty-state
|
|
268
|
+
line (e.g. `No active courses found`, or `No active courses match: cisco` when
|
|
269
|
+
a name filter excludes everything).
|
|
239
270
|
|
|
240
271
|
### `unretire-course` — Restore a retired course
|
|
241
272
|
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import re
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from setup_course_github import __author__, __email__, __version__
|
|
6
|
+
from setup_course_github.config import CourseConfig, load_config
|
|
7
|
+
from setup_course_github.notebooks import date_range, find_notebooks
|
|
8
|
+
|
|
9
|
+
JUNK_NAMES = frozenset(
|
|
10
|
+
{"__pycache__", "build", "dist", "bin", "include", "lib", "node_modules"}
|
|
11
|
+
)
|
|
12
|
+
_YEAR_RE = re.compile(r"^\d{4}$")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _is_junk_name(name: str) -> bool:
|
|
16
|
+
"""True for hidden (dot-prefixed) or known build/cache directory names."""
|
|
17
|
+
return name.startswith(".") or name in JUNK_NAMES
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def is_course(path: Path) -> bool:
|
|
21
|
+
"""A course is a directory with a .git subdir and at least one notebook."""
|
|
22
|
+
if not (path / ".git").is_dir():
|
|
23
|
+
return False
|
|
24
|
+
ipynb_files, marimo_files = find_notebooks(path)
|
|
25
|
+
return bool(ipynb_files or marimo_files)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def is_archived_course(path: Path) -> bool:
|
|
29
|
+
"""An archived course: a non-junk directory containing at least one notebook.
|
|
30
|
+
|
|
31
|
+
Unlike active courses, a ``.git`` subdir is NOT required (archived repos may
|
|
32
|
+
have had it stripped).
|
|
33
|
+
"""
|
|
34
|
+
if _is_junk_name(path.name):
|
|
35
|
+
return False
|
|
36
|
+
ipynb_files, marimo_files = find_notebooks(path)
|
|
37
|
+
return bool(ipynb_files or marimo_files)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def course_summary_line(path: Path) -> str:
|
|
41
|
+
"""Return '<name> — <N> notebooks (<date range>)' for a course directory."""
|
|
42
|
+
ipynb_files, marimo_files = find_notebooks(path)
|
|
43
|
+
count = len(ipynb_files) + len(marimo_files)
|
|
44
|
+
span = date_range(ipynb_files + marimo_files)
|
|
45
|
+
return f"{path.name} — {count} notebooks ({span})"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _matches_names(name: str, patterns: list[str]) -> bool:
|
|
49
|
+
"""True if *name* (case-insensitively) contains any of *patterns*.
|
|
50
|
+
|
|
51
|
+
An empty *patterns* list matches everything.
|
|
52
|
+
"""
|
|
53
|
+
if not patterns:
|
|
54
|
+
return True
|
|
55
|
+
lowered = name.lower()
|
|
56
|
+
return any(p.lower() in lowered for p in patterns)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def filter_active(courses: list[Path], patterns: list[str]) -> list[Path]:
|
|
60
|
+
"""Keep only active-course paths whose name matches *patterns*."""
|
|
61
|
+
return [c for c in courses if _matches_names(c.name, patterns)]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def filter_archived(
|
|
65
|
+
archived: dict[str, list[Path]], years: list[str], patterns: list[str]
|
|
66
|
+
) -> dict[str, list[Path]]:
|
|
67
|
+
"""Filter the {year: courses} map by *years* (if non-empty) and *patterns*.
|
|
68
|
+
|
|
69
|
+
Years not in *years* are dropped; within a kept year, courses are filtered by
|
|
70
|
+
name; years left with no matching courses are omitted.
|
|
71
|
+
"""
|
|
72
|
+
result: dict[str, list[Path]] = {}
|
|
73
|
+
for year, courses in archived.items():
|
|
74
|
+
if years and year not in years:
|
|
75
|
+
continue
|
|
76
|
+
matched = [c for c in courses if _matches_names(c.name, patterns)]
|
|
77
|
+
if matched:
|
|
78
|
+
result[year] = matched
|
|
79
|
+
return result
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def archive_summary_line(archived: dict[str, list[Path]], patterns: list[str]) -> str:
|
|
83
|
+
"""One-line summary of the archived-course count and year span."""
|
|
84
|
+
total = sum(len(courses) for courses in archived.values())
|
|
85
|
+
match_clause = ""
|
|
86
|
+
if patterns:
|
|
87
|
+
quoted = ", ".join(f'"{p}"' for p in patterns)
|
|
88
|
+
match_clause = f" matching {quoted}"
|
|
89
|
+
if total == 0:
|
|
90
|
+
return f"Archived: none{match_clause}"
|
|
91
|
+
years = sorted(archived.keys())
|
|
92
|
+
span = years[0] if len(years) == 1 else f"{years[0]}–{years[-1]}"
|
|
93
|
+
noun = "course" if total == 1 else "courses"
|
|
94
|
+
return (
|
|
95
|
+
f"Archived: {total} {noun}{match_clause} across {span}"
|
|
96
|
+
" — use --archived to list them."
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def resolve_scan_dirs(cli_dirs: list[str], config: CourseConfig) -> list[Path]:
|
|
101
|
+
"""Choose which directories to scan: CLI args, else config, else cwd."""
|
|
102
|
+
if cli_dirs:
|
|
103
|
+
return [Path(d).expanduser() for d in cli_dirs]
|
|
104
|
+
if config.course_dirs:
|
|
105
|
+
return [Path(d).expanduser() for d in config.course_dirs]
|
|
106
|
+
return [Path(".")]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def find_active_courses(scan_dirs: list[Path]) -> list[Path]:
|
|
110
|
+
"""Return course directories found directly under each scan dir, sorted."""
|
|
111
|
+
courses: list[Path] = []
|
|
112
|
+
for scan_dir in scan_dirs:
|
|
113
|
+
if not scan_dir.is_dir():
|
|
114
|
+
continue
|
|
115
|
+
for child in sorted(scan_dir.iterdir()):
|
|
116
|
+
if child.is_dir() and is_course(child):
|
|
117
|
+
courses.append(child)
|
|
118
|
+
return courses
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def find_archived_courses(archive_path: Path) -> dict[str, list[Path]]:
|
|
122
|
+
"""Return {year: [course dirs]} under archive_path/<YYYY>/<course>.
|
|
123
|
+
|
|
124
|
+
Only directories named like a 4-digit year are treated as year groups, and
|
|
125
|
+
only ``is_archived_course`` children are included.
|
|
126
|
+
"""
|
|
127
|
+
result: dict[str, list[Path]] = {}
|
|
128
|
+
if not archive_path.is_dir():
|
|
129
|
+
return result
|
|
130
|
+
for year_dir in sorted(archive_path.iterdir()):
|
|
131
|
+
if not (year_dir.is_dir() and _YEAR_RE.match(year_dir.name)):
|
|
132
|
+
continue
|
|
133
|
+
courses = sorted(
|
|
134
|
+
c for c in year_dir.iterdir() if c.is_dir() and is_archived_course(c)
|
|
135
|
+
)
|
|
136
|
+
if courses:
|
|
137
|
+
result[year_dir.name] = courses
|
|
138
|
+
return result
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _year_arg(value: str) -> str:
|
|
142
|
+
"""argparse type: accept only a 4-digit year."""
|
|
143
|
+
if not _YEAR_RE.match(value):
|
|
144
|
+
raise argparse.ArgumentTypeError(f"invalid year '{value}' (expected 4 digits)")
|
|
145
|
+
return value
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def main(argv: list[str] | None = None) -> None:
|
|
149
|
+
pypi_url = "https://pypi.org/project/course-setup/"
|
|
150
|
+
author_line = f"{__author__} <{__email__}>"
|
|
151
|
+
|
|
152
|
+
parser = argparse.ArgumentParser(
|
|
153
|
+
description="List active and archived courses.",
|
|
154
|
+
epilog=f"Version {__version__} — {pypi_url}\n{author_line}",
|
|
155
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
156
|
+
)
|
|
157
|
+
parser.add_argument(
|
|
158
|
+
"--version",
|
|
159
|
+
action="version",
|
|
160
|
+
version=f"%(prog)s {__version__}\n{pypi_url}\n{author_line}",
|
|
161
|
+
)
|
|
162
|
+
parser.add_argument(
|
|
163
|
+
"names",
|
|
164
|
+
nargs="*",
|
|
165
|
+
help="Filter courses by case-insensitive name substring (any match)",
|
|
166
|
+
)
|
|
167
|
+
parser.add_argument(
|
|
168
|
+
"--dir",
|
|
169
|
+
action="append",
|
|
170
|
+
default=[],
|
|
171
|
+
metavar="PATH",
|
|
172
|
+
help="Directory to scan for active courses (repeatable; overrides config)",
|
|
173
|
+
)
|
|
174
|
+
parser.add_argument(
|
|
175
|
+
"--active",
|
|
176
|
+
action="store_true",
|
|
177
|
+
help="Show only active courses",
|
|
178
|
+
)
|
|
179
|
+
parser.add_argument(
|
|
180
|
+
"--archived",
|
|
181
|
+
action="store_true",
|
|
182
|
+
help="Show only archived courses (expanded, grouped by year)",
|
|
183
|
+
)
|
|
184
|
+
parser.add_argument(
|
|
185
|
+
"--year",
|
|
186
|
+
action="append",
|
|
187
|
+
default=[],
|
|
188
|
+
dest="years",
|
|
189
|
+
type=_year_arg,
|
|
190
|
+
metavar="YYYY",
|
|
191
|
+
help="Restrict archived courses to a year (repeatable; hides active)",
|
|
192
|
+
)
|
|
193
|
+
parser.add_argument(
|
|
194
|
+
"--count",
|
|
195
|
+
action="store_true",
|
|
196
|
+
help="Show counts instead of individual course lines",
|
|
197
|
+
)
|
|
198
|
+
args = parser.parse_args(argv)
|
|
199
|
+
|
|
200
|
+
config = load_config()
|
|
201
|
+
scan_dirs = resolve_scan_dirs(args.dir, config)
|
|
202
|
+
|
|
203
|
+
patterns: list[str] = args.names
|
|
204
|
+
has_names = bool(patterns)
|
|
205
|
+
has_years = bool(args.years)
|
|
206
|
+
only_active = args.active and not args.archived
|
|
207
|
+
only_archived = args.archived and not args.active
|
|
208
|
+
year_focus = has_years and not args.active and not args.archived
|
|
209
|
+
show_active = not (only_archived or year_focus)
|
|
210
|
+
show_archived = not only_active
|
|
211
|
+
archive_expanded = args.archived or has_years
|
|
212
|
+
|
|
213
|
+
if show_active:
|
|
214
|
+
active = filter_active(find_active_courses(scan_dirs), patterns)
|
|
215
|
+
if args.count:
|
|
216
|
+
print(f"Active courses: {len(active)}")
|
|
217
|
+
else:
|
|
218
|
+
print("Active courses:")
|
|
219
|
+
if active:
|
|
220
|
+
for course in active:
|
|
221
|
+
print(f" {course_summary_line(course)}")
|
|
222
|
+
elif has_names:
|
|
223
|
+
print(f" No active courses match: {', '.join(patterns)}")
|
|
224
|
+
else:
|
|
225
|
+
print(" No active courses found")
|
|
226
|
+
|
|
227
|
+
if show_archived:
|
|
228
|
+
archived = filter_archived(
|
|
229
|
+
find_archived_courses(config.archive_path), args.years, patterns
|
|
230
|
+
)
|
|
231
|
+
if show_active:
|
|
232
|
+
print()
|
|
233
|
+
if args.count:
|
|
234
|
+
total = sum(len(courses) for courses in archived.values())
|
|
235
|
+
print(f"Archived courses: {total}")
|
|
236
|
+
for year in sorted(archived):
|
|
237
|
+
print(f" {year}: {len(archived[year])}")
|
|
238
|
+
elif archive_expanded:
|
|
239
|
+
print("Archived courses:")
|
|
240
|
+
if archived:
|
|
241
|
+
for year in sorted(archived):
|
|
242
|
+
print(f" {year}:")
|
|
243
|
+
for course in archived[year]:
|
|
244
|
+
print(f" {course_summary_line(course)}")
|
|
245
|
+
elif has_names or has_years:
|
|
246
|
+
print(" No archived courses match your filters")
|
|
247
|
+
else:
|
|
248
|
+
print(" No archived courses found")
|
|
249
|
+
else:
|
|
250
|
+
print(archive_summary_line(archived, patterns))
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
if __name__ == "__main__": # pragma: no cover
|
|
254
|
+
main()
|
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
import argparse
|
|
2
|
-
from pathlib import Path
|
|
3
|
-
|
|
4
|
-
from setup_course_github import __author__, __email__, __version__
|
|
5
|
-
from setup_course_github.config import CourseConfig, load_config
|
|
6
|
-
from setup_course_github.notebooks import date_range, find_notebooks
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
def is_course(path: Path) -> bool:
|
|
10
|
-
"""A course is a directory with a .git subdir and at least one notebook."""
|
|
11
|
-
if not (path / ".git").is_dir():
|
|
12
|
-
return False
|
|
13
|
-
ipynb_files, marimo_files = find_notebooks(path)
|
|
14
|
-
return bool(ipynb_files or marimo_files)
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def course_summary_line(path: Path) -> str:
|
|
18
|
-
"""Return '<name> — <N> notebooks (<date range>)' for a course directory."""
|
|
19
|
-
ipynb_files, marimo_files = find_notebooks(path)
|
|
20
|
-
count = len(ipynb_files) + len(marimo_files)
|
|
21
|
-
span = date_range(ipynb_files + marimo_files)
|
|
22
|
-
return f"{path.name} — {count} notebooks ({span})"
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def resolve_scan_dirs(cli_dirs: list[str], config: CourseConfig) -> list[Path]:
|
|
26
|
-
"""Choose which directories to scan: CLI args, else config, else cwd."""
|
|
27
|
-
if cli_dirs:
|
|
28
|
-
return [Path(d).expanduser() for d in cli_dirs]
|
|
29
|
-
if config.course_dirs:
|
|
30
|
-
return [Path(d).expanduser() for d in config.course_dirs]
|
|
31
|
-
return [Path(".")]
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def find_active_courses(scan_dirs: list[Path]) -> list[Path]:
|
|
35
|
-
"""Return course directories found directly under each scan dir, sorted."""
|
|
36
|
-
courses: list[Path] = []
|
|
37
|
-
for scan_dir in scan_dirs:
|
|
38
|
-
if not scan_dir.is_dir():
|
|
39
|
-
continue
|
|
40
|
-
for child in sorted(scan_dir.iterdir()):
|
|
41
|
-
if child.is_dir() and is_course(child):
|
|
42
|
-
courses.append(child)
|
|
43
|
-
return courses
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def find_archived_courses(archive_path: Path) -> dict[str, list[Path]]:
|
|
47
|
-
"""Return {year: [course dirs]} found under archive_path/<year>/<course>."""
|
|
48
|
-
result: dict[str, list[Path]] = {}
|
|
49
|
-
if not archive_path.is_dir():
|
|
50
|
-
return result
|
|
51
|
-
for year_dir in sorted(archive_path.iterdir()):
|
|
52
|
-
if not year_dir.is_dir():
|
|
53
|
-
continue
|
|
54
|
-
courses = sorted(c for c in year_dir.iterdir() if c.is_dir())
|
|
55
|
-
if courses:
|
|
56
|
-
result[year_dir.name] = courses
|
|
57
|
-
return result
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
def main(argv: list[str] | None = None) -> None:
|
|
61
|
-
pypi_url = "https://pypi.org/project/course-setup/"
|
|
62
|
-
author_line = f"{__author__} <{__email__}>"
|
|
63
|
-
|
|
64
|
-
parser = argparse.ArgumentParser(
|
|
65
|
-
description="List active and archived courses.",
|
|
66
|
-
epilog=f"Version {__version__} — {pypi_url}\n{author_line}",
|
|
67
|
-
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
68
|
-
)
|
|
69
|
-
parser.add_argument(
|
|
70
|
-
"--version",
|
|
71
|
-
action="version",
|
|
72
|
-
version=f"%(prog)s {__version__}\n{pypi_url}\n{author_line}",
|
|
73
|
-
)
|
|
74
|
-
parser.add_argument(
|
|
75
|
-
"dirs",
|
|
76
|
-
nargs="*",
|
|
77
|
-
help="Directories to scan (overrides course_dirs from config)",
|
|
78
|
-
)
|
|
79
|
-
args = parser.parse_args(argv)
|
|
80
|
-
|
|
81
|
-
config = load_config()
|
|
82
|
-
scan_dirs = resolve_scan_dirs(args.dirs, config)
|
|
83
|
-
|
|
84
|
-
active = find_active_courses(scan_dirs)
|
|
85
|
-
print("Active courses:")
|
|
86
|
-
if active:
|
|
87
|
-
for course in active:
|
|
88
|
-
print(f" {course_summary_line(course)}")
|
|
89
|
-
else:
|
|
90
|
-
print(" No active courses found")
|
|
91
|
-
|
|
92
|
-
archived = find_archived_courses(config.archive_path)
|
|
93
|
-
print("\nArchived courses:")
|
|
94
|
-
if archived:
|
|
95
|
-
for year, courses in archived.items():
|
|
96
|
-
print(f" {year}:")
|
|
97
|
-
for course in courses:
|
|
98
|
-
print(f" {course_summary_line(course)}")
|
|
99
|
-
else:
|
|
100
|
-
print(" No archived courses found")
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
if __name__ == "__main__": # pragma: no cover
|
|
104
|
-
main()
|
|
File without changes
|
{course_setup-3.1.0 → course_setup-3.2.1}/src/setup_course_github/.claude/settings.local.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{course_setup-3.1.0 → course_setup-3.2.1}/src/setup_course_github/generic/Course notebook.ipynb
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|