pythonlings 0.3.0__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 +54 -24
- pythonlings/core/curriculum.py +4 -1
- pythonlings/core/manifest.py +1 -1
- pythonlings/core/workspace.py +57 -0
- {pythonlings-0.3.0.dist-info → pythonlings-0.4.0.dist-info}/METADATA +6 -6
- {pythonlings-0.3.0.dist-info → pythonlings-0.4.0.dist-info}/RECORD +9 -8
- {pythonlings-0.3.0.dist-info → pythonlings-0.4.0.dist-info}/WHEEL +0 -0
- {pythonlings-0.3.0.dist-info → pythonlings-0.4.0.dist-info}/entry_points.txt +0 -0
- {pythonlings-0.3.0.dist-info → pythonlings-0.4.0.dist-info}/licenses/LICENSE +0 -0
pythonlings/cli.py
CHANGED
|
@@ -3,9 +3,15 @@ from __future__ import annotations
|
|
|
3
3
|
|
|
4
4
|
import argparse
|
|
5
5
|
import sys
|
|
6
|
+
from importlib.metadata import PackageNotFoundError, version as _package_version
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
|
|
8
|
-
|
|
9
|
+
from pythonlings.core.workspace import default_workspace_root
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
__version__ = _package_version("pythonlings")
|
|
13
|
+
except PackageNotFoundError: # running from a source checkout without an install
|
|
14
|
+
__version__ = "0.0.0+unknown"
|
|
9
15
|
|
|
10
16
|
|
|
11
17
|
def _build_parser() -> argparse.ArgumentParser:
|
|
@@ -17,19 +23,19 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
17
23
|
parser.add_argument(
|
|
18
24
|
"--root",
|
|
19
25
|
type=Path,
|
|
20
|
-
default=
|
|
21
|
-
help="
|
|
26
|
+
default=None,
|
|
27
|
+
help="Workspace root containing info.toml (default: auto-resolve).",
|
|
22
28
|
)
|
|
23
29
|
sub = parser.add_subparsers(dest="command")
|
|
24
30
|
|
|
25
31
|
p_init = sub.add_parser("init", help="Create a pythonlings workspace.")
|
|
26
|
-
p_init.add_argument("--path", type=Path, default=
|
|
32
|
+
p_init.add_argument("--path", type=Path, default=default_workspace_root())
|
|
27
33
|
p_init.add_argument(
|
|
28
34
|
"--force", action="store_true", help="Overwrite managed workspace files."
|
|
29
35
|
)
|
|
30
36
|
|
|
31
37
|
p_update = sub.add_parser("update", help="Update an existing pythonlings workspace.")
|
|
32
|
-
p_update.add_argument("--path", type=Path, default=
|
|
38
|
+
p_update.add_argument("--path", type=Path, default=default_workspace_root())
|
|
33
39
|
|
|
34
40
|
sub.add_parser("watch", help="Launch the TUI in watch mode (default).")
|
|
35
41
|
sub.add_parser("topics", help="Launch the TUI on the topic picker.")
|
|
@@ -66,6 +72,14 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
66
72
|
return parser
|
|
67
73
|
|
|
68
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
|
+
|
|
69
83
|
def _resolve_topic(manifest, topic: str):
|
|
70
84
|
"""Return the topic name if valid, else write an error and return None."""
|
|
71
85
|
if topic in manifest.topics():
|
|
@@ -79,13 +93,18 @@ def _resolve_topic(manifest, topic: str):
|
|
|
79
93
|
|
|
80
94
|
def _cmd_init(path: Path, force: bool) -> int:
|
|
81
95
|
from pythonlings.core.curriculum import WorkspaceError, init_workspace
|
|
96
|
+
from pythonlings.core.workspace import is_workspace
|
|
82
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
|
|
83
102
|
try:
|
|
84
103
|
root = init_workspace(path, force=force)
|
|
85
104
|
except WorkspaceError as e:
|
|
86
105
|
sys.stderr.write(f"pythonlings: {e}\n")
|
|
87
106
|
return 1
|
|
88
|
-
print(f"
|
|
107
|
+
print(f"Created your workspace at {_display_path(root)}")
|
|
89
108
|
return 0
|
|
90
109
|
|
|
91
110
|
|
|
@@ -254,19 +273,29 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
254
273
|
parser = _build_parser()
|
|
255
274
|
args = parser.parse_args(argv if argv is not None else sys.argv[1:])
|
|
256
275
|
|
|
257
|
-
# Migrate any legacy .pylings/ state dir. init/update target --path; the
|
|
258
|
-
# in-workspace commands target --root. Both are no-ops without a legacy dir.
|
|
259
276
|
from pythonlings.core.curriculum import migrate_legacy_state_dir
|
|
260
|
-
|
|
261
|
-
for attr in ("path", "root"):
|
|
262
|
-
workspace = getattr(args, attr, None)
|
|
263
|
-
if workspace is not None:
|
|
264
|
-
migrate_legacy_state_dir(Path(workspace))
|
|
277
|
+
from pythonlings.core.workspace import resolve_workspace_root
|
|
265
278
|
|
|
266
279
|
try:
|
|
267
|
-
|
|
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:
|
|
268
297
|
try:
|
|
269
|
-
(
|
|
298
|
+
(root / ".pythonlings_debug.log").write_text(
|
|
270
299
|
f"argv={argv if argv is not None else sys.argv[1:]!r}\n",
|
|
271
300
|
encoding="utf-8",
|
|
272
301
|
)
|
|
@@ -278,30 +307,31 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
278
307
|
if args.command == "update":
|
|
279
308
|
return _cmd_update(args.path)
|
|
280
309
|
|
|
310
|
+
assert root is not None
|
|
281
311
|
if args.command == "verify":
|
|
282
|
-
return _cmd_verify(
|
|
312
|
+
return _cmd_verify(root, args.topic)
|
|
283
313
|
if args.command == "list":
|
|
284
|
-
return _cmd_list(
|
|
314
|
+
return _cmd_list(root, args.topic)
|
|
285
315
|
if args.command == "hint":
|
|
286
|
-
return _cmd_hint(
|
|
316
|
+
return _cmd_hint(root, args.name)
|
|
287
317
|
if args.command == "run":
|
|
288
|
-
return _cmd_run(
|
|
318
|
+
return _cmd_run(root, args.name)
|
|
289
319
|
if args.command == "dry-run":
|
|
290
|
-
return _cmd_run(
|
|
320
|
+
return _cmd_run(root, args.name)
|
|
291
321
|
if args.command in {"solution", "sol"}:
|
|
292
|
-
return _cmd_solution(
|
|
322
|
+
return _cmd_solution(root, args.name)
|
|
293
323
|
if args.command == "reset":
|
|
294
|
-
return _cmd_reset(
|
|
324
|
+
return _cmd_reset(root, args.name, args.yes)
|
|
295
325
|
|
|
296
326
|
if args.command in (None, "watch", "start", "topics"):
|
|
297
327
|
start_topic = getattr(args, "topic", None)
|
|
298
328
|
if start_topic is not None:
|
|
299
329
|
from pythonlings.core.manifest import load as load_manifest
|
|
300
|
-
if _resolve_topic(load_manifest(
|
|
330
|
+
if _resolve_topic(load_manifest(root), start_topic) is None:
|
|
301
331
|
return 2
|
|
302
332
|
from pythonlings.app import run_tui # lazy: Textual is heavy
|
|
303
333
|
return run_tui(
|
|
304
|
-
|
|
334
|
+
root,
|
|
305
335
|
start_topic,
|
|
306
336
|
force_picker=args.command == "topics",
|
|
307
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.0`, 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
|