course-setup 3.2.1__tar.gz → 3.3.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 (18) hide show
  1. {course_setup-3.2.1 → course_setup-3.3.0}/PKG-INFO +17 -4
  2. {course_setup-3.2.1 → course_setup-3.3.0}/README.md +16 -3
  3. {course_setup-3.2.1 → course_setup-3.3.0}/pyproject.toml +1 -1
  4. {course_setup-3.2.1 → course_setup-3.3.0}/src/setup_course_github/list_courses.py +81 -7
  5. {course_setup-3.2.1 → course_setup-3.3.0}/src/setup_course_github/.DS_Store +0 -0
  6. {course_setup-3.2.1 → course_setup-3.3.0}/src/setup_course_github/.claude/settings.local.json +0 -0
  7. {course_setup-3.2.1 → course_setup-3.3.0}/src/setup_course_github/__init__.py +0 -0
  8. {course_setup-3.2.1 → course_setup-3.3.0}/src/setup_course_github/archive_course.py +0 -0
  9. {course_setup-3.2.1 → course_setup-3.3.0}/src/setup_course_github/config.py +0 -0
  10. {course_setup-3.2.1 → course_setup-3.3.0}/src/setup_course_github/generic/.gitignore +0 -0
  11. {course_setup-3.2.1 → course_setup-3.3.0}/src/setup_course_github/generic/.ipynb_checkpoints/Course notebook-checkpoint.ipynb +0 -0
  12. {course_setup-3.2.1 → course_setup-3.3.0}/src/setup_course_github/generic/Course notebook.ipynb +0 -0
  13. {course_setup-3.2.1 → course_setup-3.3.0}/src/setup_course_github/generic/README.md +0 -0
  14. {course_setup-3.2.1 → course_setup-3.3.0}/src/setup_course_github/init_config.py +0 -0
  15. {course_setup-3.2.1 → course_setup-3.3.0}/src/setup_course_github/notebooks.py +0 -0
  16. {course_setup-3.2.1 → course_setup-3.3.0}/src/setup_course_github/retire_course.py +0 -0
  17. {course_setup-3.2.1 → course_setup-3.3.0}/src/setup_course_github/setup_course.py +0 -0
  18. {course_setup-3.2.1 → course_setup-3.3.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.2.1
3
+ Version: 3.3.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>
@@ -226,7 +226,8 @@ 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, and each course's local `git` remote, but does not use
230
+ the network).
230
231
 
231
232
  > **Breaking change (3.2.0):** the positional argument used to be a
232
233
  > scan-directory override (`list-courses ~/Other`). It is now a **name
@@ -243,13 +244,21 @@ directory (an archived course is any non-hidden, non-junk directory under a
243
244
  ```
244
245
  Active courses:
245
246
  Acme-python-intro-2026-03 — 5 notebooks (2026-03-17 → 2026-03-21)
247
+ https://github.com/acme/python-intro-2026-03
246
248
  Beta-pandas-2026-02 — 3 notebooks (2026-02-02 → 2026-02-16)
249
+ https://github.com/beta/pandas-2026-02
247
250
 
248
251
  Archived: 412 courses across 2018–2026 — use --archived to list them.
249
252
  ```
250
253
 
251
254
  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).
255
+ with dates parsed from notebook filenames (`n/a` if none are found). By
256
+ default, an indented line below each *listed* course (active courses, and
257
+ archived courses in the expanded `--archived`/`--year` views) shows its GitHub
258
+ URL, read from the course's local `git` `origin` remote — no network call. A
259
+ course with no usable GitHub remote shows `(no GitHub remote)` instead. Pass
260
+ `--no-urls` to hide these lines. URLs are not shown in the one-line archive
261
+ summary or with `--count`.
253
262
 
254
263
  | Argument / Option | Description |
255
264
  |----------|-------------|
@@ -257,8 +266,9 @@ with dates parsed from notebook filenames (`n/a` if none are found).
257
266
  | `--dir PATH` | Directory to scan for active courses (repeatable; replaces `course_dirs` from config for this run) |
258
267
  | `--active` | Show only the active-courses section |
259
268
  | `--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 |
269
+ | `--year YYYY[-YYYY]` | Restrict archived courses to a year or an inclusive `YYYY-YYYY` range (repeatable; singles and ranges mix freely); implies `--archived` unless `--active` is also given, in which case `--year` is ignored. A reversed range (e.g. `2022-2020`) is auto-swapped, printing `Note: swapped reversed year range 2022-2020 → 2020-2022` to stderr. |
261
270
  | `--count` | Print counts instead of course lines, honoring all other filters |
271
+ | `--no-urls` | Hide the per-course GitHub URL line shown by default under each listed course |
262
272
  | `--version` | Show the version number, PyPI URL, author name, and email |
263
273
 
264
274
  Directories to scan for active courses are resolved in this order:
@@ -278,11 +288,14 @@ Directories to scan for active courses are resolved in this order:
278
288
  | `list-courses cisco --archived` | archived courses matching "cisco" (all years, expanded) |
279
289
  | `list-courses --year 2024` | archive for 2024 only (active section suppressed) |
280
290
  | `list-courses --year 2024 --year 2025` | archive for 2024 and 2025 only |
291
+ | `list-courses --year 2020-2022` | archive for 2020 through 2022 only (inclusive range) |
292
+ | `list-courses --year 2019 --year 2021-2023` | archive for 2019, 2021, 2022, 2023 (singles and ranges mix) |
281
293
  | `list-courses cisco --year 2024` | archived courses matching "cisco" from 2024 only |
282
294
  | `list-courses --active --year 2024` | active only (`--year` is ignored for active) |
283
295
  | `list-courses --count` | `Active courses: 3` + `Archived courses: 412` + per-year counts |
284
296
  | `list-courses cisco --count` | counts of active + archived courses matching "cisco" |
285
297
  | `list-courses --dir ~/Other` | scan `~/Other` for active courses instead of config `course_dirs` |
298
+ | `list-courses --no-urls` | active courses in full, without the per-course GitHub URL lines |
286
299
 
287
300
  If there are no matching active or archived courses, it prints an empty-state
288
301
  line (e.g. `No active courses found`, or `No active courses match: cisco` when
@@ -206,7 +206,8 @@ 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, and each course's local `git` remote, but does not use
210
+ the network).
210
211
 
211
212
  > **Breaking change (3.2.0):** the positional argument used to be a
212
213
  > scan-directory override (`list-courses ~/Other`). It is now a **name
@@ -223,13 +224,21 @@ directory (an archived course is any non-hidden, non-junk directory under a
223
224
  ```
224
225
  Active courses:
225
226
  Acme-python-intro-2026-03 — 5 notebooks (2026-03-17 → 2026-03-21)
227
+ https://github.com/acme/python-intro-2026-03
226
228
  Beta-pandas-2026-02 — 3 notebooks (2026-02-02 → 2026-02-16)
229
+ https://github.com/beta/pandas-2026-02
227
230
 
228
231
  Archived: 412 courses across 2018–2026 — use --archived to list them.
229
232
  ```
230
233
 
231
234
  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).
235
+ with dates parsed from notebook filenames (`n/a` if none are found). By
236
+ default, an indented line below each *listed* course (active courses, and
237
+ archived courses in the expanded `--archived`/`--year` views) shows its GitHub
238
+ URL, read from the course's local `git` `origin` remote — no network call. A
239
+ course with no usable GitHub remote shows `(no GitHub remote)` instead. Pass
240
+ `--no-urls` to hide these lines. URLs are not shown in the one-line archive
241
+ summary or with `--count`.
233
242
 
234
243
  | Argument / Option | Description |
235
244
  |----------|-------------|
@@ -237,8 +246,9 @@ with dates parsed from notebook filenames (`n/a` if none are found).
237
246
  | `--dir PATH` | Directory to scan for active courses (repeatable; replaces `course_dirs` from config for this run) |
238
247
  | `--active` | Show only the active-courses section |
239
248
  | `--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 |
249
+ | `--year YYYY[-YYYY]` | Restrict archived courses to a year or an inclusive `YYYY-YYYY` range (repeatable; singles and ranges mix freely); implies `--archived` unless `--active` is also given, in which case `--year` is ignored. A reversed range (e.g. `2022-2020`) is auto-swapped, printing `Note: swapped reversed year range 2022-2020 → 2020-2022` to stderr. |
241
250
  | `--count` | Print counts instead of course lines, honoring all other filters |
251
+ | `--no-urls` | Hide the per-course GitHub URL line shown by default under each listed course |
242
252
  | `--version` | Show the version number, PyPI URL, author name, and email |
243
253
 
244
254
  Directories to scan for active courses are resolved in this order:
@@ -258,11 +268,14 @@ Directories to scan for active courses are resolved in this order:
258
268
  | `list-courses cisco --archived` | archived courses matching "cisco" (all years, expanded) |
259
269
  | `list-courses --year 2024` | archive for 2024 only (active section suppressed) |
260
270
  | `list-courses --year 2024 --year 2025` | archive for 2024 and 2025 only |
271
+ | `list-courses --year 2020-2022` | archive for 2020 through 2022 only (inclusive range) |
272
+ | `list-courses --year 2019 --year 2021-2023` | archive for 2019, 2021, 2022, 2023 (singles and ranges mix) |
261
273
  | `list-courses cisco --year 2024` | archived courses matching "cisco" from 2024 only |
262
274
  | `list-courses --active --year 2024` | active only (`--year` is ignored for active) |
263
275
  | `list-courses --count` | `Active courses: 3` + `Archived courses: 412` + per-year counts |
264
276
  | `list-courses cisco --count` | counts of active + archived courses matching "cisco" |
265
277
  | `list-courses --dir ~/Other` | scan `~/Other` for active courses instead of config `course_dirs` |
278
+ | `list-courses --no-urls` | active courses in full, without the per-course GitHub URL lines |
266
279
 
267
280
  If there are no matching active or archived courses, it prints an empty-state
268
281
  line (e.g. `No active courses found`, or `No active courses match: cisco` when
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "course-setup"
3
- version = "3.2.1"
3
+ version = "3.3.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"
@@ -1,5 +1,7 @@
1
1
  import argparse
2
2
  import re
3
+ import subprocess
4
+ import sys
3
5
  from pathlib import Path
4
6
 
5
7
  from setup_course_github import __author__, __email__, __version__
@@ -10,6 +12,8 @@ JUNK_NAMES = frozenset(
10
12
  {"__pycache__", "build", "dist", "bin", "include", "lib", "node_modules"}
11
13
  )
12
14
  _YEAR_RE = re.compile(r"^\d{4}$")
15
+ _YEAR_TOKEN_RE = re.compile(r"^\d{4}(-\d{4})?$")
16
+ _GITHUB_RE = re.compile(r"github\.com[:/](?P<path>[^/]+/[^/]+?)(?:\.git)?/?$")
13
17
 
14
18
 
15
19
  def _is_junk_name(name: str) -> bool:
@@ -45,6 +49,32 @@ def course_summary_line(path: Path) -> str:
45
49
  return f"{path.name} — {count} notebooks ({span})"
46
50
 
47
51
 
52
+ def _github_url(remote: str) -> str | None:
53
+ """Normalize a GitHub SSH/HTTPS remote to https://github.com/<owner>/<repo>.
54
+
55
+ Returns None for an empty string or a non-GitHub remote.
56
+ """
57
+ match = _GITHUB_RE.search(remote)
58
+ if not match:
59
+ return None
60
+ return f"https://github.com/{match.group('path')}"
61
+
62
+
63
+ def github_url(course_path: Path) -> str | None:
64
+ """Return the course repo's GitHub URL from its local origin remote, or None.
65
+
66
+ Reads ``git config --get remote.origin.url`` in *course_path*; no network.
67
+ """
68
+ result = subprocess.run(
69
+ ["git", "config", "--get", "remote.origin.url"],
70
+ cwd=str(course_path),
71
+ capture_output=True,
72
+ )
73
+ if result.returncode != 0:
74
+ return None
75
+ return _github_url(result.stdout.decode().strip())
76
+
77
+
48
78
  def _matches_names(name: str, patterns: list[str]) -> bool:
49
79
  """True if *name* (case-insensitively) contains any of *patterns*.
50
80
 
@@ -139,12 +169,46 @@ def find_archived_courses(archive_path: Path) -> dict[str, list[Path]]:
139
169
 
140
170
 
141
171
  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)")
172
+ """argparse type: accept a 4-digit year or an inclusive YYYY-YYYY range."""
173
+ if not _YEAR_TOKEN_RE.match(value):
174
+ raise argparse.ArgumentTypeError(
175
+ f"invalid year '{value}' (expected YYYY or YYYY-YYYY)"
176
+ )
145
177
  return value
146
178
 
147
179
 
180
+ def _expand_year_tokens(tokens: list[str]) -> tuple[list[str], list[str]]:
181
+ """Flatten single-year and range tokens into individual 4-digit years.
182
+
183
+ Returns ``(years, notes)`` where *notes* holds a message for each reversed
184
+ range that was auto-swapped. Ranges are inclusive; a reversed range like
185
+ ``2022-2020`` is swapped to ``2020-2022``.
186
+ """
187
+ years: list[str] = []
188
+ notes: list[str] = []
189
+ for token in tokens:
190
+ if "-" in token:
191
+ start_s, end_s = token.split("-")
192
+ start, end = int(start_s), int(end_s)
193
+ if start > end:
194
+ notes.append(
195
+ f"swapped reversed year range {start_s}-{end_s} → {end_s}-{start_s}"
196
+ )
197
+ start, end = end, start
198
+ years.extend(f"{year:04d}" for year in range(start, end + 1))
199
+ else:
200
+ years.append(token)
201
+ return years, notes
202
+
203
+
204
+ def _print_course(course: Path, indent: int, show_urls: bool) -> None:
205
+ """Print a course summary line and, when *show_urls*, its indented URL line."""
206
+ print(f"{' ' * indent}{course_summary_line(course)}")
207
+ if show_urls:
208
+ url = github_url(course)
209
+ print(f"{' ' * (indent + 2)}{url or '(no GitHub remote)'}")
210
+
211
+
148
212
  def main(argv: list[str] | None = None) -> None:
149
213
  pypi_url = "https://pypi.org/project/course-setup/"
150
214
  author_line = f"{__author__} <{__email__}>"
@@ -188,15 +252,24 @@ def main(argv: list[str] | None = None) -> None:
188
252
  dest="years",
189
253
  type=_year_arg,
190
254
  metavar="YYYY",
191
- help="Restrict archived courses to a year (repeatable; hides active)",
255
+ help="Restrict archived courses to a year or YYYY-YYYY range (repeatable)",
192
256
  )
193
257
  parser.add_argument(
194
258
  "--count",
195
259
  action="store_true",
196
260
  help="Show counts instead of individual course lines",
197
261
  )
262
+ parser.add_argument(
263
+ "--no-urls",
264
+ action="store_true",
265
+ help="Do not show each course's GitHub URL",
266
+ )
198
267
  args = parser.parse_args(argv)
199
268
 
269
+ years, year_notes = _expand_year_tokens(args.years)
270
+ for note in year_notes:
271
+ print(f"Note: {note}", file=sys.stderr)
272
+
200
273
  config = load_config()
201
274
  scan_dirs = resolve_scan_dirs(args.dir, config)
202
275
 
@@ -209,6 +282,7 @@ def main(argv: list[str] | None = None) -> None:
209
282
  show_active = not (only_archived or year_focus)
210
283
  show_archived = not only_active
211
284
  archive_expanded = args.archived or has_years
285
+ show_urls = not args.no_urls
212
286
 
213
287
  if show_active:
214
288
  active = filter_active(find_active_courses(scan_dirs), patterns)
@@ -218,7 +292,7 @@ def main(argv: list[str] | None = None) -> None:
218
292
  print("Active courses:")
219
293
  if active:
220
294
  for course in active:
221
- print(f" {course_summary_line(course)}")
295
+ _print_course(course, 2, show_urls)
222
296
  elif has_names:
223
297
  print(f" No active courses match: {', '.join(patterns)}")
224
298
  else:
@@ -226,7 +300,7 @@ def main(argv: list[str] | None = None) -> None:
226
300
 
227
301
  if show_archived:
228
302
  archived = filter_archived(
229
- find_archived_courses(config.archive_path), args.years, patterns
303
+ find_archived_courses(config.archive_path), years, patterns
230
304
  )
231
305
  if show_active:
232
306
  print()
@@ -241,7 +315,7 @@ def main(argv: list[str] | None = None) -> None:
241
315
  for year in sorted(archived):
242
316
  print(f" {year}:")
243
317
  for course in archived[year]:
244
- print(f" {course_summary_line(course)}")
318
+ _print_course(course, 4, show_urls)
245
319
  elif has_names or has_years:
246
320
  print(" No archived courses match your filters")
247
321
  else: