diff --git a/CHANGELOG.md b/CHANGELOG.md index 7351947..aa00a0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,26 @@ All notable changes to this project are documented here. Pythonlings follows Semantic Versioning. +## [0.4.0] - 2026-06-20 + +### Added + +- Zero-config first run: `pythonlings` with no workspace creates one at + `~/.pythonlings` and opens the first exercise — no `init`, no `--path`, no + `cd` required. +- `PYTHONLINGS_HOME` environment variable to point the default workspace + somewhere else. + +### Changed + +- `pythonlings init` with no `--path` now targets `~/.pythonlings` instead of + the current directory, so it can no longer fail by landing in a folder full + of unrelated files. +- `init` is now idempotent on an existing workspace (a friendly note instead of + an error), and its non-empty-directory error explains how to proceed. +- Every command resolves its workspace the same way: an explicit `--root`, then + the current directory if it is a workspace, then `~/.pythonlings`. + ## [0.3.1] - 2026-06-20 ### Fixed diff --git a/Readme.md b/Readme.md index b750e61..083129a 100644 --- a/Readme.md +++ b/Readme.md @@ -20,14 +20,13 @@ Python documentation snippets so learners can work without leaving the terminal. and [uv](https://docs.astral.sh/uv/): ```bash -uvx pythonlings init --path ./learn-python -cd learn-python && uvx pythonlings +uvx pythonlings ``` How it works: **edit** the broken exercise in the built-in editor → checks rerun as you type and advance you to the next one. That's the whole loop. -Status: `v0.3.1`, alpha — published on PyPI as `pythonlings`. +Status: `v0.4.0`, alpha — published on PyPI as `pythonlings`. ![Coding screen](docs/assets/screenshots/coding-screen.png) @@ -49,8 +48,9 @@ Status: `v0.3.1`, alpha — published on PyPI as `pythonlings`. ## What Happens When You Run It -1. `pythonlings init` creates a self-contained learner workspace with exercises, - hidden checks, local docs, and original snapshots for reset. +1. `pythonlings` with no workspace creates a self-contained one at + `~/.pythonlings` (override with `PYTHONLINGS_HOME` or `pythonlings init + --path `) and opens the first exercise. 2. `pythonlings` opens the first pending exercise in the terminal UI. 3. You edit the broken code, remove `# I AM NOT DONE`, and checks rerun as you work. diff --git a/docs/superpowers/plans/2026-06-20-zero-config-first-run.md b/docs/superpowers/plans/2026-06-20-zero-config-first-run.md new file mode 100644 index 0000000..e3f94ac --- /dev/null +++ b/docs/superpowers/plans/2026-06-20-zero-config-first-run.md @@ -0,0 +1,644 @@ +# Zero-config First Run Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make bare `pythonlings` set up a workspace automatically (hidden `~/.pythonlings`, overridable) and launch the first exercise, so a new user just types `pythonlings`. + +**Architecture:** A new `core/workspace.py` owns the default location and a precedence resolver (explicit `--root` → cwd-workspace → home-workspace → create home). `cli.py` switches `--root`/`--path` to lazy defaults and routes every command's root through the resolver; only TUI-launch commands auto-create. `init` gets friendlier, actionable messages. No curriculum, state-format, or TUI changes. + +**Tech Stack:** Python 3.9+, argparse, pathlib, dataclasses, pytest (+ pytest-asyncio auto mode). Textual stays out of unit tests via mocking `run_tui`. + +## Global Constraints + +- `requires-python = ">=3.9"`; start every new module with `from __future__ import annotations`. +- Default workspace root: `PYTHONLINGS_HOME` env var if set, else `~/.pythonlings`. +- The unrelated `pylings` PyPI name is never used; command/package are `pythonlings`. +- Conventional commit prefixes (`feat:`, `fix:`, `docs:`, `test:`, `chore:`). +- CLI tests must mock `pythonlings.app.run_tui` — never launch Textual. +- Target release: **0.4.0** (minor). +- Keep core free of UI imports (`core/` has no Textual/screens imports). + +--- + +### Task 1: Default location + workspace detection (`core/workspace.py`) + +**Files:** +- Create: `pythonlings/core/workspace.py` +- Test: `tests/unit/test_workspace.py` + +**Interfaces:** +- Produces: `default_workspace_root() -> Path` (reads `PYTHONLINGS_HOME`, else `~/.pythonlings`, always expanded+resolved); `is_workspace(path: Path) -> bool` (True iff `path/"info.toml"` is a file). + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/unit/test_workspace.py +from __future__ import annotations + +from pathlib import Path + +from pythonlings.core.workspace import default_workspace_root, is_workspace + + +def test_default_root_uses_env_when_set(tmp_path, monkeypatch): + monkeypatch.setenv("PYTHONLINGS_HOME", str(tmp_path / "custom")) + assert default_workspace_root() == (tmp_path / "custom").resolve() + + +def test_default_root_falls_back_to_hidden_home_dir(monkeypatch): + monkeypatch.delenv("PYTHONLINGS_HOME", raising=False) + assert default_workspace_root() == (Path.home() / ".pythonlings").resolve() + + +def test_is_workspace_true_only_with_info_toml(tmp_path): + assert is_workspace(tmp_path) is False + (tmp_path / "info.toml").write_text("", encoding="utf-8") + assert is_workspace(tmp_path) is True +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `python -m pytest tests/unit/test_workspace.py -q` +Expected: FAIL with `ModuleNotFoundError: No module named 'pythonlings.core.workspace'`. + +- [ ] **Step 3: Write minimal implementation** + +```python +# pythonlings/core/workspace.py +from __future__ import annotations + +import os +from pathlib import Path + + +def default_workspace_root() -> Path: + """Where a no-argument `pythonlings` keeps its workspace. + + `PYTHONLINGS_HOME` overrides; otherwise a hidden `~/.pythonlings`. + """ + env = os.environ.get("PYTHONLINGS_HOME") + if env: + return Path(env).expanduser().resolve() + return (Path.home() / ".pythonlings").resolve() + + +def is_workspace(path: Path) -> bool: + """True if `path` is a pythonlings workspace (has an `info.toml`).""" + return (path / "info.toml").is_file() +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `python -m pytest tests/unit/test_workspace.py -q` +Expected: PASS (3 passed). + +- [ ] **Step 5: Commit** + +```bash +git add pythonlings/core/workspace.py tests/unit/test_workspace.py +git commit -m "feat: add default_workspace_root and is_workspace helpers" +``` + +--- + +### Task 2: Workspace resolver (`resolve_workspace_root`) + +**Files:** +- Modify: `pythonlings/core/workspace.py` +- Test: `tests/unit/test_workspace.py` + +**Interfaces:** +- Consumes: `default_workspace_root`, `is_workspace` (Task 1); `init_workspace` from `pythonlings.core.curriculum` (signature `init_workspace(path: Path, *, force: bool = False) -> Path`). +- Produces: `ResolvedWorkspace` (frozen dataclass with `root: Path`, `created: bool`); `resolve_workspace_root(cwd: Path, explicit_root: Path | None = None, *, create_if_missing: bool = False) -> ResolvedWorkspace`. + +Precedence: explicit_root → cwd-is-workspace → home-is-workspace → (create home if `create_if_missing` else return home uncreated). + +- [ ] **Step 1: Write the failing tests** + +```python +# append to tests/unit/test_workspace.py +from pythonlings.core.workspace import ResolvedWorkspace, resolve_workspace_root + + +def _make_ws(path: Path) -> Path: + path.mkdir(parents=True, exist_ok=True) + (path / "info.toml").write_text("", encoding="utf-8") + return path + + +def test_resolve_prefers_explicit_root(tmp_path, monkeypatch): + monkeypatch.setenv("PYTHONLINGS_HOME", str(tmp_path / "home")) + explicit = tmp_path / "given" + out = resolve_workspace_root(tmp_path / "cwd", explicit, create_if_missing=True) + assert out == ResolvedWorkspace(explicit.resolve(), created=False) + + +def test_resolve_uses_cwd_when_cwd_is_workspace(tmp_path, monkeypatch): + monkeypatch.setenv("PYTHONLINGS_HOME", str(tmp_path / "home")) + cwd = _make_ws(tmp_path / "cwd") + out = resolve_workspace_root(cwd, None, create_if_missing=True) + assert out == ResolvedWorkspace(cwd.resolve(), created=False) + + +def test_resolve_resumes_existing_home_workspace(tmp_path, monkeypatch): + home = _make_ws(tmp_path / "home") + monkeypatch.setenv("PYTHONLINGS_HOME", str(home)) + out = resolve_workspace_root(tmp_path / "cwd", None, create_if_missing=True) + assert out == ResolvedWorkspace(home.resolve(), created=False) + + +def test_resolve_creates_home_when_missing(tmp_path, monkeypatch): + home = tmp_path / "home" + monkeypatch.setenv("PYTHONLINGS_HOME", str(home)) + out = resolve_workspace_root(tmp_path / "cwd", None, create_if_missing=True) + assert out.root == home.resolve() + assert out.created is True + assert (home / "info.toml").is_file() + + +def test_resolve_does_not_create_when_flag_off(tmp_path, monkeypatch): + home = tmp_path / "home" + monkeypatch.setenv("PYTHONLINGS_HOME", str(home)) + out = resolve_workspace_root(tmp_path / "cwd", None, create_if_missing=False) + assert out == ResolvedWorkspace(home.resolve(), created=False) + assert not home.exists() +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `python -m pytest tests/unit/test_workspace.py -q` +Expected: FAIL with `ImportError: cannot import name 'resolve_workspace_root'`. + +- [ ] **Step 3: Write minimal implementation** + +```python +# add to pythonlings/core/workspace.py +from dataclasses import dataclass + +from pythonlings.core.curriculum import init_workspace + + +@dataclass(frozen=True) +class ResolvedWorkspace: + root: Path + created: bool + + +def resolve_workspace_root( + cwd: Path, + explicit_root: Path | None = None, + *, + create_if_missing: bool = False, +) -> ResolvedWorkspace: + """Pick the workspace root for a command. + + Order: an explicit `--root`, then the current directory if it is a + workspace, then the default home workspace, then (only when + `create_if_missing`) a freshly created home workspace. + """ + if explicit_root is not None: + return ResolvedWorkspace(explicit_root.expanduser().resolve(), created=False) + + cwd = cwd.resolve() + if is_workspace(cwd): + return ResolvedWorkspace(cwd, created=False) + + home = default_workspace_root() + if is_workspace(home): + return ResolvedWorkspace(home, created=False) + + if create_if_missing: + return ResolvedWorkspace(init_workspace(home), created=True) + return ResolvedWorkspace(home, created=False) +``` + +Place the `from dataclasses import dataclass` and `from pythonlings.core.curriculum import init_workspace` imports at the top of the module with the existing imports. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `python -m pytest tests/unit/test_workspace.py -q` +Expected: PASS (8 passed). + +- [ ] **Step 5: Commit** + +```bash +git add pythonlings/core/workspace.py tests/unit/test_workspace.py +git commit -m "feat: add resolve_workspace_root precedence resolver" +``` + +--- + +### Task 3: Friendlier `init` messages + +**Files:** +- Modify: `pythonlings/core/curriculum.py:81-82` (error text) +- Modify: `pythonlings/cli.py:84-93` (`_cmd_init`) +- Test: `tests/integration/test_cli_workspace.py` + +**Interfaces:** +- Consumes: `is_workspace` (Task 1). +- Behavior: init on an existing workspace → friendly, exit 0; init on a non-empty non-workspace dir → actionable error, exit 1; empty/new → scaffold, exit 0. + +- [ ] **Step 1: Update the existing test and add two new ones** + +Replace `test_init_command_requires_force_for_non_empty_directory` and add the two below in `tests/integration/test_cli_workspace.py`: + +```python +def test_init_rejects_non_empty_non_workspace_dir(tmp_path: Path, capsys) -> None: + target = tmp_path / "stuff" + target.mkdir() + (target / "notes.txt").write_text("keep", encoding="utf-8") + + code = main(["init", "--path", str(target)]) + + assert code == 1 + err = capsys.readouterr().err + assert "isn't empty and isn't a pythonlings workspace" in err + + +def test_init_on_existing_workspace_is_friendly_noop(tmp_path: Path, capsys) -> None: + target = tmp_path / "ws" + assert main(["init", "--path", str(target)]) == 0 + capsys.readouterr() # discard first output + + code = main(["init", "--path", str(target)]) + + assert code == 0 + assert "Already set up" in capsys.readouterr().out + + +def test_init_force_overwrites_existing_workspace(tmp_path: Path) -> None: + target = tmp_path / "ws" + assert main(["init", "--path", str(target)]) == 0 + code = main(["init", "--path", str(target), "--force"]) + assert code == 0 + assert (target / "info.toml").exists() +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `python -m pytest tests/integration/test_cli_workspace.py -q` +Expected: FAIL — old message assertion gone; `Already set up` not printed yet. + +- [ ] **Step 3: Update the error message in `curriculum.py`** + +Replace `pythonlings/core/curriculum.py:81-82`: + +```python + if path.exists() and any(path.iterdir()) and not force: + raise WorkspaceError( + f"{path} isn't empty and isn't a pythonlings workspace. " + "Pick another location with --path , or --force to set up here anyway." + ) +``` + +- [ ] **Step 4: Update `_cmd_init` in `cli.py`** + +Replace `pythonlings/cli.py:84-93`: + +```python +def _cmd_init(path: Path, force: bool) -> int: + from pythonlings.core.curriculum import WorkspaceError, init_workspace + from pythonlings.core.workspace import is_workspace + + path = path.expanduser().resolve() + if is_workspace(path) and not force: + print(f"Already set up at {path} — just run `pythonlings`") + return 0 + try: + root = init_workspace(path, force=force) + except WorkspaceError as e: + sys.stderr.write(f"pythonlings: {e}\n") + return 1 + print(f"Created your workspace at {root}") + return 0 +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `python -m pytest tests/integration/test_cli_workspace.py -q` +Expected: PASS (all tests in the file). + +- [ ] **Step 6: Commit** + +```bash +git add pythonlings/core/curriculum.py pythonlings/cli.py tests/integration/test_cli_workspace.py +git commit -m "feat: make init messages actionable and idempotent" +``` + +--- + +### Task 4: Wire defaults + resolver into the CLI + +**Files:** +- Modify: `pythonlings/cli.py` (`_build_parser` defaults; add `_display_path`; rewrite the resolution block in `main`) +- Test: `tests/integration/test_first_run.py` (new), `tests/integration/test_cli_workspace.py` + +**Interfaces:** +- Consumes: `resolve_workspace_root`, `default_workspace_root` (Tasks 1-2). +- Behavior: bare `pythonlings`/`watch`/`start`/`topics` create the home workspace if none is found, print a one-line notice, and launch the TUI on the resolved root. All other root commands resolve cwd-workspace → home-workspace (no create). `init`/`update` with no `--path` default to the home workspace. + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/integration/test_first_run.py +from __future__ import annotations + +from pathlib import Path + +import pythonlings.app as app_module +from pythonlings.cli import main + + +def test_bare_run_creates_home_workspace_and_launches(tmp_path, monkeypatch, capsys): + home = tmp_path / "home" + monkeypatch.setenv("PYTHONLINGS_HOME", str(home)) + monkeypatch.chdir(tmp_path) # cwd is not a workspace + + calls = {} + + def fake_run_tui(root, start_topic, force_picker=False): + calls["root"] = root + return 0 + + monkeypatch.setattr(app_module, "run_tui", fake_run_tui) + + code = main([]) + + assert code == 0 + assert (home / "info.toml").is_file() + assert calls["root"] == home.resolve() + assert "Created your workspace at" in capsys.readouterr().out + + +def test_bare_run_resumes_existing_home_without_notice(tmp_path, monkeypatch, capsys): + home = tmp_path / "home" + monkeypatch.setenv("PYTHONLINGS_HOME", str(home)) + main(["init", "--path", str(home)]) + capsys.readouterr() + monkeypatch.chdir(tmp_path) + + monkeypatch.setattr(app_module, "run_tui", lambda root, t, force_picker=False: 0) + + code = main([]) + + assert code == 0 + assert "Created your workspace" not in capsys.readouterr().out + + +def test_init_with_no_path_targets_home(tmp_path, monkeypatch, capsys): + home = tmp_path / "home" + monkeypatch.setenv("PYTHONLINGS_HOME", str(home)) + monkeypatch.chdir(tmp_path) + + code = main(["init"]) + + assert code == 0 + assert (home / "info.toml").is_file() +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `python -m pytest tests/integration/test_first_run.py -q` +Expected: FAIL — `init` still defaults to cwd; bare run does not create/notice. + +- [ ] **Step 3: Update parser defaults in `_build_parser`** + +In `pythonlings/cli.py`, change line 24 (`--root` default) and lines 30 / 36 (`--path` defaults). First add the import near the top of the module (after the existing imports): + +```python +from pythonlings.core.workspace import default_workspace_root +``` + +Then: + +```python + parser.add_argument( + "--root", + type=Path, + default=None, + help="Workspace root containing info.toml (default: auto-resolve).", + ) +``` + +```python + p_init.add_argument("--path", type=Path, default=default_workspace_root()) +``` + +```python + p_update.add_argument("--path", type=Path, default=default_workspace_root()) +``` + +- [ ] **Step 4: Add the `_display_path` helper** + +Add near `_resolve_topic` in `pythonlings/cli.py`: + +```python +def _display_path(path: Path) -> str: + """Render `path` with a leading `~/` when inside the home directory.""" + try: + return "~/" + str(path.relative_to(Path.home())) + except ValueError: + return str(path) +``` + +- [ ] **Step 5: Rewrite the resolution + dispatch block in `main`** + +Replace `pythonlings/cli.py:261-311` (from the migration comment through the watch branch) with: + +```python + from pythonlings.core.curriculum import migrate_legacy_state_dir + from pythonlings.core.workspace import resolve_workspace_root + + try: + root: Path | None = None + if args.command in ("init", "update"): + migrate_legacy_state_dir(Path(args.path)) + else: + launches_tui = args.command in (None, "watch", "start", "topics") + resolved = resolve_workspace_root( + Path.cwd(), args.root, create_if_missing=launches_tui + ) + root = resolved.root + migrate_legacy_state_dir(root) + if resolved.created: + print( + f"Created your workspace at {_display_path(root)} " + "(edit in-app, or open that folder in your editor)" + ) + + if getattr(args, "debug", False) and root is not None: + try: + (root / ".pythonlings_debug.log").write_text( + f"argv={argv if argv is not None else sys.argv[1:]!r}\n", + encoding="utf-8", + ) + except OSError: + pass + + if args.command == "init": + return _cmd_init(args.path, args.force) + if args.command == "update": + return _cmd_update(args.path) + + assert root is not None + if args.command == "verify": + return _cmd_verify(root, args.topic) + if args.command == "list": + return _cmd_list(root, args.topic) + if args.command == "hint": + return _cmd_hint(root, args.name) + if args.command == "run": + return _cmd_run(root, args.name) + if args.command == "dry-run": + return _cmd_run(root, args.name) + if args.command in {"solution", "sol"}: + return _cmd_solution(root, args.name) + if args.command == "reset": + return _cmd_reset(root, args.name, args.yes) + + if args.command in (None, "watch", "start", "topics"): + start_topic = getattr(args, "topic", None) + if start_topic is not None: + from pythonlings.core.manifest import load as load_manifest + if _resolve_topic(load_manifest(root), start_topic) is None: + return 2 + from pythonlings.app import run_tui # lazy: Textual is heavy + return run_tui( + root, + start_topic, + force_picker=args.command == "topics", + ) + + # Other subcommands wired in later tasks. + sys.stderr.write(f"pythonlings: '{args.command}' not implemented yet\n") + return 1 +``` + +Leave the existing `except Exception as e:` handler that follows it unchanged. + +- [ ] **Step 6: Run the new tests, then the full suite** + +Run: `python -m pytest tests/integration/test_first_run.py -q` +Expected: PASS (3 passed). + +Run: `python -m pytest -q` +Expected: PASS (full suite). If a pre-existing test fails because it relied on `--root` defaulting to cwd while cwd is not a workspace, fix it by passing an explicit `--root` (matching prior intent). + +- [ ] **Step 7: Commit** + +```bash +git add pythonlings/cli.py tests/integration/test_first_run.py +git commit -m "feat: auto-resolve and auto-create the workspace for bare pythonlings" +``` + +--- + +### Task 5: Docs + version bump to 0.4.0 + +**Files:** +- Modify: `Readme.md:19-25` (quick-start), `Readme.md:30` (status), `Readme.md:52-53` (step 1) +- Modify: `pyproject.toml:7` (version) +- Modify: `CHANGELOG.md` (new `[0.4.0]` entry) + +**Interfaces:** none (docs/metadata only). + +- [ ] **Step 1: Update the README quick-start** + +Replace `Readme.md:19-25`: + +```markdown +**Try it in 10 seconds** — zero install, needs [Python 3.9+](https://www.python.org/downloads/) +and [uv](https://docs.astral.sh/uv/): + +```bash +uvx pythonlings +``` +``` + +Replace `Readme.md:30`: + +```markdown +Status: `v0.4.0`, alpha — published on PyPI as `pythonlings`. +``` + +Replace `Readme.md:52-53` (step 1 of "What Happens When You Run It"): + +```markdown +1. `pythonlings` with no workspace creates a self-contained one at + `~/.pythonlings` (override with `PYTHONLINGS_HOME` or `pythonlings init + --path `) and opens the first exercise. +``` + +- [ ] **Step 2: Bump the version** + +Replace `pyproject.toml:7`: + +```toml +version = "0.4.0" +``` + +- [ ] **Step 3: Add the CHANGELOG entry** + +Insert above the most recent entry in `CHANGELOG.md` (after the intro paragraph that ends on line 4): + +```markdown +## [0.4.0] - 2026-06-20 + +### Added + +- Zero-config first run: `pythonlings` with no workspace creates one at + `~/.pythonlings` and opens the first exercise — no `init`, no `--path`, no + `cd` required. +- `PYTHONLINGS_HOME` environment variable to point the default workspace + somewhere else. + +### Changed + +- `pythonlings init` with no `--path` now targets `~/.pythonlings` instead of + the current directory, so it can no longer fail by landing in a folder full + of unrelated files. +- `init` is now idempotent on an existing workspace (a friendly note instead of + an error), and its non-empty-directory error explains how to proceed. +- Every command resolves its workspace the same way: an explicit `--root`, then + the current directory if it is a workspace, then `~/.pythonlings`. + +``` + +- [ ] **Step 4: Verify the build reports the new version** + +Run: +```bash +python -m pytest -q +rm -rf dist && python -m build >/dev/null 2>&1 +python -m pip install --force-reinstall dist/pythonlings-0.4.0-py3-none-any.whl >/dev/null +pythonlings --version +``` +Expected: full suite PASS; `pythonlings 0.4.0`. + +- [ ] **Step 5: Commit** + +```bash +git add Readme.md pyproject.toml CHANGELOG.md +git commit -m "docs: document zero-config first run and bump to 0.4.0" +``` + +--- + +## Self-Review + +**Spec coverage:** +- Default location `PYTHONLINGS_HOME`/`~/.pythonlings` → Task 1. +- `resolve_workspace_root` precedence (4 branches) → Task 2. +- `init` default → home; friendly/actionable messages → Tasks 3-4. +- One-line create notice + `~/.pythonlings` display form → Task 4 (`_display_path`). +- cwd-workspace-wins; `--root`/`--path` custom support preserved → Tasks 2, 4. +- Edge: home exists non-empty non-workspace → `init_workspace` raises the actionable error (Task 3) via the resolver's create branch; surfaces through the existing `except` handler. +- Unit tests (resolver, default root) + integration (init messaging, first run) with `run_tui` mocked → Tasks 1-4. +- Version 0.4.0 + CHANGELOG → Task 5. + +**Placeholder scan:** none — every code/test step is complete. + +**Type consistency:** `default_workspace_root() -> Path`, `is_workspace(Path) -> bool`, `ResolvedWorkspace(root, created)`, and `resolve_workspace_root(cwd, explicit_root=None, *, create_if_missing=False)` are used identically across Tasks 2 and 4. `_display_path(Path) -> str` used only in Task 4. + +**Known risk:** changing `--root`'s default from `Path.cwd()` to `None` could surface a pre-existing test that ran a root command without `--root` from a non-workspace cwd. Task 4 Step 6 runs the full suite to catch it; the fix is to pass an explicit `--root`. diff --git a/docs/superpowers/specs/2026-06-20-zero-config-first-run-design.md b/docs/superpowers/specs/2026-06-20-zero-config-first-run-design.md new file mode 100644 index 0000000..c3d40d9 --- /dev/null +++ b/docs/superpowers/specs/2026-06-20-zero-config-first-run-design.md @@ -0,0 +1,128 @@ +# Zero-config first run for pythonlings + +- **Date:** 2026-06-20 +- **Status:** Approved design +- **Target release:** 0.4.0 (minor — new behavior) + +## Problem + +`pythonlings init` and bare `pythonlings` both default their target directory to +the current working directory (`Path.cwd()`): + +- `init` scaffolds into cwd and aborts with ` already exists and is not + empty` when cwd contains unrelated files. Observed in the wild: a user ran + `pythonlings init` inside a folder full of `.mp4` screen recordings and got a + cryptic failure. +- Bare `pythonlings` launches the TUI against cwd and only prints guidance when + cwd lacks `info.toml`. + +A brand-new user therefore has to know to create and enter a dedicated directory +before anything works. We want Rustlings-grade onboarding: install, type +`pythonlings`, start learning. + +## Goals + +- A first-time user runs `pythonlings` (no flags, from any directory) and lands + in the first exercise. +- No prompts, no required `init`, no `--path`. +- Existing workflows keep working: `--root`, `--path`, running inside a + workspace, and the test fixtures. +- `init`'s confusing non-empty error is replaced with actionable guidance. + +## Non-goals + +- An in-TUI welcome / onboarding screen (possible later; not required here). +- Changes to the curriculum, the state format, or the TUI editor. + +## Design + +### Default workspace location + +- Default workspace root: the `PYTHONLINGS_HOME` environment variable if set, + otherwise `~/.pythonlings` (a hidden folder). +- Why hidden: exercises are edited in the **built-in TUI editor** (checks rerun + as you type), so a beginner never navigates to the files on disk. A hidden + folder keeps `$HOME` clean. Power users who prefer their own editor pass an + explicit `--path` / `--root` pointing at a visible location. +- Single source of truth: `default_workspace_root() -> Path` in `core/`. It + reads `PYTHONLINGS_HOME` (expanded + resolved) and falls back to + `Path.home() / ".pythonlings"`. +- Note: the in-workspace state dir is also named `.pythonlings/`, so state for + the default workspace lands at `~/.pythonlings/.pythonlings/state.json`. + Functional; no change to the state-dir convention. + +### Workspace resolution (bare `pythonlings`, no `--root`) + +A new `resolve_workspace_root(cwd, explicit_root=None)` in `core/` returns the +chosen root and whether it was just created (e.g. a small dataclass/namedtuple +`ResolvedWorkspace(root: Path, created: bool)`). Precedence: + +1. **`explicit_root` given** (`--root`): use it as-is. Existing behavior — error + later if it is not a workspace. +2. **cwd is a workspace** (`cwd/info.toml` exists): use cwd. Preserves "run + inside a workspace", multiple workspaces, and the `--root` test fixtures. +3. **default home workspace exists** (`default_workspace_root()/info.toml` + exists): resume it, silently. +4. **none of the above**: create a workspace at `default_workspace_root()` via + `init_workspace`, and return `created=True`. + +`cli.py` calls the resolver, prints the one-line create notice when `created`, +then launches the TUI. The TUI already resumes at the first unsolved exercise, +so a fresh workspace lands on `variables1` with no extra code. + +### `init` changes + +- `--path` default changes from `Path.cwd()` to `default_workspace_root()`. +- `_cmd_init` / `init_workspace` behavior by target state: + - **Already a workspace** (`info.toml` present): print + `` Already set up at — just run `pythonlings` `` and exit `0` (not an + error). + - **Empty or nonexistent**: scaffold as today. + - **Non-empty and NOT a workspace**: error with + ` isn't empty and isn't a pythonlings workspace. Pick another location with --path , or --force to set up here anyway.` + and a non-zero exit. This replaces today's cryptic message. + - **`--force`**: scaffold regardless (unchanged). + +### Messaging + +- Create notice: `Created your workspace at (edit in-app, or open that folder in your editor)`, + where `` renders the `~/.pythonlings` form rather than an + absolute path. +- Resume: silent (just launch). + +### Edge cases + +- Default home root exists, non-empty, no `info.toml`: treated as the `init` + non-workspace error (never clobber). Rare. +- Not a TTY: the resolver still creates the workspace; TUI launch behaves as it + does today in non-interactive contexts (out of scope to change here). +- `PYTHONLINGS_HOME` set to a relative or `~`-prefixed path: resolved via + `Path(...).expanduser().resolve()`. + +## Affected code + +- `core/` (`curriculum.py`, or a focused new `core/workspace.py`): + `default_workspace_root()`, `resolve_workspace_root()`, and the friendlier + `init_workspace` messaging. +- `cli.py`: `--path` / `--root` defaults; the bare-command resolver call + create + notice; `_cmd_init` messaging. + +## Testing + +- **Unit** (`tests/unit/`): + - `default_workspace_root()`: respects `PYTHONLINGS_HOME`; falls back to + `~/.pythonlings` (monkeypatch env + `Path.home`). + - `resolve_workspace_root()`: all four precedence branches (explicit root, + cwd-workspace, existing home workspace, create-new), asserting the chosen + root and the `created` flag, using `tmp_path` + monkeypatched home. No TUI. + - `init` messaging: existing-workspace → friendly exit 0; non-empty + non-workspace → actionable error; empty → scaffolds. +- **Integration** (`tests/integration/`): `pythonlings init` (no path) targets + the monkeypatched home; bare run resolves correctly with `run_tui` patched. +- Keep `run_tui` mocked in CLI tests so Textual stays out of unit tests. + +## Versioning + +Minor feature → **0.4.0**. CHANGELOG entries under `Added` (zero-config first +run, `PYTHONLINGS_HOME`) and `Changed` (`init` default location + friendlier +errors). diff --git a/pyproject.toml b/pyproject.toml index 924dc03..d19ec16 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "pythonlings" -version = "0.3.1" +version = "0.4.0" description = "Python learnings, Rustlings-style, in a terminal TUI." readme = "Readme.md" requires-python = ">=3.9" diff --git a/pythonlings/cli.py b/pythonlings/cli.py index 9ffccb1..f12905c 100644 --- a/pythonlings/cli.py +++ b/pythonlings/cli.py @@ -6,6 +6,8 @@ from importlib.metadata import PackageNotFoundError, version as _package_version from pathlib import Path +from pythonlings.core.workspace import default_workspace_root + try: __version__ = _package_version("pythonlings") except PackageNotFoundError: # running from a source checkout without an install @@ -21,19 +23,19 @@ def _build_parser() -> argparse.ArgumentParser: parser.add_argument( "--root", type=Path, - default=Path.cwd(), - help="Project root containing info.toml (default: cwd).", + default=None, + help="Workspace root containing info.toml (default: auto-resolve).", ) sub = parser.add_subparsers(dest="command") p_init = sub.add_parser("init", help="Create a pythonlings workspace.") - p_init.add_argument("--path", type=Path, default=Path.cwd()) + p_init.add_argument("--path", type=Path, default=default_workspace_root()) p_init.add_argument( "--force", action="store_true", help="Overwrite managed workspace files." ) p_update = sub.add_parser("update", help="Update an existing pythonlings workspace.") - p_update.add_argument("--path", type=Path, default=Path.cwd()) + p_update.add_argument("--path", type=Path, default=default_workspace_root()) sub.add_parser("watch", help="Launch the TUI in watch mode (default).") sub.add_parser("topics", help="Launch the TUI on the topic picker.") @@ -70,6 +72,14 @@ def _build_parser() -> argparse.ArgumentParser: return parser +def _display_path(path: Path) -> str: + """Render `path` with a leading `~/` when inside the home directory.""" + try: + return "~/" + str(path.relative_to(Path.home())) + except ValueError: + return str(path) + + def _resolve_topic(manifest, topic: str): """Return the topic name if valid, else write an error and return None.""" if topic in manifest.topics(): @@ -83,13 +93,18 @@ def _resolve_topic(manifest, topic: str): def _cmd_init(path: Path, force: bool) -> int: from pythonlings.core.curriculum import WorkspaceError, init_workspace + from pythonlings.core.workspace import is_workspace + path = path.expanduser().resolve() + if is_workspace(path) and not force: + print(f"Already set up at {path} — just run `pythonlings`") + return 0 try: root = init_workspace(path, force=force) except WorkspaceError as e: sys.stderr.write(f"pythonlings: {e}\n") return 1 - print(f"initialized: {root}") + print(f"Created your workspace at {_display_path(root)}") return 0 @@ -258,19 +273,29 @@ def main(argv: list[str] | None = None) -> int: parser = _build_parser() args = parser.parse_args(argv if argv is not None else sys.argv[1:]) - # Migrate any legacy .pylings/ state dir. init/update target --path; the - # in-workspace commands target --root. Both are no-ops without a legacy dir. from pythonlings.core.curriculum import migrate_legacy_state_dir - - for attr in ("path", "root"): - workspace = getattr(args, attr, None) - if workspace is not None: - migrate_legacy_state_dir(Path(workspace)) + from pythonlings.core.workspace import resolve_workspace_root try: - if getattr(args, "debug", False): + root: Path | None = None + if args.command in ("init", "update"): + migrate_legacy_state_dir(Path(args.path)) + else: + launches_tui = args.command in (None, "watch", "start", "topics") + resolved = resolve_workspace_root( + Path.cwd(), args.root, create_if_missing=launches_tui + ) + root = resolved.root + migrate_legacy_state_dir(root) + if resolved.created: + print( + f"Created your workspace at {_display_path(root)} " + "(edit in-app, or open that folder in your editor)" + ) + + if getattr(args, "debug", False) and root is not None: try: - (args.root / ".pythonlings_debug.log").write_text( + (root / ".pythonlings_debug.log").write_text( f"argv={argv if argv is not None else sys.argv[1:]!r}\n", encoding="utf-8", ) @@ -282,30 +307,31 @@ def main(argv: list[str] | None = None) -> int: if args.command == "update": return _cmd_update(args.path) + assert root is not None if args.command == "verify": - return _cmd_verify(args.root, args.topic) + return _cmd_verify(root, args.topic) if args.command == "list": - return _cmd_list(args.root, args.topic) + return _cmd_list(root, args.topic) if args.command == "hint": - return _cmd_hint(args.root, args.name) + return _cmd_hint(root, args.name) if args.command == "run": - return _cmd_run(args.root, args.name) + return _cmd_run(root, args.name) if args.command == "dry-run": - return _cmd_run(args.root, args.name) + return _cmd_run(root, args.name) if args.command in {"solution", "sol"}: - return _cmd_solution(args.root, args.name) + return _cmd_solution(root, args.name) if args.command == "reset": - return _cmd_reset(args.root, args.name, args.yes) + return _cmd_reset(root, args.name, args.yes) if args.command in (None, "watch", "start", "topics"): start_topic = getattr(args, "topic", None) if start_topic is not None: from pythonlings.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 pythonlings.app import run_tui # lazy: Textual is heavy return run_tui( - args.root, + root, start_topic, force_picker=args.command == "topics", ) diff --git a/pythonlings/core/curriculum.py b/pythonlings/core/curriculum.py index d9a7e58..655dbf5 100644 --- a/pythonlings/core/curriculum.py +++ b/pythonlings/core/curriculum.py @@ -79,7 +79,10 @@ def _sync_originals(root: Path, src_root: Path) -> None: def init_workspace(path: Path, *, force: bool = False) -> Path: path = path.expanduser().resolve() if path.exists() and any(path.iterdir()) and not force: - raise WorkspaceError(f"{path} already exists and is not empty") + raise WorkspaceError( + f"{path} isn't empty and isn't a pythonlings workspace. " + "Pick another location with --path , or --force to set up here anyway." + ) path.mkdir(parents=True, exist_ok=True) src_root = source_root() diff --git a/pythonlings/core/manifest.py b/pythonlings/core/manifest.py index ca6b188..fcf41e0 100644 --- a/pythonlings/core/manifest.py +++ b/pythonlings/core/manifest.py @@ -50,7 +50,7 @@ def load(root: Path) -> Manifest: info_path = root / "info.toml" if not info_path.exists(): raise ManifestError( - f"no pythonlings workspace here ({info_path} not found). " + f"no pythonlings workspace at {root} (info.toml not found). " "Run 'pythonlings init' to create one." ) diff --git a/pythonlings/core/workspace.py b/pythonlings/core/workspace.py new file mode 100644 index 0000000..c55713e --- /dev/null +++ b/pythonlings/core/workspace.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + +from pythonlings.core.curriculum import init_workspace + + +@dataclass(frozen=True) +class ResolvedWorkspace: + root: Path + created: bool + + +def default_workspace_root() -> Path: + """Where a no-argument `pythonlings` keeps its workspace. + + `PYTHONLINGS_HOME` overrides; otherwise a hidden `~/.pythonlings`. + """ + env = os.environ.get("PYTHONLINGS_HOME") + if env: + return Path(env).expanduser().resolve() + return (Path.home() / ".pythonlings").resolve() + + +def is_workspace(path: Path) -> bool: + """True if `path` is a pythonlings workspace (has an `info.toml`).""" + return (path / "info.toml").is_file() + + +def resolve_workspace_root( + cwd: Path, + explicit_root: Path | None = None, + *, + create_if_missing: bool = False, +) -> ResolvedWorkspace: + """Pick the workspace root for a command. + + Order: an explicit `--root`, then the current directory if it is a + workspace, then the default home workspace, then (only when + `create_if_missing`) a freshly created home workspace. + """ + if explicit_root is not None: + return ResolvedWorkspace(explicit_root.expanduser().resolve(), created=False) + + cwd = cwd.resolve() + if is_workspace(cwd): + return ResolvedWorkspace(cwd, created=False) + + home = default_workspace_root() + if is_workspace(home): + return ResolvedWorkspace(home, created=False) + + if create_if_missing: + return ResolvedWorkspace(init_workspace(home), created=True) + return ResolvedWorkspace(home, created=False) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..dda4e9b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +import pytest + + +@pytest.fixture(autouse=True, scope="function") +def _isolate_pythonlings_home(monkeypatch, tmp_path): + """Point PYTHONLINGS_HOME at a fresh tmp dir for every test. + + This prevents any test from accidentally creating or reading the + developer's real ~/.pythonlings workspace. + + Tests that need to exercise the *absence* of PYTHONLINGS_HOME + (e.g. test_default_root_falls_back_to_hidden_home_dir) can simply + call ``monkeypatch.delenv("PYTHONLINGS_HOME", raising=False)`` in + their own body — the per-test monkeypatch runs after this fixture + but within the same scope, so the test's own delenv/setenv wins. + """ + monkeypatch.setenv("PYTHONLINGS_HOME", str(tmp_path / "_pythonlings_home")) diff --git a/tests/integration/test_cli_workspace.py b/tests/integration/test_cli_workspace.py index d6ba721..7294d22 100644 --- a/tests/integration/test_cli_workspace.py +++ b/tests/integration/test_cli_workspace.py @@ -14,17 +14,35 @@ def test_init_command_creates_workspace(tmp_path: Path) -> None: assert (target / "checks").is_dir() -def test_init_command_requires_force_for_non_empty_directory( - tmp_path: Path, capsys -) -> None: - target = tmp_path / "learn-python" +def test_init_rejects_non_empty_non_workspace_dir(tmp_path: Path, capsys) -> None: + target = tmp_path / "stuff" target.mkdir() (target / "notes.txt").write_text("keep", encoding="utf-8") code = main(["init", "--path", str(target)]) assert code == 1 - assert "already exists and is not empty" in capsys.readouterr().err + err = capsys.readouterr().err + assert "isn't empty and isn't a pythonlings workspace" in err + + +def test_init_on_existing_workspace_is_friendly_noop(tmp_path: Path, capsys) -> None: + target = tmp_path / "ws" + assert main(["init", "--path", str(target)]) == 0 + capsys.readouterr() # discard first output + + code = main(["init", "--path", str(target)]) + + assert code == 0 + assert "Already set up" in capsys.readouterr().out + + +def test_init_force_overwrites_existing_workspace(tmp_path: Path) -> None: + target = tmp_path / "ws" + assert main(["init", "--path", str(target)]) == 0 + code = main(["init", "--path", str(target), "--force"]) + assert code == 0 + assert (target / "info.toml").exists() def test_update_via_path_migrates_legacy_state_dir(tmp_path: Path) -> None: diff --git a/tests/integration/test_first_run.py b/tests/integration/test_first_run.py new file mode 100644 index 0000000..0e40ee2 --- /dev/null +++ b/tests/integration/test_first_run.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from pathlib import Path + +import pythonlings.app as app_module +from pythonlings.cli import main + + +def test_bare_run_creates_home_workspace_and_launches(tmp_path, monkeypatch, capsys): + home = tmp_path / "home" + monkeypatch.setenv("PYTHONLINGS_HOME", str(home)) + monkeypatch.chdir(tmp_path) # cwd is not a workspace + + calls = {} + + def fake_run_tui(root, start_topic, force_picker=False): + calls["root"] = root + return 0 + + monkeypatch.setattr(app_module, "run_tui", fake_run_tui) + + code = main([]) + + assert code == 0 + assert (home / "info.toml").is_file() + assert calls["root"] == home.resolve() + assert "Created your workspace at" in capsys.readouterr().out + + +def test_bare_run_resumes_existing_home_without_notice(tmp_path, monkeypatch, capsys): + home = tmp_path / "home" + monkeypatch.setenv("PYTHONLINGS_HOME", str(home)) + main(["init", "--path", str(home)]) + capsys.readouterr() + monkeypatch.chdir(tmp_path) + + monkeypatch.setattr(app_module, "run_tui", lambda root, t, force_picker=False: 0) + + code = main([]) + + assert code == 0 + assert "Created your workspace" not in capsys.readouterr().out + + +def test_init_with_no_path_targets_home(tmp_path, monkeypatch, capsys): + home = tmp_path / "home" + monkeypatch.setenv("PYTHONLINGS_HOME", str(home)) + monkeypatch.chdir(tmp_path) + + code = main(["init"]) + + assert code == 0 + assert (home / "info.toml").is_file() diff --git a/tests/unit/test_curriculum.py b/tests/unit/test_curriculum.py index 948cb45..f923e88 100644 --- a/tests/unit/test_curriculum.py +++ b/tests/unit/test_curriculum.py @@ -38,7 +38,7 @@ def test_init_workspace_refuses_non_empty_directory(tmp_path: Path) -> None: try: curriculum.init_workspace(target) except curriculum.WorkspaceError as exc: - assert "already exists and is not empty" in str(exc) + assert "isn't empty and isn't a pythonlings workspace" in str(exc) else: raise AssertionError("expected WorkspaceError") diff --git a/tests/unit/test_workspace.py b/tests/unit/test_workspace.py new file mode 100644 index 0000000..bb9f8dc --- /dev/null +++ b/tests/unit/test_workspace.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from pathlib import Path + +from pythonlings.core.workspace import ( + ResolvedWorkspace, + default_workspace_root, + is_workspace, + resolve_workspace_root, +) + + +def test_default_root_uses_env_when_set(tmp_path, monkeypatch): + monkeypatch.setenv("PYTHONLINGS_HOME", str(tmp_path / "custom")) + assert default_workspace_root() == (tmp_path / "custom").resolve() + + +def test_default_root_falls_back_to_hidden_home_dir(monkeypatch): + monkeypatch.delenv("PYTHONLINGS_HOME", raising=False) + assert default_workspace_root() == (Path.home() / ".pythonlings").resolve() + + +def test_is_workspace_true_only_with_info_toml(tmp_path): + assert is_workspace(tmp_path) is False + (tmp_path / "info.toml").write_text("", encoding="utf-8") + assert is_workspace(tmp_path) is True + + +def _make_ws(path: Path) -> Path: + path.mkdir(parents=True, exist_ok=True) + (path / "info.toml").write_text("", encoding="utf-8") + return path + + +def test_resolve_prefers_explicit_root(tmp_path, monkeypatch): + monkeypatch.setenv("PYTHONLINGS_HOME", str(tmp_path / "home")) + explicit = tmp_path / "given" + out = resolve_workspace_root(tmp_path / "cwd", explicit, create_if_missing=True) + assert out == ResolvedWorkspace(explicit.resolve(), created=False) + + +def test_resolve_uses_cwd_when_cwd_is_workspace(tmp_path, monkeypatch): + monkeypatch.setenv("PYTHONLINGS_HOME", str(tmp_path / "home")) + cwd = _make_ws(tmp_path / "cwd") + out = resolve_workspace_root(cwd, None, create_if_missing=True) + assert out == ResolvedWorkspace(cwd.resolve(), created=False) + + +def test_resolve_resumes_existing_home_workspace(tmp_path, monkeypatch): + home = _make_ws(tmp_path / "home") + monkeypatch.setenv("PYTHONLINGS_HOME", str(home)) + out = resolve_workspace_root(tmp_path / "cwd", None, create_if_missing=True) + assert out == ResolvedWorkspace(home.resolve(), created=False) + + +def test_resolve_creates_home_when_missing(tmp_path, monkeypatch): + home = tmp_path / "home" + monkeypatch.setenv("PYTHONLINGS_HOME", str(home)) + out = resolve_workspace_root(tmp_path / "cwd", None, create_if_missing=True) + assert out.root == home.resolve() + assert out.created is True + assert (home / "info.toml").is_file() + + +def test_resolve_does_not_create_when_flag_off(tmp_path, monkeypatch): + home = tmp_path / "home" + monkeypatch.setenv("PYTHONLINGS_HOME", str(home)) + out = resolve_workspace_root(tmp_path / "cwd", None, create_if_missing=False) + assert out == ResolvedWorkspace(home.resolve(), created=False) + assert not home.exists()