pythonlings 0.3.1__py3-none-any.whl → 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pythonlings/cli.py +49 -23
- pythonlings/core/curriculum.py +4 -1
- pythonlings/core/manifest.py +1 -1
- pythonlings/core/workspace.py +57 -0
- {pythonlings-0.3.1.dist-info → pythonlings-0.4.0.dist-info}/METADATA +6 -6
- {pythonlings-0.3.1.dist-info → pythonlings-0.4.0.dist-info}/RECORD +9 -8
- {pythonlings-0.3.1.dist-info → pythonlings-0.4.0.dist-info}/WHEEL +0 -0
- {pythonlings-0.3.1.dist-info → pythonlings-0.4.0.dist-info}/entry_points.txt +0 -0
- {pythonlings-0.3.1.dist-info → pythonlings-0.4.0.dist-info}/licenses/LICENSE +0 -0
pythonlings/cli.py
CHANGED
|
@@ -6,6 +6,8 @@ import sys
|
|
|
6
6
|
from importlib.metadata import PackageNotFoundError, version as _package_version
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
|
|
9
|
+
from pythonlings.core.workspace import default_workspace_root
|
|
10
|
+
|
|
9
11
|
try:
|
|
10
12
|
__version__ = _package_version("pythonlings")
|
|
11
13
|
except PackageNotFoundError: # running from a source checkout without an install
|
|
@@ -21,19 +23,19 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
21
23
|
parser.add_argument(
|
|
22
24
|
"--root",
|
|
23
25
|
type=Path,
|
|
24
|
-
default=
|
|
25
|
-
help="
|
|
26
|
+
default=None,
|
|
27
|
+
help="Workspace root containing info.toml (default: auto-resolve).",
|
|
26
28
|
)
|
|
27
29
|
sub = parser.add_subparsers(dest="command")
|
|
28
30
|
|
|
29
31
|
p_init = sub.add_parser("init", help="Create a pythonlings workspace.")
|
|
30
|
-
p_init.add_argument("--path", type=Path, default=
|
|
32
|
+
p_init.add_argument("--path", type=Path, default=default_workspace_root())
|
|
31
33
|
p_init.add_argument(
|
|
32
34
|
"--force", action="store_true", help="Overwrite managed workspace files."
|
|
33
35
|
)
|
|
34
36
|
|
|
35
37
|
p_update = sub.add_parser("update", help="Update an existing pythonlings workspace.")
|
|
36
|
-
p_update.add_argument("--path", type=Path, default=
|
|
38
|
+
p_update.add_argument("--path", type=Path, default=default_workspace_root())
|
|
37
39
|
|
|
38
40
|
sub.add_parser("watch", help="Launch the TUI in watch mode (default).")
|
|
39
41
|
sub.add_parser("topics", help="Launch the TUI on the topic picker.")
|
|
@@ -70,6 +72,14 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
70
72
|
return parser
|
|
71
73
|
|
|
72
74
|
|
|
75
|
+
def _display_path(path: Path) -> str:
|
|
76
|
+
"""Render `path` with a leading `~/` when inside the home directory."""
|
|
77
|
+
try:
|
|
78
|
+
return "~/" + str(path.relative_to(Path.home()))
|
|
79
|
+
except ValueError:
|
|
80
|
+
return str(path)
|
|
81
|
+
|
|
82
|
+
|
|
73
83
|
def _resolve_topic(manifest, topic: str):
|
|
74
84
|
"""Return the topic name if valid, else write an error and return None."""
|
|
75
85
|
if topic in manifest.topics():
|
|
@@ -83,13 +93,18 @@ def _resolve_topic(manifest, topic: str):
|
|
|
83
93
|
|
|
84
94
|
def _cmd_init(path: Path, force: bool) -> int:
|
|
85
95
|
from pythonlings.core.curriculum import WorkspaceError, init_workspace
|
|
96
|
+
from pythonlings.core.workspace import is_workspace
|
|
86
97
|
|
|
98
|
+
path = path.expanduser().resolve()
|
|
99
|
+
if is_workspace(path) and not force:
|
|
100
|
+
print(f"Already set up at {path} — just run `pythonlings`")
|
|
101
|
+
return 0
|
|
87
102
|
try:
|
|
88
103
|
root = init_workspace(path, force=force)
|
|
89
104
|
except WorkspaceError as e:
|
|
90
105
|
sys.stderr.write(f"pythonlings: {e}\n")
|
|
91
106
|
return 1
|
|
92
|
-
print(f"
|
|
107
|
+
print(f"Created your workspace at {_display_path(root)}")
|
|
93
108
|
return 0
|
|
94
109
|
|
|
95
110
|
|
|
@@ -258,19 +273,29 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
258
273
|
parser = _build_parser()
|
|
259
274
|
args = parser.parse_args(argv if argv is not None else sys.argv[1:])
|
|
260
275
|
|
|
261
|
-
# Migrate any legacy .pylings/ state dir. init/update target --path; the
|
|
262
|
-
# in-workspace commands target --root. Both are no-ops without a legacy dir.
|
|
263
276
|
from pythonlings.core.curriculum import migrate_legacy_state_dir
|
|
264
|
-
|
|
265
|
-
for attr in ("path", "root"):
|
|
266
|
-
workspace = getattr(args, attr, None)
|
|
267
|
-
if workspace is not None:
|
|
268
|
-
migrate_legacy_state_dir(Path(workspace))
|
|
277
|
+
from pythonlings.core.workspace import resolve_workspace_root
|
|
269
278
|
|
|
270
279
|
try:
|
|
271
|
-
|
|
280
|
+
root: Path | None = None
|
|
281
|
+
if args.command in ("init", "update"):
|
|
282
|
+
migrate_legacy_state_dir(Path(args.path))
|
|
283
|
+
else:
|
|
284
|
+
launches_tui = args.command in (None, "watch", "start", "topics")
|
|
285
|
+
resolved = resolve_workspace_root(
|
|
286
|
+
Path.cwd(), args.root, create_if_missing=launches_tui
|
|
287
|
+
)
|
|
288
|
+
root = resolved.root
|
|
289
|
+
migrate_legacy_state_dir(root)
|
|
290
|
+
if resolved.created:
|
|
291
|
+
print(
|
|
292
|
+
f"Created your workspace at {_display_path(root)} "
|
|
293
|
+
"(edit in-app, or open that folder in your editor)"
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
if getattr(args, "debug", False) and root is not None:
|
|
272
297
|
try:
|
|
273
|
-
(
|
|
298
|
+
(root / ".pythonlings_debug.log").write_text(
|
|
274
299
|
f"argv={argv if argv is not None else sys.argv[1:]!r}\n",
|
|
275
300
|
encoding="utf-8",
|
|
276
301
|
)
|
|
@@ -282,30 +307,31 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
282
307
|
if args.command == "update":
|
|
283
308
|
return _cmd_update(args.path)
|
|
284
309
|
|
|
310
|
+
assert root is not None
|
|
285
311
|
if args.command == "verify":
|
|
286
|
-
return _cmd_verify(
|
|
312
|
+
return _cmd_verify(root, args.topic)
|
|
287
313
|
if args.command == "list":
|
|
288
|
-
return _cmd_list(
|
|
314
|
+
return _cmd_list(root, args.topic)
|
|
289
315
|
if args.command == "hint":
|
|
290
|
-
return _cmd_hint(
|
|
316
|
+
return _cmd_hint(root, args.name)
|
|
291
317
|
if args.command == "run":
|
|
292
|
-
return _cmd_run(
|
|
318
|
+
return _cmd_run(root, args.name)
|
|
293
319
|
if args.command == "dry-run":
|
|
294
|
-
return _cmd_run(
|
|
320
|
+
return _cmd_run(root, args.name)
|
|
295
321
|
if args.command in {"solution", "sol"}:
|
|
296
|
-
return _cmd_solution(
|
|
322
|
+
return _cmd_solution(root, args.name)
|
|
297
323
|
if args.command == "reset":
|
|
298
|
-
return _cmd_reset(
|
|
324
|
+
return _cmd_reset(root, args.name, args.yes)
|
|
299
325
|
|
|
300
326
|
if args.command in (None, "watch", "start", "topics"):
|
|
301
327
|
start_topic = getattr(args, "topic", None)
|
|
302
328
|
if start_topic is not None:
|
|
303
329
|
from pythonlings.core.manifest import load as load_manifest
|
|
304
|
-
if _resolve_topic(load_manifest(
|
|
330
|
+
if _resolve_topic(load_manifest(root), start_topic) is None:
|
|
305
331
|
return 2
|
|
306
332
|
from pythonlings.app import run_tui # lazy: Textual is heavy
|
|
307
333
|
return run_tui(
|
|
308
|
-
|
|
334
|
+
root,
|
|
309
335
|
start_topic,
|
|
310
336
|
force_picker=args.command == "topics",
|
|
311
337
|
)
|
pythonlings/core/curriculum.py
CHANGED
|
@@ -79,7 +79,10 @@ def _sync_originals(root: Path, src_root: Path) -> None:
|
|
|
79
79
|
def init_workspace(path: Path, *, force: bool = False) -> Path:
|
|
80
80
|
path = path.expanduser().resolve()
|
|
81
81
|
if path.exists() and any(path.iterdir()) and not force:
|
|
82
|
-
raise WorkspaceError(
|
|
82
|
+
raise WorkspaceError(
|
|
83
|
+
f"{path} isn't empty and isn't a pythonlings workspace. "
|
|
84
|
+
"Pick another location with --path <dir>, or --force to set up here anyway."
|
|
85
|
+
)
|
|
83
86
|
path.mkdir(parents=True, exist_ok=True)
|
|
84
87
|
|
|
85
88
|
src_root = source_root()
|
pythonlings/core/manifest.py
CHANGED
|
@@ -50,7 +50,7 @@ def load(root: Path) -> Manifest:
|
|
|
50
50
|
info_path = root / "info.toml"
|
|
51
51
|
if not info_path.exists():
|
|
52
52
|
raise ManifestError(
|
|
53
|
-
f"no pythonlings workspace
|
|
53
|
+
f"no pythonlings workspace at {root} (info.toml not found). "
|
|
54
54
|
"Run 'pythonlings init' to create one."
|
|
55
55
|
)
|
|
56
56
|
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from pythonlings.core.curriculum import init_workspace
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class ResolvedWorkspace:
|
|
12
|
+
root: Path
|
|
13
|
+
created: bool
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def default_workspace_root() -> Path:
|
|
17
|
+
"""Where a no-argument `pythonlings` keeps its workspace.
|
|
18
|
+
|
|
19
|
+
`PYTHONLINGS_HOME` overrides; otherwise a hidden `~/.pythonlings`.
|
|
20
|
+
"""
|
|
21
|
+
env = os.environ.get("PYTHONLINGS_HOME")
|
|
22
|
+
if env:
|
|
23
|
+
return Path(env).expanduser().resolve()
|
|
24
|
+
return (Path.home() / ".pythonlings").resolve()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def is_workspace(path: Path) -> bool:
|
|
28
|
+
"""True if `path` is a pythonlings workspace (has an `info.toml`)."""
|
|
29
|
+
return (path / "info.toml").is_file()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def resolve_workspace_root(
|
|
33
|
+
cwd: Path,
|
|
34
|
+
explicit_root: Path | None = None,
|
|
35
|
+
*,
|
|
36
|
+
create_if_missing: bool = False,
|
|
37
|
+
) -> ResolvedWorkspace:
|
|
38
|
+
"""Pick the workspace root for a command.
|
|
39
|
+
|
|
40
|
+
Order: an explicit `--root`, then the current directory if it is a
|
|
41
|
+
workspace, then the default home workspace, then (only when
|
|
42
|
+
`create_if_missing`) a freshly created home workspace.
|
|
43
|
+
"""
|
|
44
|
+
if explicit_root is not None:
|
|
45
|
+
return ResolvedWorkspace(explicit_root.expanduser().resolve(), created=False)
|
|
46
|
+
|
|
47
|
+
cwd = cwd.resolve()
|
|
48
|
+
if is_workspace(cwd):
|
|
49
|
+
return ResolvedWorkspace(cwd, created=False)
|
|
50
|
+
|
|
51
|
+
home = default_workspace_root()
|
|
52
|
+
if is_workspace(home):
|
|
53
|
+
return ResolvedWorkspace(home, created=False)
|
|
54
|
+
|
|
55
|
+
if create_if_missing:
|
|
56
|
+
return ResolvedWorkspace(init_workspace(home), created=True)
|
|
57
|
+
return ResolvedWorkspace(home, created=False)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pythonlings
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Python learnings, Rustlings-style, in a terminal TUI.
|
|
5
5
|
Project-URL: Homepage, https://github.com/abhiksark/pythonlings
|
|
6
6
|
Project-URL: Repository, https://github.com/abhiksark/pythonlings
|
|
@@ -55,14 +55,13 @@ Python documentation snippets so learners can work without leaving the terminal.
|
|
|
55
55
|
and [uv](https://docs.astral.sh/uv/):
|
|
56
56
|
|
|
57
57
|
```bash
|
|
58
|
-
uvx pythonlings
|
|
59
|
-
cd learn-python && uvx pythonlings
|
|
58
|
+
uvx pythonlings
|
|
60
59
|
```
|
|
61
60
|
|
|
62
61
|
How it works: **edit** the broken exercise in the built-in editor → checks
|
|
63
62
|
rerun as you type and advance you to the next one. That's the whole loop.
|
|
64
63
|
|
|
65
|
-
Status: `v0.
|
|
64
|
+
Status: `v0.4.0`, alpha — published on PyPI as `pythonlings`.
|
|
66
65
|
|
|
67
66
|

|
|
68
67
|
|
|
@@ -84,8 +83,9 @@ Status: `v0.3.1`, alpha — published on PyPI as `pythonlings`.
|
|
|
84
83
|
|
|
85
84
|
## What Happens When You Run It
|
|
86
85
|
|
|
87
|
-
1. `pythonlings
|
|
88
|
-
|
|
86
|
+
1. `pythonlings` with no workspace creates a self-contained one at
|
|
87
|
+
`~/.pythonlings` (override with `PYTHONLINGS_HOME` or `pythonlings init
|
|
88
|
+
--path <dir>`) and opens the first exercise.
|
|
89
89
|
2. `pythonlings` opens the first pending exercise in the terminal UI.
|
|
90
90
|
3. You edit the broken code, remove `# I AM NOT DONE`, and checks rerun as you
|
|
91
91
|
work.
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
pythonlings/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
2
|
pythonlings/__main__.py,sha256=uSvclvHiv4C9zXiw2cdtV_56ba4i6VeKB0wyCd06k-s,90
|
|
3
3
|
pythonlings/app.py,sha256=aaj3CtSP08_fN8d7OZxHccC8A1TQOcqZkE5BGncQmmg,2605
|
|
4
|
-
pythonlings/cli.py,sha256=
|
|
4
|
+
pythonlings/cli.py,sha256=BJ5zR3WCP0loUH5TlmOwJdQi3bL2r8cVUkRqmLGE7h4,11809
|
|
5
5
|
pythonlings/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
-
pythonlings/core/curriculum.py,sha256=
|
|
6
|
+
pythonlings/core/curriculum.py,sha256=cPlHYHXu95X24YjB_aNMC7H2EkYSlYXKYF7JFFpKBU8,3576
|
|
7
7
|
pythonlings/core/docs.py,sha256=caRAYtu-bYd7qetL9ZfgBOVcFRR0SyI0hfXPP5_VDtQ,1892
|
|
8
8
|
pythonlings/core/exercise.py,sha256=V_LVXXcdS7JLplGGCdZv54rYxAhxCTiG8v7wcViFI2o,652
|
|
9
|
-
pythonlings/core/manifest.py,sha256=
|
|
9
|
+
pythonlings/core/manifest.py,sha256=gg0LiSYgJy7mTGCnKVY-8es2KUC0y9F6MCx9hWObyBo,3456
|
|
10
10
|
pythonlings/core/reset.py,sha256=tqrwbjU-1V0q4fHAny0NrwK_IVAU4rHhziWtaaS5X1k,1414
|
|
11
11
|
pythonlings/core/runner.py,sha256=h7JX7FGIzGa5cuggEOJN4nov2u7BDiuURpEfV-si0oM,3745
|
|
12
12
|
pythonlings/core/solutions.py,sha256=06srf3SNjTnBn4u0QGk55-Tjjt9SfnxsUigD7JnD4rE,507
|
|
13
13
|
pythonlings/core/state.py,sha256=0CGLIBwaZH86QjmacjX60jCkjrY8bYNOsTUpMHvsOmE,2532
|
|
14
|
+
pythonlings/core/workspace.py,sha256=A29P7dUSQezfzSOxAsnvCsS8c0gWrabz_BrRf9lge4U,1629
|
|
14
15
|
pythonlings/docs/NOTICE.md,sha256=t9FfUSDE2wT5amf3PYI1Vs8CLYUVtzPq0PdArvmtss4,450
|
|
15
16
|
pythonlings/docs/__init__.py,sha256=Oj4hwHUM_Ssj-1vs8U52o491Ogy2mDbas7Y8q_dw3UY,51
|
|
16
17
|
pythonlings/docs/index.json,sha256=BlKHcCw9N9oNP0qfVi230QtTzZ_jUc_MA17b0ITpB8k,5422
|
|
@@ -935,8 +936,8 @@ pythonlings/curriculum/solutions/variables6.py,sha256=7UIUOJNTTWft93V4pDFNZlriym
|
|
|
935
936
|
pythonlings/curriculum/solutions/variables7.py,sha256=7UIUOJNTTWft93V4pDFNZlriymc-fjEhdWotp8wxIWo,64
|
|
936
937
|
pythonlings/curriculum/solutions/variables8.py,sha256=7UIUOJNTTWft93V4pDFNZlriymc-fjEhdWotp8wxIWo,64
|
|
937
938
|
pythonlings/curriculum/solutions/variables9.py,sha256=7UIUOJNTTWft93V4pDFNZlriymc-fjEhdWotp8wxIWo,64
|
|
938
|
-
pythonlings-0.
|
|
939
|
-
pythonlings-0.
|
|
940
|
-
pythonlings-0.
|
|
941
|
-
pythonlings-0.
|
|
942
|
-
pythonlings-0.
|
|
939
|
+
pythonlings-0.4.0.dist-info/METADATA,sha256=Sfph4XHU6h336Y2lCOelPaFMlsrWVk1NFjAbzkknhaY,9525
|
|
940
|
+
pythonlings-0.4.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
941
|
+
pythonlings-0.4.0.dist-info/entry_points.txt,sha256=x3HLcXJL_k5cqbwij4PL_Rr-q_BsBbm2ofJHMpeFbr8,53
|
|
942
|
+
pythonlings-0.4.0.dist-info/licenses/LICENSE,sha256=FV2UAm_ETO065zJVIcDAWp2kWCShPts33fXrNASIMXs,1069
|
|
943
|
+
pythonlings-0.4.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|