course-setup 3.2.0__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.0 → course_setup-3.3.0}/PKG-INFO +17 -4
  2. {course_setup-3.2.0 → course_setup-3.3.0}/README.md +16 -3
  3. {course_setup-3.2.0 → course_setup-3.3.0}/pyproject.toml +1 -1
  4. {course_setup-3.2.0 → course_setup-3.3.0}/src/setup_course_github/list_courses.py +83 -8
  5. {course_setup-3.2.0 → course_setup-3.3.0}/src/setup_course_github/.DS_Store +0 -0
  6. {course_setup-3.2.0 → course_setup-3.3.0}/src/setup_course_github/.claude/settings.local.json +0 -0
  7. {course_setup-3.2.0 → course_setup-3.3.0}/src/setup_course_github/__init__.py +0 -0
  8. {course_setup-3.2.0 → course_setup-3.3.0}/src/setup_course_github/archive_course.py +0 -0
  9. {course_setup-3.2.0 → course_setup-3.3.0}/src/setup_course_github/config.py +0 -0
  10. {course_setup-3.2.0 → course_setup-3.3.0}/src/setup_course_github/generic/.gitignore +0 -0
  11. {course_setup-3.2.0 → course_setup-3.3.0}/src/setup_course_github/generic/.ipynb_checkpoints/Course notebook-checkpoint.ipynb +0 -0
  12. {course_setup-3.2.0 → course_setup-3.3.0}/src/setup_course_github/generic/Course notebook.ipynb +0 -0
  13. {course_setup-3.2.0 → course_setup-3.3.0}/src/setup_course_github/generic/README.md +0 -0
  14. {course_setup-3.2.0 → course_setup-3.3.0}/src/setup_course_github/init_config.py +0 -0
  15. {course_setup-3.2.0 → course_setup-3.3.0}/src/setup_course_github/notebooks.py +0 -0
  16. {course_setup-3.2.0 → course_setup-3.3.0}/src/setup_course_github/retire_course.py +0 -0
  17. {course_setup-3.2.0 → course_setup-3.3.0}/src/setup_course_github/setup_course.py +0 -0
  18. {course_setup-3.2.0 → 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.0
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.0"
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
 
@@ -90,8 +120,9 @@ def archive_summary_line(archived: dict[str, list[Path]], patterns: list[str]) -
90
120
  return f"Archived: none{match_clause}"
91
121
  years = sorted(archived.keys())
92
122
  span = years[0] if len(years) == 1 else f"{years[0]}–{years[-1]}"
123
+ noun = "course" if total == 1 else "courses"
93
124
  return (
94
- f"Archived: {total} courses{match_clause} across {span}"
125
+ f"Archived: {total} {noun}{match_clause} across {span}"
95
126
  " — use --archived to list them."
96
127
  )
97
128
 
@@ -138,12 +169,46 @@ def find_archived_courses(archive_path: Path) -> dict[str, list[Path]]:
138
169
 
139
170
 
140
171
  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)")
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
+ )
144
177
  return value
145
178
 
146
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
+
147
212
  def main(argv: list[str] | None = None) -> None:
148
213
  pypi_url = "https://pypi.org/project/course-setup/"
149
214
  author_line = f"{__author__} <{__email__}>"
@@ -187,15 +252,24 @@ def main(argv: list[str] | None = None) -> None:
187
252
  dest="years",
188
253
  type=_year_arg,
189
254
  metavar="YYYY",
190
- help="Restrict archived courses to a year (repeatable; hides active)",
255
+ help="Restrict archived courses to a year or YYYY-YYYY range (repeatable)",
191
256
  )
192
257
  parser.add_argument(
193
258
  "--count",
194
259
  action="store_true",
195
260
  help="Show counts instead of individual course lines",
196
261
  )
262
+ parser.add_argument(
263
+ "--no-urls",
264
+ action="store_true",
265
+ help="Do not show each course's GitHub URL",
266
+ )
197
267
  args = parser.parse_args(argv)
198
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
+
199
273
  config = load_config()
200
274
  scan_dirs = resolve_scan_dirs(args.dir, config)
201
275
 
@@ -208,6 +282,7 @@ def main(argv: list[str] | None = None) -> None:
208
282
  show_active = not (only_archived or year_focus)
209
283
  show_archived = not only_active
210
284
  archive_expanded = args.archived or has_years
285
+ show_urls = not args.no_urls
211
286
 
212
287
  if show_active:
213
288
  active = filter_active(find_active_courses(scan_dirs), patterns)
@@ -217,7 +292,7 @@ def main(argv: list[str] | None = None) -> None:
217
292
  print("Active courses:")
218
293
  if active:
219
294
  for course in active:
220
- print(f" {course_summary_line(course)}")
295
+ _print_course(course, 2, show_urls)
221
296
  elif has_names:
222
297
  print(f" No active courses match: {', '.join(patterns)}")
223
298
  else:
@@ -225,7 +300,7 @@ def main(argv: list[str] | None = None) -> None:
225
300
 
226
301
  if show_archived:
227
302
  archived = filter_archived(
228
- find_archived_courses(config.archive_path), args.years, patterns
303
+ find_archived_courses(config.archive_path), years, patterns
229
304
  )
230
305
  if show_active:
231
306
  print()
@@ -240,7 +315,7 @@ def main(argv: list[str] | None = None) -> None:
240
315
  for year in sorted(archived):
241
316
  print(f" {year}:")
242
317
  for course in archived[year]:
243
- print(f" {course_summary_line(course)}")
318
+ _print_course(course, 4, show_urls)
244
319
  elif has_names or has_years:
245
320
  print(" No archived courses match your filters")
246
321
  else: