course-setup 3.0.14__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.
- {course_setup-3.0.14 → course_setup-3.2.0}/PKG-INFO +94 -8
- {course_setup-3.0.14 → course_setup-3.2.0}/README.md +93 -7
- {course_setup-3.0.14 → course_setup-3.2.0}/pyproject.toml +2 -1
- {course_setup-3.0.14 → course_setup-3.2.0}/src/setup_course_github/archive_course.py +48 -10
- {course_setup-3.0.14 → course_setup-3.2.0}/src/setup_course_github/config.py +8 -0
- {course_setup-3.0.14 → course_setup-3.2.0}/src/setup_course_github/init_config.py +8 -0
- course_setup-3.2.0/src/setup_course_github/list_courses.py +253 -0
- course_setup-3.2.0/src/setup_course_github/notebooks.py +34 -0
- {course_setup-3.0.14 → course_setup-3.2.0}/src/setup_course_github/retire_course.py +30 -28
- {course_setup-3.0.14 → course_setup-3.2.0}/src/setup_course_github/.DS_Store +0 -0
- {course_setup-3.0.14 → course_setup-3.2.0}/src/setup_course_github/.claude/settings.local.json +0 -0
- {course_setup-3.0.14 → course_setup-3.2.0}/src/setup_course_github/__init__.py +0 -0
- {course_setup-3.0.14 → course_setup-3.2.0}/src/setup_course_github/generic/.gitignore +0 -0
- {course_setup-3.0.14 → course_setup-3.2.0}/src/setup_course_github/generic/.ipynb_checkpoints/Course notebook-checkpoint.ipynb +0 -0
- {course_setup-3.0.14 → course_setup-3.2.0}/src/setup_course_github/generic/Course notebook.ipynb +0 -0
- {course_setup-3.0.14 → course_setup-3.2.0}/src/setup_course_github/generic/README.md +0 -0
- {course_setup-3.0.14 → course_setup-3.2.0}/src/setup_course_github/setup_course.py +0 -0
- {course_setup-3.0.14 → 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.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>
|
|
@@ -33,9 +33,9 @@ uv tool install course-setup
|
|
|
33
33
|
```
|
|
34
34
|
|
|
35
35
|
This makes `setup-course`, `retire-course`, `unretire-course`,
|
|
36
|
-
`archive-course`, and `setup-course-config` available on your
|
|
37
|
-
commands support `--version` and `--help`, which display the
|
|
38
|
-
PyPI URL, author name (Reuven Lerner), and email. To upgrade:
|
|
36
|
+
`archive-course`, `list-courses`, and `setup-course-config` available on your
|
|
37
|
+
PATH. All six commands support `--version` and `--help`, which display the
|
|
38
|
+
version number, PyPI URL, author name (Reuven Lerner), and email. To upgrade:
|
|
39
39
|
|
|
40
40
|
```bash
|
|
41
41
|
uv tool upgrade course-setup
|
|
@@ -74,6 +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. Overridden by `--dir` on the `list-courses` command line. |
|
|
77
78
|
| `[defaults] notebook_type` | No | `"jupyter"` (default) or `"marimo"`. |
|
|
78
79
|
| `[defaults] verbose` | No | `true` or `false` (default). Sets the default verbosity for `setup-course`. |
|
|
79
80
|
| `[defaults] private` | No | `true` or `false` (default). When `true`, `setup-course` creates private GitHub repos by default. |
|
|
@@ -162,6 +163,7 @@ retire-course ./Acme-python-intro-2026-03
|
|
|
162
163
|
|-----------------|-------------|
|
|
163
164
|
| `DIRNAME...` | One or more course directories to retire |
|
|
164
165
|
| `--keep-public` | Archive without making the GitHub repo private |
|
|
166
|
+
| `--dry-run` | Preview the retirement without making any changes |
|
|
165
167
|
|
|
166
168
|
This will (for each directory):
|
|
167
169
|
|
|
@@ -171,6 +173,13 @@ This will (for each directory):
|
|
|
171
173
|
3. Print a retirement summary showing: notebook count, date range, dependencies,
|
|
172
174
|
archive location, and GitHub URL
|
|
173
175
|
|
|
176
|
+
With `--dry-run`, none of that happens: the GitHub repo is not touched, the
|
|
177
|
+
archive directory is not created, and the course directory is not moved.
|
|
178
|
+
Instead, `retire-course` prints a `[DRY RUN]` banner followed by the same
|
|
179
|
+
retirement summary the real run would show. It reads your config file but makes
|
|
180
|
+
no GitHub API calls, so a dry run touches nothing on the network. `--dry-run`
|
|
181
|
+
applies to every directory passed on the command line.
|
|
182
|
+
|
|
174
183
|
You can retire multiple courses at once:
|
|
175
184
|
|
|
176
185
|
```bash
|
|
@@ -193,14 +202,91 @@ archive-course ./Acme-python-intro-2026-03
|
|
|
193
202
|
|------|-------------|
|
|
194
203
|
| `--output`, `-o` | Custom output zip path (defaults to `{dirname}.zip`) |
|
|
195
204
|
| `--no-html` | Skip HTML export of Jupyter notebooks |
|
|
205
|
+
| `--no-pdf` | Skip PDF export of Jupyter notebooks |
|
|
196
206
|
|
|
197
207
|
This archives **all files** in the course directory (`.py`, `.csv`, `.toml`,
|
|
198
208
|
notebooks, etc.), excluding `.git`, `.venv`, `__pycache__`, and
|
|
199
209
|
`.ipynb_checkpoints` directories. For Jupyter notebooks, each `.ipynb` is also
|
|
200
|
-
exported to HTML
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
210
|
+
exported to HTML and to PDF, and both are included alongside the original;
|
|
211
|
+
use `--no-html` / `--no-pdf` to skip either export. PDF export is **on by
|
|
212
|
+
default**, via `nbconvert --to webpdf` (headless Chromium — no LaTeX
|
|
213
|
+
installation required). The first time you export to PDF, you may need to
|
|
214
|
+
run `uv run playwright install chromium` inside the course directory to
|
|
215
|
+
download the Chromium binary. If the PDF engine isn't available, `archive-course`
|
|
216
|
+
prints a warning and skips PDF for that notebook rather than failing — the
|
|
217
|
+
rest of the archive is still produced. After creating the archive, a summary
|
|
218
|
+
is printed listing the archive path, file count, size, notebooks (shown as
|
|
219
|
+
`notebook.ipynb + notebook.html + notebook.pdf`), export counts, and all
|
|
220
|
+
other included files.
|
|
221
|
+
|
|
222
|
+
### `list-courses` — List active and archived courses
|
|
223
|
+
|
|
224
|
+
```bash
|
|
225
|
+
list-courses
|
|
226
|
+
```
|
|
227
|
+
|
|
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).
|
|
230
|
+
|
|
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`.
|
|
235
|
+
|
|
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):
|
|
242
|
+
|
|
243
|
+
```
|
|
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.
|
|
249
|
+
```
|
|
250
|
+
|
|
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).
|
|
253
|
+
|
|
254
|
+
| Argument / Option | Description |
|
|
255
|
+
|----------|-------------|
|
|
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 |
|
|
263
|
+
|
|
264
|
+
Directories to scan for active courses are resolved in this order:
|
|
265
|
+
|
|
266
|
+
1. `--dir PATH` values passed on the command line (repeatable)
|
|
267
|
+
2. Otherwise, `[paths] course_dirs` from your config file
|
|
268
|
+
3. Otherwise, the current directory
|
|
269
|
+
|
|
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).
|
|
204
290
|
|
|
205
291
|
### `unretire-course` — Restore a retired course
|
|
206
292
|
|
|
@@ -13,9 +13,9 @@ uv tool install course-setup
|
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
This makes `setup-course`, `retire-course`, `unretire-course`,
|
|
16
|
-
`archive-course`, and `setup-course-config` available on your
|
|
17
|
-
commands support `--version` and `--help`, which display the
|
|
18
|
-
PyPI URL, author name (Reuven Lerner), and email. To upgrade:
|
|
16
|
+
`archive-course`, `list-courses`, and `setup-course-config` available on your
|
|
17
|
+
PATH. All six commands support `--version` and `--help`, which display the
|
|
18
|
+
version number, PyPI URL, author name (Reuven Lerner), and email. To upgrade:
|
|
19
19
|
|
|
20
20
|
```bash
|
|
21
21
|
uv tool upgrade course-setup
|
|
@@ -54,6 +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. Overridden by `--dir` on the `list-courses` command line. |
|
|
57
58
|
| `[defaults] notebook_type` | No | `"jupyter"` (default) or `"marimo"`. |
|
|
58
59
|
| `[defaults] verbose` | No | `true` or `false` (default). Sets the default verbosity for `setup-course`. |
|
|
59
60
|
| `[defaults] private` | No | `true` or `false` (default). When `true`, `setup-course` creates private GitHub repos by default. |
|
|
@@ -142,6 +143,7 @@ retire-course ./Acme-python-intro-2026-03
|
|
|
142
143
|
|-----------------|-------------|
|
|
143
144
|
| `DIRNAME...` | One or more course directories to retire |
|
|
144
145
|
| `--keep-public` | Archive without making the GitHub repo private |
|
|
146
|
+
| `--dry-run` | Preview the retirement without making any changes |
|
|
145
147
|
|
|
146
148
|
This will (for each directory):
|
|
147
149
|
|
|
@@ -151,6 +153,13 @@ This will (for each directory):
|
|
|
151
153
|
3. Print a retirement summary showing: notebook count, date range, dependencies,
|
|
152
154
|
archive location, and GitHub URL
|
|
153
155
|
|
|
156
|
+
With `--dry-run`, none of that happens: the GitHub repo is not touched, the
|
|
157
|
+
archive directory is not created, and the course directory is not moved.
|
|
158
|
+
Instead, `retire-course` prints a `[DRY RUN]` banner followed by the same
|
|
159
|
+
retirement summary the real run would show. It reads your config file but makes
|
|
160
|
+
no GitHub API calls, so a dry run touches nothing on the network. `--dry-run`
|
|
161
|
+
applies to every directory passed on the command line.
|
|
162
|
+
|
|
154
163
|
You can retire multiple courses at once:
|
|
155
164
|
|
|
156
165
|
```bash
|
|
@@ -173,14 +182,91 @@ archive-course ./Acme-python-intro-2026-03
|
|
|
173
182
|
|------|-------------|
|
|
174
183
|
| `--output`, `-o` | Custom output zip path (defaults to `{dirname}.zip`) |
|
|
175
184
|
| `--no-html` | Skip HTML export of Jupyter notebooks |
|
|
185
|
+
| `--no-pdf` | Skip PDF export of Jupyter notebooks |
|
|
176
186
|
|
|
177
187
|
This archives **all files** in the course directory (`.py`, `.csv`, `.toml`,
|
|
178
188
|
notebooks, etc.), excluding `.git`, `.venv`, `__pycache__`, and
|
|
179
189
|
`.ipynb_checkpoints` directories. For Jupyter notebooks, each `.ipynb` is also
|
|
180
|
-
exported to HTML
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
190
|
+
exported to HTML and to PDF, and both are included alongside the original;
|
|
191
|
+
use `--no-html` / `--no-pdf` to skip either export. PDF export is **on by
|
|
192
|
+
default**, via `nbconvert --to webpdf` (headless Chromium — no LaTeX
|
|
193
|
+
installation required). The first time you export to PDF, you may need to
|
|
194
|
+
run `uv run playwright install chromium` inside the course directory to
|
|
195
|
+
download the Chromium binary. If the PDF engine isn't available, `archive-course`
|
|
196
|
+
prints a warning and skips PDF for that notebook rather than failing — the
|
|
197
|
+
rest of the archive is still produced. After creating the archive, a summary
|
|
198
|
+
is printed listing the archive path, file count, size, notebooks (shown as
|
|
199
|
+
`notebook.ipynb + notebook.html + notebook.pdf`), export counts, and all
|
|
200
|
+
other included files.
|
|
201
|
+
|
|
202
|
+
### `list-courses` — List active and archived courses
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
list-courses
|
|
206
|
+
```
|
|
207
|
+
|
|
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).
|
|
210
|
+
|
|
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`.
|
|
215
|
+
|
|
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):
|
|
222
|
+
|
|
223
|
+
```
|
|
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.
|
|
229
|
+
```
|
|
230
|
+
|
|
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).
|
|
233
|
+
|
|
234
|
+
| Argument / Option | Description |
|
|
235
|
+
|----------|-------------|
|
|
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 |
|
|
243
|
+
|
|
244
|
+
Directories to scan for active courses are resolved in this order:
|
|
245
|
+
|
|
246
|
+
1. `--dir PATH` values passed on the command line (repeatable)
|
|
247
|
+
2. Otherwise, `[paths] course_dirs` from your config file
|
|
248
|
+
3. Otherwise, the current directory
|
|
249
|
+
|
|
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).
|
|
184
270
|
|
|
185
271
|
### `unretire-course` — Restore a retired course
|
|
186
272
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "course-setup"
|
|
3
|
-
version = "3.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"
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
setup-course-config = "setup_course_github.init_config:main"
|
|
32
32
|
unretire-course = "setup_course_github.unretire_course:main"
|
|
33
33
|
archive-course = "setup_course_github.archive_course:main"
|
|
34
|
+
list-courses = "setup_course_github.list_courses:main"
|
|
34
35
|
|
|
35
36
|
|
|
36
37
|
[build-system]
|
|
@@ -8,14 +8,17 @@ from pathlib import Path
|
|
|
8
8
|
from setup_course_github import __author__, __email__, __version__
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
def
|
|
12
|
-
"""Export a
|
|
11
|
+
def _export_notebook(nb_path: Path, course_path: Path, fmt: str, label: str) -> bool:
|
|
12
|
+
"""Export a notebook to *fmt* via nbconvert. Returns True on success.
|
|
13
|
+
|
|
14
|
+
*label* is the human-readable format name used in warning messages.
|
|
15
|
+
"""
|
|
13
16
|
# Use the notebook's relative path from the course dir so nbconvert
|
|
14
17
|
# can find it regardless of spaces in the name.
|
|
15
18
|
relative = nb_path.relative_to(course_path)
|
|
16
19
|
try:
|
|
17
20
|
subprocess.run(
|
|
18
|
-
["uv", "run", "jupyter", "nbconvert", "--to",
|
|
21
|
+
["uv", "run", "jupyter", "nbconvert", "--to", fmt, str(relative)],
|
|
19
22
|
cwd=str(course_path),
|
|
20
23
|
capture_output=True,
|
|
21
24
|
check=True,
|
|
@@ -23,21 +26,34 @@ def _export_notebook_to_html(nb_path: Path, course_path: Path) -> bool:
|
|
|
23
26
|
return True
|
|
24
27
|
except subprocess.CalledProcessError as exc:
|
|
25
28
|
stderr = exc.stderr.decode() if exc.stderr else ""
|
|
26
|
-
print(
|
|
29
|
+
print(
|
|
30
|
+
f" Warning: failed to export {nb_path.name} to {label}: {stderr.strip()}"
|
|
31
|
+
)
|
|
27
32
|
return False
|
|
28
33
|
except FileNotFoundError:
|
|
29
|
-
print(" Warning: jupyter nbconvert not found, skipping
|
|
34
|
+
print(f" Warning: jupyter nbconvert not found, skipping {label} export")
|
|
30
35
|
return False
|
|
31
36
|
|
|
32
37
|
|
|
38
|
+
def _export_notebook_to_html(nb_path: Path, course_path: Path) -> bool:
|
|
39
|
+
"""Export a single notebook to HTML. Returns True on success."""
|
|
40
|
+
return _export_notebook(nb_path, course_path, "html", "HTML")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _export_notebook_to_pdf(nb_path: Path, course_path: Path) -> bool:
|
|
44
|
+
"""Export a single notebook to PDF via webpdf. Returns True on success."""
|
|
45
|
+
return _export_notebook(nb_path, course_path, "webpdf", "PDF")
|
|
46
|
+
|
|
47
|
+
|
|
33
48
|
def archive_course(
|
|
34
49
|
dirname: str,
|
|
35
50
|
output: str | None = None,
|
|
36
51
|
export_html: bool = True,
|
|
52
|
+
export_pdf: bool = True,
|
|
37
53
|
) -> Path:
|
|
38
54
|
"""Create a zip archive of a course directory.
|
|
39
55
|
|
|
40
|
-
Optionally exports notebooks to HTML first.
|
|
56
|
+
Optionally exports notebooks to HTML and/or PDF first.
|
|
41
57
|
"""
|
|
42
58
|
course_path = Path(dirname)
|
|
43
59
|
if not course_path.is_dir():
|
|
@@ -58,6 +74,14 @@ def archive_course(
|
|
|
58
74
|
if _export_notebook_to_html(nb_path, course_path):
|
|
59
75
|
html_exported += 1
|
|
60
76
|
|
|
77
|
+
# Export notebooks to PDF if requested
|
|
78
|
+
pdf_exported = 0
|
|
79
|
+
if export_pdf and notebooks:
|
|
80
|
+
print("Exporting notebooks to PDF...")
|
|
81
|
+
for nb_path in notebooks:
|
|
82
|
+
if _export_notebook_to_pdf(nb_path, course_path):
|
|
83
|
+
pdf_exported += 1
|
|
84
|
+
|
|
61
85
|
# Determine output path
|
|
62
86
|
if output is not None:
|
|
63
87
|
zip_path = Path(output)
|
|
@@ -88,15 +112,17 @@ def archive_course(
|
|
|
88
112
|
else f"{zip_size / (1024 * 1024):.1f} MB"
|
|
89
113
|
)
|
|
90
114
|
|
|
91
|
-
# Collect non-notebook, non-HTML files for the summary
|
|
115
|
+
# Collect non-notebook, non-HTML, non-PDF files for the summary
|
|
92
116
|
notebook_names = {nb.name for nb in notebooks}
|
|
93
117
|
html_names = {nb.with_suffix(".html").name for nb in notebooks}
|
|
118
|
+
pdf_names = {nb.with_suffix(".pdf").name for nb in notebooks}
|
|
94
119
|
with zipfile.ZipFile(zip_path, "r") as zf:
|
|
95
120
|
other_files = sorted(
|
|
96
121
|
Path(name).name
|
|
97
122
|
for name in zf.namelist()
|
|
98
123
|
if Path(name).name not in notebook_names
|
|
99
124
|
and Path(name).name not in html_names
|
|
125
|
+
and Path(name).name not in pdf_names
|
|
100
126
|
)
|
|
101
127
|
|
|
102
128
|
# Print summary
|
|
@@ -107,13 +133,18 @@ def archive_course(
|
|
|
107
133
|
if notebooks:
|
|
108
134
|
print("Notebooks:")
|
|
109
135
|
for nb in notebooks:
|
|
136
|
+
parts = [nb.name]
|
|
110
137
|
html_path = nb.with_suffix(".html")
|
|
111
138
|
if export_html and html_path.exists():
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
139
|
+
parts.append(html_path.name)
|
|
140
|
+
pdf_path = nb.with_suffix(".pdf")
|
|
141
|
+
if export_pdf and pdf_path.exists():
|
|
142
|
+
parts.append(pdf_path.name)
|
|
143
|
+
print(" " + " + ".join(parts))
|
|
115
144
|
if export_html and html_exported > 0:
|
|
116
145
|
print(f"HTML exports: {html_exported}")
|
|
146
|
+
if export_pdf and pdf_exported > 0:
|
|
147
|
+
print(f"PDF exports: {pdf_exported}")
|
|
117
148
|
|
|
118
149
|
if other_files:
|
|
119
150
|
print("Other files:")
|
|
@@ -153,12 +184,19 @@ def main() -> None:
|
|
|
153
184
|
default=False,
|
|
154
185
|
help="Skip HTML export of notebooks",
|
|
155
186
|
)
|
|
187
|
+
parser.add_argument(
|
|
188
|
+
"--no-pdf",
|
|
189
|
+
action="store_true",
|
|
190
|
+
default=False,
|
|
191
|
+
help="Skip PDF export of notebooks",
|
|
192
|
+
)
|
|
156
193
|
args = parser.parse_args()
|
|
157
194
|
|
|
158
195
|
archive_course(
|
|
159
196
|
dirname=args.dirname,
|
|
160
197
|
output=args.output,
|
|
161
198
|
export_html=not args.no_html,
|
|
199
|
+
export_pdf=not args.no_pdf,
|
|
162
200
|
)
|
|
163
201
|
|
|
164
202
|
|
|
@@ -27,6 +27,7 @@ class CourseConfig:
|
|
|
27
27
|
custom_extras: dict[str, list[str]] = field(default_factory=dict)
|
|
28
28
|
default_weekend: str | None = None
|
|
29
29
|
additional_files: list[str] = field(default_factory=list)
|
|
30
|
+
course_dirs: list[str] = field(default_factory=list)
|
|
30
31
|
default_private: bool = False
|
|
31
32
|
|
|
32
33
|
|
|
@@ -128,6 +129,12 @@ def load_config(path: Path = CONFIG_PATH) -> CourseConfig:
|
|
|
128
129
|
raise ConfigError("additional_files must be a list of file/directory paths")
|
|
129
130
|
additional_files: list[str] = [str(f) for f in raw_additional_files]
|
|
130
131
|
|
|
132
|
+
# Course directories: optional list of dirs scanned by `list-courses`
|
|
133
|
+
raw_course_dirs: object = paths_section.get("course_dirs", [])
|
|
134
|
+
if not isinstance(raw_course_dirs, list):
|
|
135
|
+
raise ConfigError("course_dirs must be a list of directory paths")
|
|
136
|
+
course_dirs: list[str] = [str(d) for d in raw_course_dirs]
|
|
137
|
+
|
|
131
138
|
return CourseConfig(
|
|
132
139
|
github_token=github_token,
|
|
133
140
|
archive_path=archive_path,
|
|
@@ -139,4 +146,5 @@ def load_config(path: Path = CONFIG_PATH) -> CourseConfig:
|
|
|
139
146
|
default_weekend=default_weekend,
|
|
140
147
|
additional_files=additional_files,
|
|
141
148
|
default_private=default_private,
|
|
149
|
+
course_dirs=course_dirs,
|
|
142
150
|
)
|
|
@@ -31,6 +31,14 @@ archive = "/path/to/your/archive"
|
|
|
31
31
|
# Examples: data files, exercise notebooks, solutions folder
|
|
32
32
|
# additional_files = ["/path/to/exercises", "/path/to/data.csv"]
|
|
33
33
|
|
|
34
|
+
# Optional: directories that `list-courses` scans for active courses.
|
|
35
|
+
# Each entry is a directory whose immediate subdirectories may be courses
|
|
36
|
+
# (a course = a subdir containing a .git folder and at least one notebook).
|
|
37
|
+
# Examples:
|
|
38
|
+
# course_dirs = ["~/Courses/Current"]
|
|
39
|
+
# course_dirs = ["~/Courses/Current", "~/Courses/Consulting"]
|
|
40
|
+
# course_dirs = []
|
|
41
|
+
|
|
34
42
|
[defaults]
|
|
35
43
|
# Default notebook type when running setup-course.
|
|
36
44
|
# Options: "jupyter" or "marimo"
|
|
@@ -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()
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from collections.abc import Iterable
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
_DATE_PATTERN = re.compile(r"(\d{4}-\d{2}-\d{2})\.\w+$")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def is_marimo_notebook(path: Path) -> bool:
|
|
9
|
+
"""Return True if a .py file looks like a marimo notebook."""
|
|
10
|
+
try:
|
|
11
|
+
text = path.read_text(encoding="utf-8")
|
|
12
|
+
except (OSError, UnicodeDecodeError):
|
|
13
|
+
return False
|
|
14
|
+
return "import marimo" in text and "marimo.App()" in text
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def find_notebooks(path: Path) -> tuple[list[Path], list[Path]]:
|
|
18
|
+
"""Return (ipynb_files, marimo_files) found directly in *path*, each sorted."""
|
|
19
|
+
ipynb_files = sorted(path.glob("*.ipynb"))
|
|
20
|
+
marimo_files = sorted(p for p in path.glob("*.py") if is_marimo_notebook(p))
|
|
21
|
+
return ipynb_files, marimo_files
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def date_range(files: Iterable[Path]) -> str:
|
|
25
|
+
"""Return '<first> → <last>' parsed from filenames, or 'n/a' if none match."""
|
|
26
|
+
dates: list[str] = []
|
|
27
|
+
for p in files:
|
|
28
|
+
match = _DATE_PATTERN.search(p.name)
|
|
29
|
+
if match:
|
|
30
|
+
dates.append(match.group(1))
|
|
31
|
+
dates.sort()
|
|
32
|
+
if dates:
|
|
33
|
+
return f"{dates[0]} → {dates[-1]}"
|
|
34
|
+
return "n/a"
|
|
@@ -12,6 +12,7 @@ from pathlib import Path
|
|
|
12
12
|
|
|
13
13
|
from setup_course_github import __author__, __email__, __version__, get_github
|
|
14
14
|
from setup_course_github.config import load_config
|
|
15
|
+
from setup_course_github.notebooks import date_range, find_notebooks
|
|
15
16
|
|
|
16
17
|
|
|
17
18
|
class InsideCourseDirectoryError(RuntimeError):
|
|
@@ -77,15 +78,6 @@ def _confirm_create_dir(dest: Path, confirm: Callable[[str], str] = input) -> No
|
|
|
77
78
|
raise RuntimeError(f"Aborted: archive directory {dest} not created")
|
|
78
79
|
|
|
79
80
|
|
|
80
|
-
def _is_marimo_notebook(path: Path) -> bool:
|
|
81
|
-
"""Return True if a .py file looks like a marimo notebook."""
|
|
82
|
-
try:
|
|
83
|
-
text = path.read_text(encoding="utf-8")
|
|
84
|
-
except (OSError, UnicodeDecodeError):
|
|
85
|
-
return False
|
|
86
|
-
return "import marimo" in text and "marimo.App()" in text
|
|
87
|
-
|
|
88
|
-
|
|
89
81
|
def _build_retirement_summary(
|
|
90
82
|
dirname: str, repo_name: str, dest: Path, *, kept_public: bool = False
|
|
91
83
|
) -> str:
|
|
@@ -96,8 +88,7 @@ def _build_retirement_summary(
|
|
|
96
88
|
dirpath = Path(dirname)
|
|
97
89
|
|
|
98
90
|
# --- count notebooks ---------------------------------------------------
|
|
99
|
-
ipynb_files =
|
|
100
|
-
marimo_files = [p for p in dirpath.glob("*.py") if _is_marimo_notebook(p)]
|
|
91
|
+
ipynb_files, marimo_files = find_notebooks(dirpath)
|
|
101
92
|
nb_count = len(ipynb_files) + len(marimo_files)
|
|
102
93
|
|
|
103
94
|
if ipynb_files and marimo_files:
|
|
@@ -110,17 +101,7 @@ def _build_retirement_summary(
|
|
|
110
101
|
nb_label = "0"
|
|
111
102
|
|
|
112
103
|
# --- date range from filenames -----------------------------------------
|
|
113
|
-
|
|
114
|
-
dates: list[str] = []
|
|
115
|
-
for p in ipynb_files + marimo_files:
|
|
116
|
-
m = date_pattern.search(p.name)
|
|
117
|
-
if m:
|
|
118
|
-
dates.append(m.group(1))
|
|
119
|
-
dates.sort()
|
|
120
|
-
if dates:
|
|
121
|
-
date_range = f"{dates[0]} \u2192 {dates[-1]}"
|
|
122
|
-
else:
|
|
123
|
-
date_range = "n/a"
|
|
104
|
+
date_range_str = date_range(ipynb_files + marimo_files)
|
|
124
105
|
|
|
125
106
|
# --- dependencies from pyproject.toml ----------------------------------
|
|
126
107
|
pyproject_path = dirpath / "pyproject.toml"
|
|
@@ -144,7 +125,7 @@ def _build_retirement_summary(
|
|
|
144
125
|
"\u2500\u2500 Retirement Summary \u2500\u2500",
|
|
145
126
|
f" Course: {dirpath.name}",
|
|
146
127
|
f" Notebooks: {nb_label}",
|
|
147
|
-
f" Date range: {
|
|
128
|
+
f" Date range: {date_range_str}",
|
|
148
129
|
f" Dependencies: {deps_str}",
|
|
149
130
|
f" Archived to: {dest / dirpath.name}",
|
|
150
131
|
f" GitHub repo: {repo_url} (still public)"
|
|
@@ -154,8 +135,14 @@ def _build_retirement_summary(
|
|
|
154
135
|
return "\n".join(lines)
|
|
155
136
|
|
|
156
137
|
|
|
157
|
-
def retire_course(
|
|
158
|
-
|
|
138
|
+
def retire_course(
|
|
139
|
+
dirname: str, keep_public: bool = False, dry_run: bool = False
|
|
140
|
+
) -> None:
|
|
141
|
+
"""Move the local directory to the archive, optionally making the repo private.
|
|
142
|
+
|
|
143
|
+
When *dry_run* is True, no repo is modified, no directory is created, and
|
|
144
|
+
nothing is moved \u2014 only a preview of the intended actions is printed.
|
|
145
|
+
"""
|
|
159
146
|
_check_not_inside_course(dirname)
|
|
160
147
|
|
|
161
148
|
config = load_config()
|
|
@@ -163,13 +150,22 @@ def retire_course(dirname: str, keep_public: bool = False) -> None:
|
|
|
163
150
|
remote_url = get_remote_url(dirname)
|
|
164
151
|
repo_name = parse_repo_name(remote_url)
|
|
165
152
|
|
|
153
|
+
year = datetime.datetime.now().year
|
|
154
|
+
dest = config.archive_path / str(year)
|
|
155
|
+
|
|
156
|
+
if dry_run:
|
|
157
|
+
summary = _build_retirement_summary(
|
|
158
|
+
dirname, repo_name, dest, kept_public=keep_public
|
|
159
|
+
)
|
|
160
|
+
print(f"[DRY RUN] Would retire {dirname} \u2192 {dest / Path(dirname).name}")
|
|
161
|
+
print(summary)
|
|
162
|
+
return
|
|
163
|
+
|
|
166
164
|
g = get_github()
|
|
167
165
|
repo = g.get_repo(repo_name)
|
|
168
166
|
if not keep_public:
|
|
169
167
|
repo.edit(private=True)
|
|
170
168
|
|
|
171
|
-
year = datetime.datetime.now().year
|
|
172
|
-
dest = config.archive_path / str(year)
|
|
173
169
|
_confirm_create_dir(dest)
|
|
174
170
|
|
|
175
171
|
summary = _build_retirement_summary(
|
|
@@ -200,6 +196,12 @@ def main() -> None:
|
|
|
200
196
|
default=False,
|
|
201
197
|
help="archive without making the GitHub repo private",
|
|
202
198
|
)
|
|
199
|
+
parser.add_argument(
|
|
200
|
+
"--dry-run",
|
|
201
|
+
action="store_true",
|
|
202
|
+
default=False,
|
|
203
|
+
help="preview the retirement without changing GitHub or moving files",
|
|
204
|
+
)
|
|
203
205
|
parser.add_argument(
|
|
204
206
|
"dirnames", nargs="+", help="Path(s) to course directories to retire"
|
|
205
207
|
)
|
|
@@ -208,7 +210,7 @@ def main() -> None:
|
|
|
208
210
|
errors: list[tuple[str, str]] = []
|
|
209
211
|
for dirname in args.dirnames:
|
|
210
212
|
try:
|
|
211
|
-
retire_course(dirname, keep_public=args.keep_public)
|
|
213
|
+
retire_course(dirname, keep_public=args.keep_public, dry_run=args.dry_run)
|
|
212
214
|
except Exception as e:
|
|
213
215
|
print(f"Error retiring {dirname}: {e}")
|
|
214
216
|
errors.append((dirname, str(e)))
|
|
File without changes
|
{course_setup-3.0.14 → course_setup-3.2.0}/src/setup_course_github/.claude/settings.local.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{course_setup-3.0.14 → course_setup-3.2.0}/src/setup_course_github/generic/Course notebook.ipynb
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|