From e964114f6c2c98372b889c247942b7cff2fead52 Mon Sep 17 00:00:00 2001 From: Abhik Sarkar Date: Mon, 17 Aug 2026 00:28:04 +0530 Subject: [PATCH 1/5] docs: document contributor branch policy (#62) Document the dev-first contributor workflow, squash integration policy, and merge-commit release promotion. --- AGENTS.md | 12 +++++++++--- CONTRIBUTING.md | 16 +++++++++++++++- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 73020bf..821566c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,3 +1,4 @@ + # AGENTS.md ## Scope @@ -46,10 +47,15 @@ Record the exact commands and their results in the pull request description. ## Branch and Merge Policy -- Branch from the current `main` and target pull requests to `main`. -- Use `dev` only when an explicitly approved release plan reactivates it. +- Follow `CONTRIBUTING.md` for branch naming, pull request content, and the + contributor workflow. +- Branch from the current `dev` using `feature/` or `fix/`, and + target pull requests to `dev`. - Keep pull requests in draft until local validation is complete and recorded. -- Merge with a merge commit only after CI passes and review feedback is resolved. +- Squash-merge feature and fix pull requests into `dev` only after CI passes and + review feedback is resolved. +- Promote a verified `dev` branch to `main` with a merge commit. Do not squash + the `dev` to `main` release promotion. - Never merge or enable auto-merge without explicit maintainer approval. ## Compatibility and Architecture diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 27dbd9a..bac4009 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,3 +1,4 @@ + # Contributing Pythonlings is actively developed and **open to contributors** — beginners welcome. @@ -39,8 +40,21 @@ reference solution** (`tests/integration/test_solution_verify.py` enforces this) ## Pull Requests -- Use focused branches named `feature/` or `fix/`. +- Create focused branches from the current `dev` branch, named + `feature/` or `fix/`. +- Open pull requests against `dev`. Feature and fix pull requests are + squash-merged after CI passes and review feedback is resolved. - Reference the issue you're closing (`Closes #NN`). - Include a short description, test output (`python -m pytest -q`), and screenshots/GIFs for TUI changes. - Keep PRs scoped to one issue where possible. + +## Release Flow + +```text +feature/ or fix/ -> dev -> main -> vMAJOR.MINOR.PATCH +``` + +Maintainers promote a verified `dev` branch to `main` with a merge commit, not +a squash merge. The release tag is created from the exact promoted commit on +`main`; see [RELEASE.md](RELEASE.md) for the release checklist. From 64101f31d8af1fa219d01b9e09105637644c6312 Mon Sep 17 00:00:00 2001 From: agu2347 <94227848+agu2347@users.noreply.github.com> Date: Mon, 17 Aug 2026 00:53:43 +0530 Subject: [PATCH 2/5] fix: wrap malformed info.toml failures in ManifestError (#54) Return contextual CLI errors for unreadable, malformed, invalid, or unsafe manifests. Cover read failures, type validation, file validation, and symlink containment. --- pythonlings/core/manifest.py | 114 ++++++++++-- tests/integration/test_cli_verify.py | 77 +++++++++ tests/unit/test_manifest.py | 250 +++++++++++++++++++++++++++ 3 files changed, 426 insertions(+), 15 deletions(-) diff --git a/pythonlings/core/manifest.py b/pythonlings/core/manifest.py index fcf41e0..2727f6b 100644 --- a/pythonlings/core/manifest.py +++ b/pythonlings/core/manifest.py @@ -15,6 +15,14 @@ class ManifestError(ValueError): """info.toml is missing, malformed, or fails validation.""" +def _resolve_path(path: Path, description: str) -> Path: + """Resolve a manifest path and translate filesystem failures.""" + try: + return path.resolve() + except (OSError, RuntimeError) as e: + raise ManifestError(f"could not resolve {description} at {path}: {e}") from e + + @dataclass(frozen=True) class Manifest: exercises: list[Exercise] @@ -54,40 +62,116 @@ def load(root: Path) -> Manifest: "Run 'pythonlings init' to create one." ) - with info_path.open("rb") as f: - data = tomllib.load(f) + try: + with info_path.open("rb") as f: + data = tomllib.load(f) + except UnicodeDecodeError as e: + raise ManifestError(f"info.toml is not valid UTF-8: {e}") from e + except tomllib.TOMLDecodeError as e: + raise ManifestError(f"info.toml is not valid TOML: {e}") from e + except OSError as e: + raise ManifestError(f"could not read info.toml at {info_path}: {e}") from e if data.get("format_version") != 1: raise ManifestError( f"info.toml format_version must be 1, got {data.get('format_version')!r}" ) + welcome_message = data.get("welcome_message", "Welcome to pythonlings!") + final_message = data.get("final_message", "All exercises complete.") + for field, value in ( + ("welcome_message", welcome_message), + ("final_message", final_message), + ): + if not isinstance(value, str): + raise ManifestError(f"info.toml {field!r} must be a string") + raw_exercises = data.get("exercises", []) - if not raw_exercises: + if not isinstance(raw_exercises, list) or not raw_exercises: raise ManifestError("info.toml must define a non-empty [[exercises]] array") + resolved_root = _resolve_path(root, "workspace root") + exercises_root = _resolve_path(root / "exercises", "workspace exercises/ directory") + checks_root = _resolve_path(root / "checks", "workspace checks/ directory") + for directory, resolved_directory in ( + ("exercises", exercises_root), + ("checks", checks_root), + ): + if not resolved_directory.is_relative_to(resolved_root): + raise ManifestError( + f"workspace {directory}/ directory escapes the workspace root " + "via a symlink" + ) + seen: set[str] = set() exercises: list[Exercise] = [] for entry in raw_exercises: - name = entry["name"] + if not isinstance(entry, dict): + raise ManifestError( + f"info.toml [[exercises]] entries must be tables, got {entry!r}" + ) + + name = entry.get("name") + if not isinstance(name, str) or not name: + raise ManifestError( + f"info.toml exercise entry is missing a valid 'name': {entry!r}" + ) if name in seen: raise ManifestError(f"duplicate exercise name: {name!r}") seen.add(name) - rel_path = Path(entry["path"]) - if not rel_path.parts or rel_path.parts[0] != "exercises": + raw_path = entry.get("path") + if not isinstance(raw_path, str) or not raw_path: + raise ManifestError(f"exercise {name!r} is missing a valid 'path'") + + rel_path = Path(raw_path) + if ( + rel_path.is_absolute() + or ".." in rel_path.parts + or not rel_path.parts + or rel_path.parts[0] != "exercises" + ): raise ManifestError( - f"exercise path must be under exercises/: {rel_path}" + f"exercise {name!r} path must be a relative path under " + f"exercises/, with no '..' components: {raw_path!r}" ) abs_path = root / rel_path - if not abs_path.exists(): - raise ManifestError(f"exercise path does not exist: {rel_path}") + # The lexical checks above reject '..' segments and absolute + # paths in the *written* path string, but a symlink inside + # exercises/ can still resolve outside the workspace even + # when the written path looks clean (e.g. exercises/link/a.py + # where "link" is a symlink pointing elsewhere). Resolve and + # confirm containment before touching the filesystem further. + resolved_abs_path = _resolve_path(abs_path, f"exercise path for {name!r}") + if not resolved_abs_path.is_relative_to(exercises_root): + raise ManifestError( + f"exercise {name!r} path escapes the workspace exercises/ " + f"directory via a symlink: {raw_path!r}" + ) + if not abs_path.is_file(): + raise ManifestError(f"exercise path is not a file: {rel_path}") # Derive the check path: exercises/<...> mirrors to checks/<...>. check_rel = Path("checks", *rel_path.parts[1:]) check_abs = root / check_rel - if not check_abs.exists(): - raise ManifestError(f"no check file for {name!r}: {check_rel}") + resolved_check_abs = _resolve_path(check_abs, f"check path for {name!r}") + if not resolved_check_abs.is_relative_to(checks_root): + raise ManifestError( + f"check path for {name!r} escapes the workspace checks/ " + f"directory via a symlink: {check_rel}" + ) + if not check_abs.is_file(): + raise ManifestError( + f"no check file for {name!r}; path is not a file: {check_rel}" + ) + + hint = entry.get("hint", "") + docs = entry.get("docs", "") + for field, value in (("hint", hint), ("docs", docs)): + if not isinstance(value, str): + raise ManifestError( + f"exercise {name!r} field {field!r} must be a string" + ) exercises.append( Exercise( @@ -95,8 +179,8 @@ def load(root: Path) -> Manifest: path=abs_path, check_path=check_abs, topic=rel_path.parent.name, - hint=entry.get("hint", ""), - docs=entry.get("docs", ""), + hint=hint, + docs=docs, root=root, rel_path=rel_path, check_rel_path=check_rel, @@ -105,6 +189,6 @@ def load(root: Path) -> Manifest: return Manifest( exercises=exercises, - welcome_message=data.get("welcome_message", "Welcome to pythonlings!"), - final_message=data.get("final_message", "All exercises complete."), + welcome_message=welcome_message, + final_message=final_message, ) diff --git a/tests/integration/test_cli_verify.py b/tests/integration/test_cli_verify.py index 0e3fc07..af525ba 100644 --- a/tests/integration/test_cli_verify.py +++ b/tests/integration/test_cli_verify.py @@ -64,3 +64,80 @@ def test_verify_reports_manifest_error_with_exit_2(tmp_path: Path) -> None: result = _run("--root", str(tmp_path), "verify") assert result.returncode == 2 assert "info.toml" in result.stderr + + +def test_verify_malformed_toml_exits_2_without_traceback(tmp_path: Path) -> None: + (tmp_path / "info.toml").write_text("format_version = [1\n", encoding="utf-8") + result = _run("--root", str(tmp_path), "verify") + assert result.returncode == 2 + assert "info.toml" in result.stderr + assert "Traceback" not in result.stderr + assert result.stderr.startswith("pythonlings:") + + +def test_verify_invalid_utf8_exits_2_without_traceback(tmp_path: Path) -> None: + (tmp_path / "info.toml").write_bytes(b"\xff") + result = _run("--root", str(tmp_path), "verify") + assert result.returncode == 2 + assert "valid UTF-8" in result.stderr + assert "Traceback" not in result.stderr + + +def test_verify_info_toml_read_error_exits_2_without_traceback(tmp_path: Path) -> None: + (tmp_path / "info.toml").mkdir() + result = _run("--root", str(tmp_path), "verify") + assert result.returncode == 2 + assert "could not read info.toml" in result.stderr + assert "Traceback" not in result.stderr + + +def test_verify_traversal_path_exits_2_without_traceback(tmp_path: Path) -> None: + (tmp_path / "info.toml").write_text( + 'format_version = 1\n' + '[[exercises]]\n' + 'name = "a"\n' + 'path = "exercises/../../etc/passwd"\n' + 'hint = "h"\n', + encoding="utf-8", + ) + result = _run("--root", str(tmp_path), "verify") + assert result.returncode == 2 + assert "Traceback" not in result.stderr + assert result.stderr.startswith("pythonlings:") + + +def test_verify_directory_path_exits_2_without_traceback(tmp_path: Path) -> None: + (tmp_path / "exercises" / "topic").mkdir(parents=True) + (tmp_path / "checks" / "topic").mkdir(parents=True) + (tmp_path / "info.toml").write_text( + 'format_version = 1\n' + '[[exercises]]\n' + 'name = "a"\n' + 'path = "exercises/topic"\n', + encoding="utf-8", + ) + + result = _run("--root", str(tmp_path), "verify") + assert result.returncode == 2 + assert "exercise path is not a file" in result.stderr + assert "Traceback" not in result.stderr + + +def test_hint_non_string_field_exits_2_without_traceback(tmp_path: Path) -> None: + (tmp_path / "exercises").mkdir() + (tmp_path / "exercises" / "a.py").write_text("", encoding="utf-8") + (tmp_path / "checks").mkdir() + (tmp_path / "checks" / "a.py").write_text("", encoding="utf-8") + (tmp_path / "info.toml").write_text( + 'format_version = 1\n' + '[[exercises]]\n' + 'name = "a"\n' + 'path = "exercises/a.py"\n' + 'hint = 1\n', + encoding="utf-8", + ) + + result = _run("--root", str(tmp_path), "hint", "a") + assert result.returncode == 2 + assert "hint" in result.stderr + assert "Traceback" not in result.stderr diff --git a/tests/unit/test_manifest.py b/tests/unit/test_manifest.py index 43823bf..6837f3f 100644 --- a/tests/unit/test_manifest.py +++ b/tests/unit/test_manifest.py @@ -210,3 +210,253 @@ def test_real_curriculum_check_files_parse() -> None: exercise.check_path.read_text(encoding="utf-8"), filename=str(exercise.check_path), ) + + +def test_load_rejects_invalid_toml_syntax(tmp_path: Path) -> None: + (tmp_path / "info.toml").write_text("format_version = [1\n", encoding="utf-8") + with pytest.raises(ManifestError, match=r"info\.toml"): + load(tmp_path) + + +def test_load_rejects_invalid_utf8(tmp_path: Path) -> None: + (tmp_path / "info.toml").write_bytes(b"\xff") + with pytest.raises(ManifestError, match="valid UTF-8"): + load(tmp_path) + + +def test_load_wraps_info_toml_read_errors(tmp_path: Path) -> None: + (tmp_path / "info.toml").mkdir() + with pytest.raises(ManifestError, match="could not read info.toml"): + load(tmp_path) + + +def test_load_rejects_missing_name_field(tmp_path: Path) -> None: + (tmp_path / "info.toml").write_text( + "format_version = 1\n" + "[[exercises]]\n" + 'path = "exercises/a.py"\n' + 'hint = "h"\n', + encoding="utf-8", + ) + with pytest.raises(ManifestError, match="'name'"): + load(tmp_path) + + +def test_load_rejects_wrong_type_name_field(tmp_path: Path) -> None: + (tmp_path / "info.toml").write_text( + "format_version = 1\n" + "[[exercises]]\n" + "name = 123\n" + 'path = "exercises/a.py"\n' + 'hint = "h"\n', + encoding="utf-8", + ) + with pytest.raises(ManifestError, match="'name'"): + load(tmp_path) + + +def test_load_rejects_missing_path_field(tmp_path: Path) -> None: + (tmp_path / "info.toml").write_text( + "format_version = 1\n" + "[[exercises]]\n" + 'name = "a"\n' + 'hint = "h"\n', + encoding="utf-8", + ) + with pytest.raises(ManifestError, match="'path'"): + load(tmp_path) + + +def test_load_rejects_absolute_path(tmp_path: Path) -> None: + (tmp_path / "info.toml").write_text( + "format_version = 1\n" + "[[exercises]]\n" + 'name = "a"\n' + 'path = "/etc/passwd"\n' + 'hint = "h"\n', + encoding="utf-8", + ) + with pytest.raises(ManifestError, match="under exercises/"): + load(tmp_path) + + +def test_load_rejects_traversal_path(tmp_path: Path) -> None: + (tmp_path / "info.toml").write_text( + "format_version = 1\n" + "[[exercises]]\n" + 'name = "a"\n' + 'path = "exercises/../../etc/passwd"\n' + 'hint = "h"\n', + encoding="utf-8", + ) + with pytest.raises(ManifestError, match="under exercises/"): + load(tmp_path) + + +def test_load_rejects_symlink_escape(tmp_path: Path) -> None: + """A path that is lexically clean (no '..', not absolute, starts with + exercises/) can still resolve outside the workspace via a symlink, e.g. + exercises/link/secret.py where "link" is a symlink to somewhere else. + The lexical checks alone don't catch this -- containment must be + verified against the *resolved* path. + """ + outside = tmp_path.parent / "outside_workspace" + outside.mkdir(exist_ok=True) + (outside / "secret.py").write_text("SECRET = 1\n", encoding="utf-8") + + (tmp_path / "exercises").mkdir() + (tmp_path / "exercises" / "link").symlink_to(outside, target_is_directory=True) + (tmp_path / "checks").mkdir() + + (tmp_path / "info.toml").write_text( + "format_version = 1\n" + "[[exercises]]\n" + 'name = "a"\n' + 'path = "exercises/link/secret.py"\n' + 'hint = "h"\n', + encoding="utf-8", + ) + with pytest.raises(ManifestError, match="escapes the workspace"): + load(tmp_path) + + +def test_load_rejects_check_symlink_escape(tmp_path: Path) -> None: + outside = tmp_path.parent / f"{tmp_path.name}_outside_checks" + outside.mkdir() + (outside / "a.py").write_text("assert True\n", encoding="utf-8") + + (tmp_path / "exercises" / "topic").mkdir(parents=True) + (tmp_path / "exercises" / "topic" / "a.py").write_text("", encoding="utf-8") + (tmp_path / "checks").mkdir() + (tmp_path / "checks" / "topic").symlink_to(outside, target_is_directory=True) + (tmp_path / "info.toml").write_text( + "format_version = 1\n" + "[[exercises]]\n" + 'name = "a"\n' + 'path = "exercises/topic/a.py"\n' + 'hint = "h"\n', + encoding="utf-8", + ) + + with pytest.raises(ManifestError, match="check path.*escapes the workspace"): + load(tmp_path) + + +def test_load_rejects_top_level_exercises_symlink_escape(tmp_path: Path) -> None: + workspace = tmp_path / "workspace" + outside = tmp_path / "outside_exercises" + workspace.mkdir() + outside.mkdir() + (outside / "a.py").write_text("", encoding="utf-8") + (workspace / "exercises").symlink_to(outside, target_is_directory=True) + (workspace / "checks").mkdir() + (workspace / "checks" / "a.py").write_text("", encoding="utf-8") + (workspace / "info.toml").write_text( + "format_version = 1\n" + "[[exercises]]\n" + 'name = "a"\n' + 'path = "exercises/a.py"\n', + encoding="utf-8", + ) + + with pytest.raises(ManifestError, match="exercises/ directory escapes"): + load(workspace) + + +def test_load_rejects_top_level_checks_symlink_escape(tmp_path: Path) -> None: + workspace = tmp_path / "workspace" + outside = tmp_path / "outside_checks" + workspace.mkdir() + outside.mkdir() + (outside / "a.py").write_text("", encoding="utf-8") + (workspace / "checks").symlink_to(outside, target_is_directory=True) + (workspace / "exercises").mkdir() + (workspace / "exercises" / "a.py").write_text("", encoding="utf-8") + (workspace / "info.toml").write_text( + "format_version = 1\n" + "[[exercises]]\n" + 'name = "a"\n' + 'path = "exercises/a.py"\n', + encoding="utf-8", + ) + + with pytest.raises(ManifestError, match="checks/ directory escapes"): + load(workspace) + + +def test_load_rejects_directory_exercise_path(tmp_path: Path) -> None: + (tmp_path / "exercises" / "topic").mkdir(parents=True) + (tmp_path / "checks" / "topic").mkdir(parents=True) + (tmp_path / "info.toml").write_text( + "format_version = 1\n" + "[[exercises]]\n" + 'name = "a"\n' + 'path = "exercises/topic"\n', + encoding="utf-8", + ) + + with pytest.raises(ManifestError, match="exercise path is not a file"): + load(tmp_path) + + +def test_load_rejects_directory_check_path(tmp_path: Path) -> None: + (tmp_path / "exercises" / "topic").mkdir(parents=True) + (tmp_path / "exercises" / "topic" / "a.py").write_text("", encoding="utf-8") + (tmp_path / "checks" / "topic" / "a.py").mkdir(parents=True) + (tmp_path / "info.toml").write_text( + "format_version = 1\n" + "[[exercises]]\n" + 'name = "a"\n' + 'path = "exercises/topic/a.py"\n', + encoding="utf-8", + ) + + with pytest.raises(ManifestError, match="path is not a file"): + load(tmp_path) + + +def test_load_wraps_symlink_resolution_errors( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + exercise_path = tmp_path / "exercises" / "a.py" + _write_curriculum(tmp_path, "a") + original_resolve = Path.resolve + + def fail_exercise_resolution(path: Path, strict: bool = False) -> Path: + if path == exercise_path: + raise RuntimeError("symlink loop") + return original_resolve(path, strict=strict) + + monkeypatch.setattr(Path, "resolve", fail_exercise_resolution) + + with pytest.raises(ManifestError, match="could not resolve exercise path"): + load(tmp_path) + + +@pytest.mark.parametrize("field", ["hint", "docs"]) +def test_load_rejects_non_string_exercise_text(tmp_path: Path, field: str) -> None: + _write_curriculum(tmp_path, "a") + (tmp_path / "info.toml").write_text( + "format_version = 1\n" + "[[exercises]]\n" + 'name = "a"\n' + 'path = "exercises/a.py"\n' + f"{field} = 1\n", + encoding="utf-8", + ) + + with pytest.raises(ManifestError, match=field): + load(tmp_path) + + +@pytest.mark.parametrize("field", ["welcome_message", "final_message"]) +def test_load_rejects_non_string_manifest_text(tmp_path: Path, field: str) -> None: + _write_curriculum(tmp_path, "a") + info_path = tmp_path / "info.toml" + info_path.write_text( + f"{field} = 1\n" + info_path.read_text(encoding="utf-8"), + encoding="utf-8", + ) + + with pytest.raises(ManifestError, match=field): + load(tmp_path) From 4c3c01dc9d6d74ae92b358da408592d28f388c96 Mon Sep 17 00:00:00 2001 From: Zhang Yuyang Date: Mon, 17 Aug 2026 03:43:54 +0800 Subject: [PATCH 3/5] fix: prefer current workspace for update (#57) Honor explicit path and root precedence before selecting the current workspace or default home. Reject invalid update targets before any mutation and cover the precedence and failure boundaries. --- pythonlings/cli.py | 13 +- pythonlings/core/curriculum.py | 9 +- tests/integration/test_cli_workspace.py | 177 ++++++++++++++++++++++++ 3 files changed, 194 insertions(+), 5 deletions(-) diff --git a/pythonlings/cli.py b/pythonlings/cli.py index f12905c..e450224 100644 --- a/pythonlings/cli.py +++ b/pythonlings/cli.py @@ -35,7 +35,7 @@ def _build_parser() -> argparse.ArgumentParser: ) p_update = sub.add_parser("update", help="Update an existing pythonlings workspace.") - p_update.add_argument("--path", type=Path, default=default_workspace_root()) + p_update.add_argument("--path", type=Path, default=None) sub.add_parser("watch", help="Launch the TUI in watch mode (default).") sub.add_parser("topics", help="Launch the TUI on the topic picker.") @@ -278,8 +278,14 @@ def main(argv: list[str] | None = None) -> int: try: root: Path | None = None - if args.command in ("init", "update"): + update_root: Path | None = None + if args.command == "init": migrate_legacy_state_dir(Path(args.path)) + elif args.command == "update": + explicit_root = args.path if args.path is not None else args.root + update_root = resolve_workspace_root( + Path.cwd(), explicit_root, create_if_missing=False + ).root else: launches_tui = args.command in (None, "watch", "start", "topics") resolved = resolve_workspace_root( @@ -305,7 +311,8 @@ def main(argv: list[str] | None = None) -> int: if args.command == "init": return _cmd_init(args.path, args.force) if args.command == "update": - return _cmd_update(args.path) + assert update_root is not None + return _cmd_update(update_root) assert root is not None if args.command == "verify": diff --git a/pythonlings/core/curriculum.py b/pythonlings/core/curriculum.py index 43c0988..530f5d2 100644 --- a/pythonlings/core/curriculum.py +++ b/pythonlings/core/curriculum.py @@ -1,3 +1,4 @@ +# pythonlings/core/curriculum.py from __future__ import annotations import shutil @@ -109,10 +110,14 @@ def init_workspace(path: Path, *, force: bool = False) -> Path: def update_workspace(path: Path) -> Path: path = path.expanduser().resolve() - if not (path / "info.toml").exists(): + if not (path / "info.toml").is_file(): raise WorkspaceError(f"{path} is not a pythonlings workspace") - src_root = source_root() + src_root = source_root().resolve() + if path == src_root: + raise WorkspaceError(f"cannot update the curriculum source at {path}") + + migrate_legacy_state_dir(path) _copy_path(src_root / "info.toml", path / "info.toml", overwrite=True) _copy_path(src_root / "checks", path / "checks", overwrite=True) _copy_path(src_root / "solutions", path / "solutions", overwrite=True) diff --git a/tests/integration/test_cli_workspace.py b/tests/integration/test_cli_workspace.py index ab22769..4b57107 100644 --- a/tests/integration/test_cli_workspace.py +++ b/tests/integration/test_cli_workspace.py @@ -1,8 +1,28 @@ +# tests/integration/test_cli_workspace.py from pathlib import Path +from pythonlings.core import curriculum from pythonlings.cli import main +_STALE_CHECK = "# stale check used to identify the updated workspace\n" + + +def _stale_workspace(root: Path) -> Path: + assert main(["init", "--path", str(root)]) == 0 + check = next((root / "checks").rglob("*.py")) + check.write_text(_STALE_CHECK, encoding="utf-8") + return check + + +def _assert_updated(check: Path) -> None: + assert check.read_text(encoding="utf-8") != _STALE_CHECK + + +def _assert_not_updated(check: Path) -> None: + assert check.read_text(encoding="utf-8") == _STALE_CHECK + + def test_init_command_creates_workspace(tmp_path: Path) -> None: target = tmp_path / "learn-python" @@ -102,3 +122,160 @@ def test_update_command_preserves_user_exercises(tmp_path: Path) -> None: "__pycache__/\n" "*.pyc\n" ) + + +def test_update_path_takes_precedence_over_root_and_cwd( + tmp_path: Path, monkeypatch +) -> None: + cwd = tmp_path / "cwd-ws" + root = tmp_path / "root-ws" + path = tmp_path / "path-ws" + cwd_check = _stale_workspace(cwd) + root_check = _stale_workspace(root) + path_check = _stale_workspace(path) + monkeypatch.chdir(cwd) + + code = main( + [ + "--root", + str(root), + "update", + "--path", + str(path), + ] + ) + + assert code == 0 + _assert_updated(path_check) + _assert_not_updated(root_check) + _assert_not_updated(cwd_check) + + +def test_update_root_takes_precedence_over_cwd(tmp_path: Path, monkeypatch) -> None: + cwd = tmp_path / "cwd-ws" + root = tmp_path / "root-ws" + cwd_check = _stale_workspace(cwd) + root_check = _stale_workspace(root) + monkeypatch.chdir(cwd) + + code = main(["--root", str(root), "update"]) + + assert code == 0 + _assert_updated(root_check) + _assert_not_updated(cwd_check) + + +def test_bare_update_prefers_current_workspace(tmp_path: Path, monkeypatch) -> None: + home = tmp_path / "home-ws" + monkeypatch.setenv("PYTHONLINGS_HOME", str(home)) + home_check = _stale_workspace(home) + cwd = tmp_path / "cwd-ws" + cwd_check = _stale_workspace(cwd) + monkeypatch.chdir(cwd) + + code = main(["update"]) + + assert code == 0 + _assert_updated(cwd_check) + _assert_not_updated(home_check) + + +def test_bare_update_uses_home_workspace_outside_workspace( + tmp_path: Path, monkeypatch +) -> None: + home = tmp_path / "home-ws" + monkeypatch.setenv("PYTHONLINGS_HOME", str(home)) + home_check = _stale_workspace(home) + outside = tmp_path / "outside" + outside.mkdir() + monkeypatch.chdir(outside) + + code = main(["update"]) + + assert code == 0 + _assert_updated(home_check) + + +def test_update_missing_path_fails_without_creating_it( + tmp_path: Path, monkeypatch, capsys +) -> None: + target = tmp_path / "missing" + outside = tmp_path / "outside" + outside.mkdir() + monkeypatch.setenv("PYTHONLINGS_HOME", str(target)) + monkeypatch.chdir(outside) + + code = main(["update"]) + + assert code == 1 + assert "is not a pythonlings workspace" in capsys.readouterr().err + assert not target.exists() + + +def test_update_explicit_missing_path_fails_without_creating_it( + tmp_path: Path, capsys +) -> None: + target = tmp_path / "missing" + + code = main(["update", "--path", str(target)]) + + assert code == 1 + assert "is not a pythonlings workspace" in capsys.readouterr().err + assert not target.exists() + + +def test_update_non_workspace_fails_without_modifying_it( + tmp_path: Path, capsys +) -> None: + target = tmp_path / "not-a-workspace" + target.mkdir() + marker = target / "keep.txt" + marker.write_text("keep\n", encoding="utf-8") + legacy = target / ".pylings" + legacy.mkdir() + legacy_marker = legacy / "state.json" + legacy_marker.write_text("keep\n", encoding="utf-8") + + code = main(["--root", str(target), "update"]) + + assert code == 1 + assert "is not a pythonlings workspace" in capsys.readouterr().err + assert marker.read_text(encoding="utf-8") == "keep\n" + assert legacy_marker.read_text(encoding="utf-8") == "keep\n" + assert not (target / ".pythonlings").exists() + + +def test_update_rejects_directory_info_without_modifying_it( + tmp_path: Path, capsys +) -> None: + target = tmp_path / "not-a-workspace" + info = target / "info.toml" + info.mkdir(parents=True) + marker = info / "keep.txt" + marker.write_text("keep\n", encoding="utf-8") + + code = main(["update", "--path", str(target)]) + + assert code == 1 + assert "is not a pythonlings workspace" in capsys.readouterr().err + assert list(info.iterdir()) == [marker] + assert marker.read_text(encoding="utf-8") == "keep\n" + assert not (target / "checks").exists() + + +def test_bare_update_rejects_curriculum_source( + tmp_path: Path, monkeypatch, capsys +) -> None: + target = tmp_path / "source-workspace" + assert main(["init", "--path", str(target)]) == 0 + local_check = target / "checks" / "local-only.py" + local_check.write_text("keep\n", encoding="utf-8") + + monkeypatch.setattr(curriculum, "source_root", lambda: target) + monkeypatch.chdir(target) + + code = main(["update"]) + + assert code == 1 + assert "curriculum source" in capsys.readouterr().err + assert local_check.read_text(encoding="utf-8") == "keep\n" From b793375fede6c562b8217ab7d88e8033f262fd05 Mon Sep 17 00:00:00 2001 From: Abhik Sarkar Date: Mon, 17 Aug 2026 01:33:59 +0530 Subject: [PATCH 4/5] chore: prepare v0.4.2 release (#65) Set package metadata and dated notes for v0.4.2. Make public release status version-neutral and route active contributor work to the issue tracker. --- CHANGELOG.md | 18 ++++++++++++++++++ CONTRIBUTING.md | 7 +++---- RELEASE.md | 5 +++-- Readme.md | 9 +++++---- docs-site/faq.md | 3 ++- docs-site/index.md | 7 +++++-- docs-site/quick-start.md | 3 ++- docs-site/roadmap.md | 16 ++++++++-------- pyproject.toml | 3 ++- 9 files changed, 48 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d940b1..0b90244 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,26 @@ + # Changelog All notable changes to this project are documented here. Pythonlings follows Semantic Versioning. +## [0.4.2] - 2026-08-17 + +### Changed + +- Refreshed the project branding, screenshots, terminal demo, and contributor + guidance. + +### Fixed + +- `pythonlings update` now prefers the current workspace while preserving the + precedence of an explicit `--path` and global `--root`. Missing and invalid + targets fail without being created or modified. +- Invalid, unreadable, and unsafe manifests now produce contextual command-line + errors without Python tracebacks. +- Workspace initialization and updates preserve custom `.gitignore` entries, + ordering, line endings, and repeated-run idempotency. + ## [0.4.1] - 2026-06-21 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bac4009..c48b871 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,10 +6,9 @@ The fastest way in is a [`good first issue`](https://github.com/abhiksark/python ## Where the work is -- **[0.3.0 roadmap](docs/roadmap/0.3.0.md)** — the current focus (wider adoption - for beginners). Each roadmap issue is written to be picked up cold: it has - context, scope, the exact files to touch, and how to verify. -- Browse open issues by label: [`good first issue`](https://github.com/abhiksark/pythonlings/issues?q=is%3Aopen+label%3A%22good+first+issue%22), +- Track current work in the + [open issue tracker](https://github.com/abhiksark/pythonlings/issues?q=is%3Aissue+is%3Aopen). +- Find contributor-ready work by label: [`good first issue`](https://github.com/abhiksark/pythonlings/issues?q=is%3Aopen+label%3A%22good+first+issue%22), [`help wanted`](https://github.com/abhiksark/pythonlings/issues?q=is%3Aopen+label%3A%22help+wanted%22). ## Claiming an issue diff --git a/RELEASE.md b/RELEASE.md index 8ab7f36..2c58cca 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,3 +1,4 @@ + # Release Checklist Pythonlings follows Semantic Versioning. Use full `MAJOR.MINOR.PATCH` versions in @@ -27,10 +28,10 @@ pythonlings --root "$tmp" solution variables1 pythonlings --root "$tmp" reset variables1 --yes ``` -Expected release version for `v0.3.0`: +Confirm that the command reports the version represented by the release tag: ```text -pythonlings 0.3.0 +pythonlings MAJOR.MINOR.PATCH ``` ## Tag And Publish diff --git a/Readme.md b/Readme.md index 153b43d..0009058 100644 --- a/Readme.md +++ b/Readme.md @@ -31,7 +31,7 @@ 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.4.0`, alpha — published on PyPI as `pythonlings`. +Status: alpha. Published on PyPI as `pythonlings`. ![Coding screen](docs/assets/screenshots/coding-screen.png) @@ -193,9 +193,10 @@ together. Keep exercise and check filenames mirrored, for example ## Contributing -Pythonlings is actively developed and welcomes contributors — beginners included. -The current focus is the [0.3.0 roadmap](docs/roadmap/0.3.0.md) (wider adoption), -and every roadmap issue is written to be picked up cold. Start with a +Pythonlings is actively developed and welcomes contributors, including +beginners. Current work is tracked in the +[open issue tracker](https://github.com/abhiksark/pythonlings/issues?q=is%3Aissue+is%3Aopen). +Start with a [`good first issue`](https://github.com/abhiksark/pythonlings/issues?q=is%3Aopen+label%3A%22good+first+issue%22), comment to claim it, and see [CONTRIBUTING.md](CONTRIBUTING.md). diff --git a/docs-site/faq.md b/docs-site/faq.md index ef1aaba..0471f8c 100644 --- a/docs-site/faq.md +++ b/docs-site/faq.md @@ -1,3 +1,4 @@ + # FAQ ## How is this different from Rustlings? @@ -26,7 +27,7 @@ Yes. All exercises and the bundled local Python reference (press `F5` in the TUI ## Is Pythonlings on PyPI? -Yes — install it as [`pythonlings`](https://pypi.org/project/pythonlings/) (current release: `v0.4.0`). +Yes. Install it as [`pythonlings`](https://pypi.org/project/pythonlings/). ## How do I see the reference answer? diff --git a/docs-site/index.md b/docs-site/index.md index 28aa203..d724042 100644 --- a/docs-site/index.md +++ b/docs-site/index.md @@ -1,4 +1,5 @@ --- +# docs-site/index.md hide: - toc --- @@ -38,5 +39,7 @@ Prefer a permanent install? See [Quick Start](quick-start.md) for `pipx`, `uv to ## Project status -Pythonlings is `v0.4.0`, published on PyPI as `pythonlings`. The learner loop, CLI, and -curriculum are stable; see the [Roadmap](roadmap.md) for what's next. +Pythonlings is published on PyPI as `pythonlings`. The learner loop, CLI, and +curriculum are stable; see the [Roadmap](roadmap.md) and +[open issue tracker](https://github.com/abhiksark/pythonlings/issues?q=is%3Aissue+is%3Aopen) +for current work. diff --git a/docs-site/quick-start.md b/docs-site/quick-start.md index 2f77e98..ddcea0a 100644 --- a/docs-site/quick-start.md +++ b/docs-site/quick-start.md @@ -1,6 +1,7 @@ + # Quick Start -> Current release: **v0.4.0** · [PyPI](https://pypi.org/project/pythonlings/) +> Latest release: [pythonlings on PyPI](https://pypi.org/project/pythonlings/) ## Zero-Install (uvx) diff --git a/docs-site/roadmap.md b/docs-site/roadmap.md index 0ab7982..9881de7 100644 --- a/docs-site/roadmap.md +++ b/docs-site/roadmap.md @@ -1,7 +1,8 @@ + # Roadmap -Pythonlings is `v0.4.0`, published on PyPI as `pythonlings`. Install with -`uvx pythonlings` or `pip install pythonlings`. +Pythonlings is published on PyPI as `pythonlings`. Install with `uvx +pythonlings` or `pip install pythonlings`. ## Shipped @@ -11,13 +12,12 @@ Pythonlings is `v0.4.0`, published on PyPI as `pythonlings`. Install with - Bundled Python docs snippets with official docs links. - Published on PyPI as `pythonlings`; canonical install is `uvx pythonlings`. -## Next Work +## Active Work -- Improve first-run onboarding and empty-state copy. -- Harden keyboard flow around `Enter`, `Esc`, `F4`, and `F5`. -- Add more TUI tests for the coding screen, docs window, and topic picker. -- Add a release smoke test that installs the built wheel and exercises the CLI. -- Continue auditing exercises for clearer hints and stronger hidden checks. +Current priorities and ready-to-pick-up tasks are maintained in the +[open issue tracker](https://github.com/abhiksark/pythonlings/issues?q=is%3Aissue+is%3Aopen). +New contributors can start with the +[`good first issue` label](https://github.com/abhiksark/pythonlings/issues?q=is%3Aopen+label%3A%22good+first+issue%22). ## Release Policy diff --git a/pyproject.toml b/pyproject.toml index 3e5be32..fbbf25d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,11 @@ +# pyproject.toml [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [project] name = "pythonlings" -version = "0.4.1" +version = "0.4.2" description = "Python learnings, Rustlings-style, in a terminal TUI." readme = "Readme.md" requires-python = ">=3.9" From fc38c65e4147da13c1e9b70b0689cb7ecef370d0 Mon Sep 17 00:00:00 2001 From: Abhik Sarkar Date: Mon, 17 Aug 2026 01:47:30 +0530 Subject: [PATCH 5/5] Address release promotion review feedback (#66) Align the release tag format and promotion guidance, keep the docs file marker outside YAML front matter, and satisfy Ruff for manifest error patterns. --- docs-site/index.md | 2 +- docs/RELEASE_PROCESS.md | 9 +++++---- tests/unit/test_manifest.py | 4 ++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/docs-site/index.md b/docs-site/index.md index d724042..4dfe3df 100644 --- a/docs-site/index.md +++ b/docs-site/index.md @@ -1,8 +1,8 @@ --- -# docs-site/index.md hide: - toc --- +
Rustlings for Python
diff --git a/docs/RELEASE_PROCESS.md b/docs/RELEASE_PROCESS.md index 60beeb3..5ed94a2 100644 --- a/docs/RELEASE_PROCESS.md +++ b/docs/RELEASE_PROCESS.md @@ -1,3 +1,4 @@ + # Release Process Pythonlings uses a feature-branch workflow and Semantic Versioning. @@ -10,7 +11,7 @@ Pythonlings uses a feature-branch workflow and Semantic Versioning. ## Versioning -Use `MAJOR.MINOR` release tags. +Use `MAJOR.MINOR.PATCH` release tags. - Increment `MAJOR` for incompatible CLI, manifest, or curriculum changes. - Increment `MINOR` for new exercises, topics, TUI features, or docs workflows. @@ -21,7 +22,7 @@ Use `MAJOR.MINOR` release tags. 1. Merge feature branches into `dev` with reviewed, focused commits. 2. Run `python -m pytest -q`. 3. Run `pythonlings --root tests/fixtures/passing_curriculum verify`. -4. Update `CHANGELOG.md` and the version in `pythonlings/cli.py` and `pyproject.toml`. -5. Merge `dev` into `main`. -6. Create an annotated tag, for example `git tag -a v0.1 -m "Release v0.1"`. +4. Update `CHANGELOG.md` and the version in `pyproject.toml`. +5. Merge the verified `dev` branch into `main` with a merge commit. +6. Create an annotated tag, for example `git tag -a v0.4.2 -m "Release v0.4.2"`. 7. Push `main`, `dev`, and tags. diff --git a/tests/unit/test_manifest.py b/tests/unit/test_manifest.py index 6837f3f..6b142cc 100644 --- a/tests/unit/test_manifest.py +++ b/tests/unit/test_manifest.py @@ -226,7 +226,7 @@ def test_load_rejects_invalid_utf8(tmp_path: Path) -> None: def test_load_wraps_info_toml_read_errors(tmp_path: Path) -> None: (tmp_path / "info.toml").mkdir() - with pytest.raises(ManifestError, match="could not read info.toml"): + with pytest.raises(ManifestError, match=r"could not read info\.toml"): load(tmp_path) @@ -338,7 +338,7 @@ def test_load_rejects_check_symlink_escape(tmp_path: Path) -> None: encoding="utf-8", ) - with pytest.raises(ManifestError, match="check path.*escapes the workspace"): + with pytest.raises(ManifestError, match=r"check path.*escapes the workspace"): load(tmp_path)