studyctl 2.0.0__py3-none-any.whl

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 (58) hide show
  1. studyctl/__init__.py +3 -0
  2. studyctl/calendar.py +140 -0
  3. studyctl/cli/__init__.py +56 -0
  4. studyctl/cli/_config.py +128 -0
  5. studyctl/cli/_content.py +462 -0
  6. studyctl/cli/_lazy.py +35 -0
  7. studyctl/cli/_review.py +491 -0
  8. studyctl/cli/_schedule.py +125 -0
  9. studyctl/cli/_setup.py +164 -0
  10. studyctl/cli/_shared.py +83 -0
  11. studyctl/cli/_state.py +69 -0
  12. studyctl/cli/_sync.py +156 -0
  13. studyctl/cli/_web.py +228 -0
  14. studyctl/content/__init__.py +5 -0
  15. studyctl/content/markdown_converter.py +271 -0
  16. studyctl/content/models.py +31 -0
  17. studyctl/content/notebooklm_client.py +434 -0
  18. studyctl/content/splitter.py +159 -0
  19. studyctl/content/storage.py +105 -0
  20. studyctl/content/syllabus.py +416 -0
  21. studyctl/history.py +982 -0
  22. studyctl/maintenance.py +69 -0
  23. studyctl/mcp/__init__.py +1 -0
  24. studyctl/mcp/server.py +58 -0
  25. studyctl/mcp/tools.py +234 -0
  26. studyctl/pdf.py +89 -0
  27. studyctl/review_db.py +277 -0
  28. studyctl/review_loader.py +375 -0
  29. studyctl/scheduler.py +242 -0
  30. studyctl/services/__init__.py +6 -0
  31. studyctl/services/content.py +39 -0
  32. studyctl/services/review.py +127 -0
  33. studyctl/settings.py +367 -0
  34. studyctl/shared.py +425 -0
  35. studyctl/state.py +120 -0
  36. studyctl/sync.py +229 -0
  37. studyctl/tui/__main__.py +33 -0
  38. studyctl/tui/app.py +395 -0
  39. studyctl/tui/study_cards.py +396 -0
  40. studyctl/web/__init__.py +1 -0
  41. studyctl/web/app.py +68 -0
  42. studyctl/web/routes/__init__.py +1 -0
  43. studyctl/web/routes/artefacts.py +57 -0
  44. studyctl/web/routes/cards.py +86 -0
  45. studyctl/web/routes/courses.py +91 -0
  46. studyctl/web/routes/history.py +69 -0
  47. studyctl/web/server.py +260 -0
  48. studyctl/web/static/app.js +853 -0
  49. studyctl/web/static/icon-192.svg +4 -0
  50. studyctl/web/static/icon-512.svg +4 -0
  51. studyctl/web/static/index.html +50 -0
  52. studyctl/web/static/manifest.json +21 -0
  53. studyctl/web/static/style.css +657 -0
  54. studyctl/web/static/sw.js +14 -0
  55. studyctl-2.0.0.dist-info/METADATA +49 -0
  56. studyctl-2.0.0.dist-info/RECORD +58 -0
  57. studyctl-2.0.0.dist-info/WHEEL +4 -0
  58. studyctl-2.0.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,462 @@
1
+ """Content commands -- PDF splitting, NotebookLM integration, syllabus workflow.
2
+
3
+ Absorbed from pdf-by-chapters. All commands are under ``studyctl content``.
4
+ Heavy imports (pymupdf, notebooklm-py) are deferred to function bodies.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ from pathlib import Path
11
+
12
+ import click
13
+ from rich.console import Console
14
+ from rich.table import Table
15
+
16
+ console = Console()
17
+
18
+
19
+ def _resolve_pdfs(source: Path) -> list[Path]:
20
+ """Resolve source to a list of PDF paths (single file or directory glob)."""
21
+ if source.is_dir():
22
+ pdfs = sorted(source.glob("*.pdf"))
23
+ if not pdfs:
24
+ raise click.ClickException(f"No PDF files found in {source}")
25
+ return pdfs
26
+ if not source.is_file():
27
+ raise click.ClickException(f"'{source}' does not exist")
28
+ return [source]
29
+
30
+
31
+ def _get_notebook_id(notebook_id: str | None) -> str:
32
+ """Resolve notebook ID from option or raise."""
33
+ if not notebook_id:
34
+ raise click.ClickException(
35
+ "No notebook ID. Use -n/--notebook-id or set NOTEBOOK_ID env var."
36
+ )
37
+ return notebook_id
38
+
39
+
40
+ def _parse_chapter_range(raw: str) -> tuple[int, int]:
41
+ """Parse a chapter range string like '1-3' into (start, end)."""
42
+ try:
43
+ parts = raw.split("-")
44
+ start, end = int(parts[0]), int(parts[1])
45
+ except (ValueError, IndexError):
46
+ raise click.ClickException(f"Invalid chapter range '{raw}'. Use format: 1-3") from None
47
+ if start < 1 or end < start:
48
+ raise click.ClickException(
49
+ f"Invalid range: start must be >= 1 and <= end (got {start}-{end})"
50
+ )
51
+ return (start, end)
52
+
53
+
54
+ @click.group(name="content")
55
+ def content_group() -> None:
56
+ """Content pipeline -- PDF splitting, NotebookLM, and syllabus workflow."""
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Core commands
61
+ # ---------------------------------------------------------------------------
62
+
63
+
64
+ @content_group.command()
65
+ @click.argument("source", type=click.Path(exists=True, path_type=Path))
66
+ @click.option("-o", "--output-dir", type=click.Path(path_type=Path), default="./chapters")
67
+ @click.option("-l", "--level", default=1, help="TOC level to split on (1=top-level).")
68
+ @click.option("--ranges", default=None, help="Page ranges for PDFs without TOC, e.g. '1-30,31-60'.")
69
+ def split(source: Path, output_dir: Path, level: int, ranges: str | None) -> None:
70
+ """Split a PDF into per-chapter files by TOC bookmarks."""
71
+ from studyctl.content.splitter import (
72
+ sanitize_filename,
73
+ split_pdf_by_chapters,
74
+ split_pdf_by_ranges,
75
+ )
76
+
77
+ for pdf_path in _resolve_pdfs(source):
78
+ book_name = sanitize_filename(pdf_path.stem)
79
+ if ranges:
80
+ paths = split_pdf_by_ranges(pdf_path, output_dir, book_name, ranges)
81
+ else:
82
+ paths = split_pdf_by_chapters(pdf_path, output_dir, book_name, level=level)
83
+ console.print(f"[green]\u2713[/green] {len(paths)} chapters written to {output_dir}")
84
+
85
+
86
+ @content_group.command()
87
+ @click.argument("source", type=click.Path(exists=True, path_type=Path))
88
+ @click.option("-o", "--output-dir", type=click.Path(path_type=Path), default="./chapters")
89
+ @click.option("-l", "--level", default=1, help="TOC level to split on.")
90
+ @click.option("-n", "--notebook-id", envvar="NOTEBOOK_ID", default=None)
91
+ def process(source: Path, output_dir: Path, level: int, notebook_id: str | None) -> None:
92
+ """Split PDFs by chapter, upload to NotebookLM, and show summary."""
93
+ from studyctl.content.notebooklm_client import upload_chapters
94
+ from studyctl.content.splitter import sanitize_filename, split_pdf_by_chapters
95
+
96
+ for pdf_path in _resolve_pdfs(source):
97
+ book_name = sanitize_filename(pdf_path.stem)
98
+ chapter_paths = split_pdf_by_chapters(pdf_path, output_dir, book_name, level=level)
99
+ console.print(f"[green]\u2713[/green] Split into {len(chapter_paths)} chapters")
100
+
101
+ nid = _get_notebook_id(notebook_id)
102
+ result = asyncio.run(upload_chapters(nid, chapter_paths))
103
+ console.print(
104
+ f"[green]\u2713[/green] Uploaded {result.chapters} chapters "
105
+ f"to notebook {result.id[:8]}..."
106
+ )
107
+
108
+
109
+ @content_group.command("list")
110
+ @click.option("-n", "--notebook-id", envvar="NOTEBOOK_ID", default=None)
111
+ def list_cmd(notebook_id: str | None) -> None:
112
+ """List notebooks, or sources within a notebook."""
113
+ from studyctl.content.notebooklm_client import list_notebooks, list_sources
114
+
115
+ if notebook_id:
116
+ sources = asyncio.run(list_sources(notebook_id))
117
+ table = Table(title=f"Sources in {notebook_id[:8]}...")
118
+ table.add_column("ID", style="dim")
119
+ table.add_column("Title")
120
+ for s in sources:
121
+ table.add_row(s.id[:8] + "...", s.title)
122
+ console.print(table)
123
+ else:
124
+ notebooks = asyncio.run(list_notebooks())
125
+ table = Table(title="NotebookLM Notebooks")
126
+ table.add_column("ID", style="dim")
127
+ table.add_column("Title", style="bold")
128
+ table.add_column("Sources", justify="right")
129
+ for nb in notebooks:
130
+ table.add_row(nb.id[:8] + "...", nb.title, str(nb.sources_count))
131
+ console.print(table)
132
+
133
+
134
+ @content_group.command()
135
+ @click.option("-n", "--notebook-id", envvar="NOTEBOOK_ID", required=True)
136
+ @click.option("-c", "--chapters", required=True, help="Chapter range, e.g. '1-3'.")
137
+ @click.option("--no-audio", is_flag=True, help="Skip audio generation.")
138
+ @click.option("--no-video", is_flag=True, help="Skip video generation.")
139
+ @click.option("-t", "--timeout", default=900, help="Timeout in seconds (default: 900).")
140
+ def generate(
141
+ notebook_id: str,
142
+ chapters: str,
143
+ no_audio: bool,
144
+ no_video: bool,
145
+ timeout: int,
146
+ ) -> None:
147
+ """Generate audio/video overviews for a chapter range."""
148
+ from studyctl.content.notebooklm_client import generate_for_chapters
149
+
150
+ start, end = _parse_chapter_range(chapters)
151
+ types = []
152
+ if not no_audio:
153
+ types.append("audio")
154
+ if not no_video:
155
+ types.append("video")
156
+
157
+ if not types:
158
+ raise click.ClickException("Nothing to generate (both audio and video disabled).")
159
+
160
+ console.print(f"Generating {', '.join(types)} for chapters {start}-{end}...")
161
+ asyncio.run(generate_for_chapters(notebook_id, start, end, types=types, timeout=timeout))
162
+ console.print("[green]\u2713[/green] Generation complete")
163
+
164
+
165
+ @content_group.command()
166
+ @click.option("-n", "--notebook-id", envvar="NOTEBOOK_ID", required=True)
167
+ @click.option("-o", "--output-dir", type=click.Path(path_type=Path), default="./overviews")
168
+ @click.option("-c", "--chapters", default=None, help="Chapter range label for filenames.")
169
+ def download(notebook_id: str, output_dir: Path, chapters: str | None) -> None:
170
+ """Download audio and video artifacts from a notebook."""
171
+ from studyctl.content.notebooklm_client import download_artifacts
172
+
173
+ output_dir.mkdir(parents=True, exist_ok=True)
174
+ paths = asyncio.run(download_artifacts(notebook_id, output_dir, chapters))
175
+ for p in paths:
176
+ console.print(f"[green]\u2713[/green] {p.name}")
177
+ if not paths:
178
+ console.print("[dim]No artifacts to download[/dim]")
179
+
180
+
181
+ @content_group.command("delete")
182
+ @click.option("-n", "--notebook-id", envvar="NOTEBOOK_ID", required=True)
183
+ @click.confirmation_option(prompt="Are you sure you want to delete this notebook?")
184
+ def delete_cmd(notebook_id: str) -> None:
185
+ """Delete a notebook and all its contents."""
186
+ from studyctl.content.notebooklm_client import delete_notebook
187
+
188
+ asyncio.run(delete_notebook(notebook_id))
189
+ console.print(f"[green]\u2713[/green] Deleted notebook {notebook_id[:8]}...")
190
+
191
+
192
+ # ---------------------------------------------------------------------------
193
+ # Syllabus workflow
194
+ # ---------------------------------------------------------------------------
195
+
196
+
197
+ @content_group.command()
198
+ @click.option("-n", "--notebook-id", envvar="NOTEBOOK_ID", required=True)
199
+ @click.option("-o", "--output-dir", type=click.Path(path_type=Path), default="./chapters")
200
+ @click.option("-m", "--max-chapters", default=2, help="Max chapters per episode.")
201
+ @click.option("-b", "--book-name", default=None, help="Book name for state file.")
202
+ @click.option("--force", is_flag=True, help="Overwrite existing syllabus.")
203
+ @click.option("--no-audio", is_flag=True, help="Skip audio generation.")
204
+ @click.option("--no-video", is_flag=True, help="Skip video generation.")
205
+ def syllabus(
206
+ notebook_id: str,
207
+ output_dir: Path,
208
+ max_chapters: int,
209
+ book_name: str | None,
210
+ force: bool,
211
+ no_audio: bool,
212
+ no_video: bool,
213
+ ) -> None:
214
+ """Generate a podcast syllabus and save as a plan."""
215
+ from studyctl.content.notebooklm_client import create_syllabus, list_sources
216
+ from studyctl.content.syllabus import (
217
+ build_fixed_size_chunks,
218
+ build_prompt,
219
+ has_non_pending_chunks,
220
+ map_sources_to_chapters,
221
+ parse_syllabus_response,
222
+ read_state,
223
+ write_state,
224
+ )
225
+
226
+ resolved_book_name = book_name or output_dir.resolve().name
227
+ state_path = output_dir / f".{resolved_book_name}-syllabus.json"
228
+
229
+ # Check for existing state
230
+ existing = read_state(state_path)
231
+ if existing and has_non_pending_chunks(existing) and not force:
232
+ console.print(
233
+ "[yellow]Syllabus already exists with in-progress chunks.[/yellow]\n"
234
+ "Use --force to overwrite."
235
+ )
236
+ return
237
+
238
+ # Get sources and build chunks
239
+ sources = asyncio.run(list_sources(notebook_id))
240
+ source_map = map_sources_to_chapters(sources)
241
+ chunks = build_fixed_size_chunks(source_map, max_chapters=max_chapters)
242
+
243
+ # Generate syllabus via NotebookLM chat
244
+ prompt = build_prompt(chunks)
245
+ console.print(f"Generating syllabus for {len(chunks)} episodes...")
246
+ response = asyncio.run(create_syllabus(notebook_id, prompt))
247
+ state = parse_syllabus_response(response, chunks, resolved_book_name)
248
+
249
+ # Configure artifact types
250
+ types = []
251
+ if not no_audio:
252
+ types.append("audio")
253
+ if not no_video:
254
+ types.append("video")
255
+ state.artifact_types = types
256
+
257
+ write_state(state, state_path)
258
+
259
+ # Display syllabus table
260
+ table = Table(title=f"Syllabus: {resolved_book_name}")
261
+ table.add_column("#", justify="right", style="dim")
262
+ table.add_column("Title", style="bold")
263
+ table.add_column("Chapters", style="cyan")
264
+ table.add_column("Status")
265
+ for chunk in state.chunks.values():
266
+ ch_str = ", ".join(str(c) for c in chunk.chapters)
267
+ table.add_row(str(chunk.episode), chunk.title, ch_str, chunk.status.value)
268
+ console.print(table)
269
+ console.print(f"\nState saved to {state_path}")
270
+ console.print(f"Next: Run [bold]studyctl content autopilot -o {output_dir}[/bold]")
271
+
272
+
273
+ @content_group.command("autopilot")
274
+ @click.option("-o", "--output-dir", type=click.Path(path_type=Path), default="./chapters")
275
+ @click.option("-b", "--book-name", default=None, help="Book name for state file.")
276
+ @click.option("-t", "--timeout", default=900, help="Timeout per episode in seconds.")
277
+ def autopilot(output_dir: Path, book_name: str | None, timeout: int) -> None:
278
+ """Generate the next pending episode from the syllabus."""
279
+ from studyctl.content.notebooklm_client import (
280
+ download_episode_audio,
281
+ start_chunk_generation,
282
+ )
283
+ from studyctl.content.syllabus import (
284
+ ChunkStatus,
285
+ get_next_chunk,
286
+ read_state,
287
+ write_state,
288
+ )
289
+
290
+ resolved_book_name = book_name or output_dir.resolve().name
291
+ state_path = output_dir / f".{resolved_book_name}-syllabus.json"
292
+
293
+ state = read_state(state_path)
294
+ if not state:
295
+ raise click.ClickException(
296
+ f"No syllabus state found at {state_path}. Run 'studyctl content syllabus' first."
297
+ )
298
+
299
+ chunk = get_next_chunk(state)
300
+ if not chunk:
301
+ console.print("[green]All episodes complete![/green]")
302
+ return
303
+
304
+ console.print(
305
+ f"Episode {chunk.episode}: [bold]{chunk.title}[/bold] "
306
+ f"(chapters {', '.join(str(c) for c in chunk.chapters)})"
307
+ )
308
+
309
+ # Start generation
310
+ chunk.status = ChunkStatus.GENERATING
311
+ write_state(state, state_path)
312
+
313
+ try:
314
+ asyncio.run(start_chunk_generation(state.notebook_id, chunk, timeout=timeout))
315
+ # Download audio
316
+ downloads_dir = output_dir / "downloads" / "audio"
317
+ downloads_dir.mkdir(parents=True, exist_ok=True)
318
+ asyncio.run(download_episode_audio(state.notebook_id, chunk, downloads_dir))
319
+ chunk.status = ChunkStatus.COMPLETED
320
+ console.print(f"[green]\u2713[/green] Episode {chunk.episode} complete")
321
+ except Exception as exc:
322
+ chunk.status = ChunkStatus.FAILED
323
+ console.print(f"[red]Episode {chunk.episode} failed: {exc}[/red]")
324
+ finally:
325
+ write_state(state, state_path)
326
+
327
+
328
+ @content_group.command("status")
329
+ @click.option("-o", "--output-dir", type=click.Path(path_type=Path), default="./chapters")
330
+ @click.option("-b", "--book-name", default=None, help="Book name for state file.")
331
+ def status_cmd(output_dir: Path, book_name: str | None) -> None:
332
+ """Show syllabus progress for chunked generation."""
333
+ from studyctl.content.syllabus import read_state
334
+
335
+ resolved_book_name = book_name or output_dir.resolve().name
336
+ state_path = output_dir / f".{resolved_book_name}-syllabus.json"
337
+
338
+ state = read_state(state_path)
339
+ if not state:
340
+ raise click.ClickException(
341
+ f"No syllabus state at {state_path}. Run 'studyctl content syllabus' first."
342
+ )
343
+
344
+ table = Table(title=f"Syllabus: {state.book_name}")
345
+ table.add_column("#", justify="right", style="dim")
346
+ table.add_column("Title", style="bold")
347
+ table.add_column("Chapters", style="cyan")
348
+ table.add_column("Status")
349
+
350
+ status_style = {
351
+ "pending": "dim",
352
+ "generating": "yellow",
353
+ "completed": "green",
354
+ "failed": "red",
355
+ }
356
+
357
+ for chunk in state.chunks.values():
358
+ ch_str = ", ".join(str(c) for c in chunk.chapters)
359
+ style = status_style.get(chunk.status.value, "")
360
+ status_text = f"[{style}]{chunk.status.value}[/{style}]" if style else chunk.status.value
361
+ table.add_row(str(chunk.episode), chunk.title, ch_str, status_text)
362
+
363
+ console.print(table)
364
+
365
+ completed = sum(1 for c in state.chunks.values() if c.status.value == "completed")
366
+ total = len(state.chunks)
367
+ console.print(f"\nProgress: {completed}/{total} episodes complete")
368
+
369
+
370
+ # ---------------------------------------------------------------------------
371
+ # Obsidian integration
372
+ # ---------------------------------------------------------------------------
373
+
374
+
375
+ @content_group.command("from-obsidian")
376
+ @click.argument("source_dir", type=click.Path(exists=True, path_type=Path))
377
+ @click.option("-o", "--output-dir", type=click.Path(path_type=Path), default=None)
378
+ @click.option("--name", "notebook_name", default=None, help="Notebook name.")
379
+ @click.option("-n", "--notebook-id", envvar="NOTEBOOK_ID", default=None)
380
+ @click.option("--no-generate", is_flag=True, help="Upload only, skip artifact generation.")
381
+ @click.option("--no-audio", is_flag=True, help="Skip audio generation.")
382
+ @click.option("--no-download", is_flag=True, help="Skip artifact download.")
383
+ @click.option("--no-quiz", is_flag=True, help="Skip quiz generation.")
384
+ @click.option("--no-flashcards", is_flag=True, help="Skip flashcard generation.")
385
+ @click.option("--skip-convert", is_flag=True, help="Skip PDF conversion, use existing PDFs.")
386
+ @click.option("-s", "--subdir", default=None, help="Subdirectory within source.")
387
+ def from_obsidian(
388
+ source_dir: Path,
389
+ output_dir: Path | None,
390
+ notebook_name: str | None,
391
+ notebook_id: str | None,
392
+ no_generate: bool,
393
+ no_audio: bool,
394
+ no_download: bool,
395
+ no_quiz: bool,
396
+ no_flashcards: bool,
397
+ skip_convert: bool,
398
+ subdir: str | None,
399
+ ) -> None:
400
+ """Convert Obsidian markdown to PDFs and upload to NotebookLM."""
401
+ from studyctl.content.markdown_converter import convert_directory
402
+ from studyctl.content.notebooklm_client import (
403
+ generate_for_chapters,
404
+ upload_chapters,
405
+ )
406
+
407
+ actual_dir = source_dir / subdir if subdir else source_dir
408
+ if not actual_dir.is_dir():
409
+ raise click.ClickException(f"Directory not found: {actual_dir}")
410
+
411
+ out = output_dir or source_dir / "downloads"
412
+ out.mkdir(parents=True, exist_ok=True)
413
+ name = notebook_name or source_dir.name.replace("-", " ").replace("_", " ").title()
414
+
415
+ # Step 1: Convert markdown to PDFs
416
+ if not skip_convert:
417
+ console.print(f"Converting markdown in {actual_dir}...")
418
+ pdf_dir = out / "pdfs"
419
+ convert_directory(actual_dir, pdf_dir)
420
+ console.print(f"[green]\u2713[/green] PDFs written to {pdf_dir}")
421
+ else:
422
+ pdf_dir = out / "pdfs"
423
+
424
+ # Step 2: Upload
425
+ pdf_files = sorted(pdf_dir.glob("*.pdf")) if pdf_dir.is_dir() else []
426
+ if not pdf_files:
427
+ console.print("[yellow]No PDFs to upload[/yellow]")
428
+ return
429
+
430
+ nid = notebook_id
431
+ if not nid:
432
+ console.print(f"Creating notebook: [bold]{name}[/bold]")
433
+ result = asyncio.run(upload_chapters(nid, pdf_files, title=name))
434
+ nid = result.id
435
+ console.print(
436
+ f"[green]\u2713[/green] Uploaded {result.chapters} files to notebook {nid[:8]}..."
437
+ )
438
+
439
+ if no_generate:
440
+ return
441
+
442
+ # Step 3: Generate artifacts
443
+ types = []
444
+ if not no_audio:
445
+ types.append("audio")
446
+ if not no_quiz:
447
+ types.append("quiz")
448
+ if not no_flashcards:
449
+ types.append("flashcards")
450
+
451
+ if types:
452
+ console.print(f"Generating {', '.join(types)}...")
453
+ asyncio.run(generate_for_chapters(nid, 1, len(pdf_files), types=types))
454
+ console.print("[green]\u2713[/green] Generation complete")
455
+
456
+ if not no_download:
457
+ from studyctl.content.notebooklm_client import download_artifacts
458
+
459
+ console.print("Downloading artifacts...")
460
+ paths = asyncio.run(download_artifacts(nid, out))
461
+ for p in paths:
462
+ console.print(f" [green]\u2713[/green] {p.name}")
studyctl/cli/_lazy.py ADDED
@@ -0,0 +1,35 @@
1
+ """LazyGroup — defers command module imports until invoked.
2
+
3
+ Keeps CLI startup fast even with many command modules. Essential once
4
+ content commands (Phase 1) bring heavy deps like pymupdf.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import importlib
10
+
11
+ import click
12
+
13
+
14
+ class LazyGroup(click.Group):
15
+ """Click group that lazy-loads subcommands from dotted import paths."""
16
+
17
+ def __init__(self, *args, lazy_subcommands: dict[str, str] | None = None, **kwargs):
18
+ super().__init__(*args, **kwargs)
19
+ self._lazy_subcommands = lazy_subcommands or {}
20
+
21
+ def list_commands(self, ctx: click.Context) -> list[str]:
22
+ base = super().list_commands(ctx)
23
+ lazy = sorted(self._lazy_subcommands.keys())
24
+ return base + lazy
25
+
26
+ def get_command(self, ctx: click.Context, cmd_name: str) -> click.BaseCommand | None: # type: ignore[override]
27
+ if cmd_name in self._lazy_subcommands:
28
+ return self._resolve(cmd_name)
29
+ return super().get_command(ctx, cmd_name)
30
+
31
+ def _resolve(self, cmd_name: str) -> click.BaseCommand: # type: ignore[return-value]
32
+ import_path = self._lazy_subcommands[cmd_name]
33
+ modname, attr_name = import_path.rsplit(":", 1)
34
+ mod = importlib.import_module(modname)
35
+ return getattr(mod, attr_name)