course-setup 3.1.0__tar.gz → 3.2.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.
Files changed (19) hide show
  1. {course_setup-3.1.0 → course_setup-3.2.0}/PKG-INFO +50 -19
  2. {course_setup-3.1.0 → course_setup-3.2.0}/README.md +49 -18
  3. {course_setup-3.1.0 → course_setup-3.2.0}/pyproject.toml +1 -1
  4. course_setup-3.2.0/src/setup_course_github/list_courses.py +253 -0
  5. course_setup-3.1.0/src/setup_course_github/list_courses.py +0 -104
  6. {course_setup-3.1.0 → course_setup-3.2.0}/src/setup_course_github/.DS_Store +0 -0
  7. {course_setup-3.1.0 → course_setup-3.2.0}/src/setup_course_github/.claude/settings.local.json +0 -0
  8. {course_setup-3.1.0 → course_setup-3.2.0}/src/setup_course_github/__init__.py +0 -0
  9. {course_setup-3.1.0 → course_setup-3.2.0}/src/setup_course_github/archive_course.py +0 -0
  10. {course_setup-3.1.0 → course_setup-3.2.0}/src/setup_course_github/config.py +0 -0
  11. {course_setup-3.1.0 → course_setup-3.2.0}/src/setup_course_github/generic/.gitignore +0 -0
  12. {course_setup-3.1.0 → course_setup-3.2.0}/src/setup_course_github/generic/.ipynb_checkpoints/Course notebook-checkpoint.ipynb +0 -0
  13. {course_setup-3.1.0 → course_setup-3.2.0}/src/setup_course_github/generic/Course notebook.ipynb +0 -0
  14. {course_setup-3.1.0 → course_setup-3.2.0}/src/setup_course_github/generic/README.md +0 -0
  15. {course_setup-3.1.0 → course_setup-3.2.0}/src/setup_course_github/init_config.py +0 -0
  16. {course_setup-3.1.0 → course_setup-3.2.0}/src/setup_course_github/notebooks.py +0 -0
  17. {course_setup-3.1.0 → course_setup-3.2.0}/src/setup_course_github/retire_course.py +0 -0
  18. {course_setup-3.1.0 → course_setup-3.2.0}/src/setup_course_github/setup_course.py +0 -0
  19. {course_setup-3.1.0 → course_setup-3.2.0}/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.0
3
+ Version: 3.2.0
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). It lists:
229
+ reads your config file but does not use the network).
230
230
 
231
- - **Active courses**: for each scan directory, its immediate subdirectories
232
- that qualify as a *course* a directory containing both a `.git`
233
- subdirectory and at least one notebook (`.ipynb`, or a marimo `.py`).
234
- - **Archived courses**: courses found under `{archive}/{year}/{course}`,
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
- Each course is shown as:
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
- name — N notebooks (first-date → last-date)
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
- with dates parsed from notebook filenames (`n/a` if none are found). If there
244
- are no active or archived courses, it prints `No active courses found` /
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
- | `DIR...` | Optional directories to scan (overrides `course_dirs` from config) |
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. Positional directories passed on the command line (`list-courses DIR ...`)
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
- Like the other commands, `list-courses --version` prints the version number,
258
- PyPI URL, author name, and email.
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). It lists:
209
+ reads your config file but does not use the network).
210
210
 
211
- - **Active courses**: for each scan directory, its immediate subdirectories
212
- that qualify as a *course* a directory containing both a `.git`
213
- subdirectory and at least one notebook (`.ipynb`, or a marimo `.py`).
214
- - **Archived courses**: courses found under `{archive}/{year}/{course}`,
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
- Each course is shown as:
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
- name — N notebooks (first-date → last-date)
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
- with dates parsed from notebook filenames (`n/a` if none are found). If there
224
- are no active or archived courses, it prints `No active courses found` /
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
- | `DIR...` | Optional directories to scan (overrides `course_dirs` from config) |
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. Positional directories passed on the command line (`list-courses DIR ...`)
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
- Like the other commands, `list-courses --version` prints the version number,
238
- PyPI URL, author name, and email.
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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "course-setup"
3
- version = "3.1.0"
3
+ version = "3.2.0"
4
4
  description = "CLI tools for setting up and retiring GitHub-backed course repositories"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -0,0 +1,253 @@
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
+ return (
94
+ f"Archived: {total} courses{match_clause} across {span}"
95
+ " — use --archived to list them."
96
+ )
97
+
98
+
99
+ def resolve_scan_dirs(cli_dirs: list[str], config: CourseConfig) -> list[Path]:
100
+ """Choose which directories to scan: CLI args, else config, else cwd."""
101
+ if cli_dirs:
102
+ return [Path(d).expanduser() for d in cli_dirs]
103
+ if config.course_dirs:
104
+ return [Path(d).expanduser() for d in config.course_dirs]
105
+ return [Path(".")]
106
+
107
+
108
+ def find_active_courses(scan_dirs: list[Path]) -> list[Path]:
109
+ """Return course directories found directly under each scan dir, sorted."""
110
+ courses: list[Path] = []
111
+ for scan_dir in scan_dirs:
112
+ if not scan_dir.is_dir():
113
+ continue
114
+ for child in sorted(scan_dir.iterdir()):
115
+ if child.is_dir() and is_course(child):
116
+ courses.append(child)
117
+ return courses
118
+
119
+
120
+ def find_archived_courses(archive_path: Path) -> dict[str, list[Path]]:
121
+ """Return {year: [course dirs]} under archive_path/<YYYY>/<course>.
122
+
123
+ Only directories named like a 4-digit year are treated as year groups, and
124
+ only ``is_archived_course`` children are included.
125
+ """
126
+ result: dict[str, list[Path]] = {}
127
+ if not archive_path.is_dir():
128
+ return result
129
+ for year_dir in sorted(archive_path.iterdir()):
130
+ if not (year_dir.is_dir() and _YEAR_RE.match(year_dir.name)):
131
+ continue
132
+ courses = sorted(
133
+ c for c in year_dir.iterdir() if c.is_dir() and is_archived_course(c)
134
+ )
135
+ if courses:
136
+ result[year_dir.name] = courses
137
+ return result
138
+
139
+
140
+ def _year_arg(value: str) -> str:
141
+ """argparse type: accept only a 4-digit year."""
142
+ if not _YEAR_RE.match(value):
143
+ raise argparse.ArgumentTypeError(f"invalid year '{value}' (expected 4 digits)")
144
+ return value
145
+
146
+
147
+ def main(argv: list[str] | None = None) -> None:
148
+ pypi_url = "https://pypi.org/project/course-setup/"
149
+ author_line = f"{__author__} <{__email__}>"
150
+
151
+ parser = argparse.ArgumentParser(
152
+ description="List active and archived courses.",
153
+ epilog=f"Version {__version__} — {pypi_url}\n{author_line}",
154
+ formatter_class=argparse.RawDescriptionHelpFormatter,
155
+ )
156
+ parser.add_argument(
157
+ "--version",
158
+ action="version",
159
+ version=f"%(prog)s {__version__}\n{pypi_url}\n{author_line}",
160
+ )
161
+ parser.add_argument(
162
+ "names",
163
+ nargs="*",
164
+ help="Filter courses by case-insensitive name substring (any match)",
165
+ )
166
+ parser.add_argument(
167
+ "--dir",
168
+ action="append",
169
+ default=[],
170
+ metavar="PATH",
171
+ help="Directory to scan for active courses (repeatable; overrides config)",
172
+ )
173
+ parser.add_argument(
174
+ "--active",
175
+ action="store_true",
176
+ help="Show only active courses",
177
+ )
178
+ parser.add_argument(
179
+ "--archived",
180
+ action="store_true",
181
+ help="Show only archived courses (expanded, grouped by year)",
182
+ )
183
+ parser.add_argument(
184
+ "--year",
185
+ action="append",
186
+ default=[],
187
+ dest="years",
188
+ type=_year_arg,
189
+ metavar="YYYY",
190
+ help="Restrict archived courses to a year (repeatable; hides active)",
191
+ )
192
+ parser.add_argument(
193
+ "--count",
194
+ action="store_true",
195
+ help="Show counts instead of individual course lines",
196
+ )
197
+ args = parser.parse_args(argv)
198
+
199
+ config = load_config()
200
+ scan_dirs = resolve_scan_dirs(args.dir, config)
201
+
202
+ patterns: list[str] = args.names
203
+ has_names = bool(patterns)
204
+ has_years = bool(args.years)
205
+ only_active = args.active and not args.archived
206
+ only_archived = args.archived and not args.active
207
+ year_focus = has_years and not args.active and not args.archived
208
+ show_active = not (only_archived or year_focus)
209
+ show_archived = not only_active
210
+ archive_expanded = args.archived or has_years
211
+
212
+ if show_active:
213
+ active = filter_active(find_active_courses(scan_dirs), patterns)
214
+ if args.count:
215
+ print(f"Active courses: {len(active)}")
216
+ else:
217
+ print("Active courses:")
218
+ if active:
219
+ for course in active:
220
+ print(f" {course_summary_line(course)}")
221
+ elif has_names:
222
+ print(f" No active courses match: {', '.join(patterns)}")
223
+ else:
224
+ print(" No active courses found")
225
+
226
+ if show_archived:
227
+ archived = filter_archived(
228
+ find_archived_courses(config.archive_path), args.years, patterns
229
+ )
230
+ if show_active:
231
+ print()
232
+ if args.count:
233
+ total = sum(len(courses) for courses in archived.values())
234
+ print(f"Archived courses: {total}")
235
+ for year in sorted(archived):
236
+ print(f" {year}: {len(archived[year])}")
237
+ elif archive_expanded:
238
+ print("Archived courses:")
239
+ if archived:
240
+ for year in sorted(archived):
241
+ print(f" {year}:")
242
+ for course in archived[year]:
243
+ print(f" {course_summary_line(course)}")
244
+ elif has_names or has_years:
245
+ print(" No archived courses match your filters")
246
+ else:
247
+ print(" No archived courses found")
248
+ else:
249
+ print(archive_summary_line(archived, patterns))
250
+
251
+
252
+ if __name__ == "__main__": # pragma: no cover
253
+ 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()