From 83b8229ace45bb59de5b1028e834379181f66210 Mon Sep 17 00:00:00 2001 From: Abhik Sarkar Date: Wed, 3 Jun 2026 01:43:35 +0530 Subject: [PATCH 1/3] feat: auto-init a workspace when launching with none (#13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #13. Running `pylings` (or watch/start/topics) in a directory without a workspace previously raised ManifestError and exited 2 — the #1 beginner trap. Now it auto-creates a workspace at ./pylings-workspace (reusing one if already present) and launches into it. - New ensure_workspace(root) in core/curriculum.py: returns root if it is already a workspace, reuses a prior ./pylings-workspace, else inits one. Returns (path, created_new). - cli.main() resolves the root via ensure_workspace before run_tui and prints where the workspace is when it differs from the requested root. - Tests: unit coverage for ensure_workspace (3 cases) and a headless CLI integration test (start with a bad topic returns before the TUI). --- pylings/cli.py | 11 +++++++-- pylings/core/curriculum.py | 21 +++++++++++++++++ tests/integration/test_cli_autoinit.py | 21 +++++++++++++++++ tests/unit/test_workspace_autoinit.py | 31 ++++++++++++++++++++++++++ 4 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 tests/integration/test_cli_autoinit.py create mode 100644 tests/unit/test_workspace_autoinit.py diff --git a/pylings/cli.py b/pylings/cli.py index 817bafe..2233c4e 100644 --- a/pylings/cli.py +++ b/pylings/cli.py @@ -290,14 +290,21 @@ def main(argv: list[str] | None = None) -> int: return _cmd_reset(args.root, args.name, args.yes) if args.command in (None, "watch", "start", "topics"): + from pylings.core.curriculum import ensure_workspace + root, created = ensure_workspace(args.root) + if root != args.root.expanduser().resolve(): + if created: + print(f"No pylings workspace found here; created one at {root}") + else: + print(f"Using existing pylings workspace at {root}") start_topic = getattr(args, "topic", None) if start_topic is not None: from pylings.core.manifest import load as load_manifest - if _resolve_topic(load_manifest(args.root), start_topic) is None: + if _resolve_topic(load_manifest(root), start_topic) is None: return 2 from pylings.app import run_tui # lazy: Textual is heavy return run_tui( - args.root, + root, start_topic, force_picker=args.command == "topics", watch_files=getattr(args, "watch_files", False), diff --git a/pylings/core/curriculum.py b/pylings/core/curriculum.py index d59e493..809f718 100644 --- a/pylings/core/curriculum.py +++ b/pylings/core/curriculum.py @@ -10,6 +10,7 @@ class WorkspaceError(RuntimeError): CURRICULUM_DIRS = ("exercises", "checks", "solutions") +DEFAULT_WORKSPACE_DIRNAME = "pylings-workspace" GITIGNORE_LINES = [ ".pylings/state.json", ".pylings_debug.log", @@ -79,6 +80,26 @@ def init_workspace(path: Path, *, force: bool = False) -> Path: return path +def ensure_workspace(root: Path) -> tuple[Path, bool]: + """Return a usable workspace for `root`, creating one if needed. + + - If `root` is already a workspace (has `info.toml`), use it as-is. + - Else if a previously auto-created `root/pylings-workspace` exists, reuse it. + - Otherwise initialize a fresh workspace at `root/pylings-workspace`. + + Returns `(workspace_path, created_new)`. + """ + root = root.expanduser().resolve() + if (root / "info.toml").exists(): + return root, False + + default = root / DEFAULT_WORKSPACE_DIRNAME + if (default / "info.toml").exists(): + return default.resolve(), False + + return init_workspace(default), True + + def update_workspace(path: Path) -> Path: path = path.expanduser().resolve() if not (path / "info.toml").exists(): diff --git a/tests/integration/test_cli_autoinit.py b/tests/integration/test_cli_autoinit.py new file mode 100644 index 0000000..f37edc1 --- /dev/null +++ b/tests/integration/test_cli_autoinit.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + + +def _run(*args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, "-m", "pylings", *args], + capture_output=True, + text=True, + ) + + +def test_launch_in_empty_dir_autoinits_workspace(tmp_path: Path) -> None: + # `start` with a non-existent topic returns before launching the TUI, so we + # can assert the auto-init side effect without needing a terminal. + _run("--root", str(tmp_path), "start", "__no_such_topic__") + + assert (tmp_path / "pylings-workspace" / "info.toml").exists() diff --git a/tests/unit/test_workspace_autoinit.py b/tests/unit/test_workspace_autoinit.py new file mode 100644 index 0000000..f7a31a3 --- /dev/null +++ b/tests/unit/test_workspace_autoinit.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from pathlib import Path + +from pylings.core.curriculum import ensure_workspace, init_workspace + + +def test_autoinits_workspace_in_empty_dir(tmp_path: Path) -> None: + work, created = ensure_workspace(tmp_path) + + assert created is True + assert work == (tmp_path / "pylings-workspace").resolve() + assert (work / "info.toml").exists() + + +def test_returns_root_unchanged_when_already_a_workspace(tmp_path: Path) -> None: + init_workspace(tmp_path) + + work, created = ensure_workspace(tmp_path) + + assert created is False + assert work == tmp_path.resolve() + + +def test_reuses_previously_autoinited_workspace(tmp_path: Path) -> None: + first, first_created = ensure_workspace(tmp_path) + second, second_created = ensure_workspace(tmp_path) + + assert first_created is True + assert second_created is False + assert second == first From 342816eee601d9c9ce3370e0864df0ee3f8dc1ea Mon Sep 17 00:00:00 2001 From: Abhik Sarkar Date: Wed, 3 Jun 2026 01:45:38 +0530 Subject: [PATCH 2/3] feat: show overall curriculum progress in the TUI (#15) Closes #15. The progress bar previously showed only the current topic's completion. It now also shows overall progress across all exercises, so learners can see how far they are in the whole curriculum. - New pure format_progress(...) in widgets/progress.py renders both the topic and overall bars/counts; ProgressBar.update_progress delegates to it. - TrackScreen._render_state passes overall counts (len(state.completed) vs len(manifest.exercises)). - Tests cover the formatter incl. the zero-total edge case. --- pylings/screens/track.py | 6 +++++- pylings/widgets/progress.py | 36 ++++++++++++++++++++++++++++++------ tests/unit/test_progress.py | 22 ++++++++++++++++++++++ 3 files changed, 57 insertions(+), 7 deletions(-) create mode 100644 tests/unit/test_progress.py diff --git a/pylings/screens/track.py b/pylings/screens/track.py index ac66dc8..30899d5 100644 --- a/pylings/screens/track.py +++ b/pylings/screens/track.py @@ -82,7 +82,11 @@ def _initial_exercise(self) -> str | None: def _render_state(self) -> None: exs = self._exercises() done = sum(1 for ex in exs if ex.name in self.app.state.completed) - self.query_one(ProgressBar).update_progress(done, len(exs)) + overall_total = len(self.app.manifest.exercises) + overall_done = len(self.app.state.completed) + self.query_one(ProgressBar).update_progress( + done, len(exs), overall_done, overall_total + ) self.query_one(ExerciseTree).render_topic( self.topic, exs, self.app.state.completed, self.current ) diff --git a/pylings/widgets/progress.py b/pylings/widgets/progress.py index 76d2a8e..7c44c2e 100644 --- a/pylings/widgets/progress.py +++ b/pylings/widgets/progress.py @@ -3,13 +3,37 @@ from textual.widgets import Static +_BAR_WIDTH = 20 + + +def _bar(completed: int, total: int) -> tuple[str, float]: + pct = (completed / total * 100) if total else 0 + filled = int(_BAR_WIDTH * pct / 100) + return "█" * filled + "░" * (_BAR_WIDTH - filled), pct + + +def format_progress( + completed: int, total: int, overall_completed: int, overall_total: int +) -> str: + """Render a one-line progress display showing topic and overall progress.""" + topic_bar, topic_pct = _bar(completed, total) + overall_bar, overall_pct = _bar(overall_completed, overall_total) + return ( + f"Topic: {topic_bar} {completed}/{total} ({topic_pct:.0f}%)" + f" Overall: {overall_bar} {overall_completed}/{overall_total} ({overall_pct:.0f}%)" + ) + class ProgressBar(Static): DEFAULT_CSS = "" - def update_progress(self, completed: int, total: int) -> None: - pct = (completed / total * 100) if total else 0 - bar_width = 20 - filled = int(bar_width * pct / 100) - bar = "█" * filled + "░" * (bar_width - filled) - self.update(f"Progress: {bar} {completed}/{total} ({pct:.0f}%)") + def update_progress( + self, + completed: int, + total: int, + overall_completed: int, + overall_total: int, + ) -> None: + self.update( + format_progress(completed, total, overall_completed, overall_total) + ) diff --git a/tests/unit/test_progress.py b/tests/unit/test_progress.py new file mode 100644 index 0000000..721a588 --- /dev/null +++ b/tests/unit/test_progress.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from pylings.widgets.progress import format_progress + + +def test_format_progress_shows_topic_and_overall() -> None: + line = format_progress(2, 5, 10, 100) + + # topic portion: 2/5 == 40% + assert "2/5" in line + assert "40%" in line + # overall portion: 10/100 == 10% + assert "10/100" in line + assert "10%" in line + assert "Overall" in line + + +def test_format_progress_handles_zero_totals() -> None: + line = format_progress(0, 0, 0, 0) + + assert "0/0" in line + assert "0%" in line From cb7ea86d633df6964bd2f004ecfb032e7b1bb4cb Mon Sep 17 00:00:00 2001 From: Abhik Sarkar Date: Wed, 3 Jun 2026 01:48:36 +0530 Subject: [PATCH 3/3] feat: celebrate finishing the whole curriculum (#16) Closes #16. A per-topic completion message already existed, but finishing the entire curriculum had no payoff. When the final pending exercise across all topics is completed, the track screen now shows a celebration (count, a shareable line, and a GitHub-star nudge) instead of the plain topic-complete message. - celebration_message(total) builds the text; shown via OutputPanel.show_final. - Gated on next_pending(manifest.exercises, completed) is None, so it fires only at true 100%. Non-blocking: F4/Ctrl+Q still work. - Unit test covers the message content. --- pylings/screens/track.py | 23 ++++++++++++++++++++--- tests/unit/test_celebration.py | 10 ++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) create mode 100644 tests/unit/test_celebration.py diff --git a/pylings/screens/track.py b/pylings/screens/track.py index 30899d5..76efeef 100644 --- a/pylings/screens/track.py +++ b/pylings/screens/track.py @@ -19,6 +19,17 @@ _DEBOUNCE_SECONDS = 0.6 +def celebration_message(total: int) -> str: + """Message shown when every exercise in the curriculum is complete.""" + return ( + f"🎉 You finished all {total} pylings exercises! 🎉\n\n" + "That's the whole curriculum — nicely done.\n" + f"Share it: \"I just completed all {total} pylings Python exercises 🎉\"\n" + "If pylings helped, a ⭐ on GitHub or a contribution is hugely appreciated.\n\n" + "Press Ctrl+Q to quit, or F4 to revisit topics." + ) + + class TrackScreen(Screen[None]): """One topic's linear track: editor + output + auto-save loop.""" @@ -179,9 +190,15 @@ def _apply_result(self, exercise: Exercise, result: RunResult) -> None: self._render_state() if self.current is None: self._record_resume(None) - self.query_one(OutputPanel).show_final( - f"Topic '{self.topic}' complete — press F4 for topics." - ) + all_exercises = self.app.manifest.exercises + if next_pending(all_exercises, self.app.state.completed) is None: + self.query_one(OutputPanel).show_final( + celebration_message(len(all_exercises)) + ) + else: + self.query_one(OutputPanel).show_final( + f"Topic '{self.topic}' complete — press F4 for topics." + ) return self._load_current() self._run_current() diff --git a/tests/unit/test_celebration.py b/tests/unit/test_celebration.py new file mode 100644 index 0000000..76b25fd --- /dev/null +++ b/tests/unit/test_celebration.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from pylings.screens.track import celebration_message + + +def test_celebration_message_includes_count_and_is_celebratory() -> None: + msg = celebration_message(292) + + assert "292" in msg + assert "🎉" in msg