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 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
- __version__ = "0.3.0"
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=Path.cwd(),
21
- help="Project root containing info.toml (default: cwd).",
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=Path.cwd())
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=Path.cwd())
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"initialized: {root}")
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
- if getattr(args, "debug", False):
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
- (args.root / ".pythonlings_debug.log").write_text(
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(args.root, args.topic)
312
+ return _cmd_verify(root, args.topic)
283
313
  if args.command == "list":
284
- return _cmd_list(args.root, args.topic)
314
+ return _cmd_list(root, args.topic)
285
315
  if args.command == "hint":
286
- return _cmd_hint(args.root, args.name)
316
+ return _cmd_hint(root, args.name)
287
317
  if args.command == "run":
288
- return _cmd_run(args.root, args.name)
318
+ return _cmd_run(root, args.name)
289
319
  if args.command == "dry-run":
290
- return _cmd_run(args.root, args.name)
320
+ return _cmd_run(root, args.name)
291
321
  if args.command in {"solution", "sol"}:
292
- return _cmd_solution(args.root, args.name)
322
+ return _cmd_solution(root, args.name)
293
323
  if args.command == "reset":
294
- return _cmd_reset(args.root, args.name, args.yes)
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(args.root), start_topic) is None:
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
- args.root,
334
+ root,
305
335
  start_topic,
306
336
  force_picker=args.command == "topics",
307
337
  )
@@ -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(f"{path} already exists and is not empty")
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()
@@ -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 here ({info_path} not found). "
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.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 init --path ./learn-python
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.3.0`, alpha — published on PyPI as `pythonlings`.
64
+ Status: `v0.4.0`, alpha — published on PyPI as `pythonlings`.
66
65
 
67
66
  ![Coding screen](docs/assets/screenshots/coding-screen.png)
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 init` creates a self-contained learner workspace with exercises,
88
- hidden checks, local docs, and original snapshots for reset.
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=MglnM8VZgVWl8XRCswbLijBbg6S23f_Frn-Z4h0gj34,10608
4
+ pythonlings/cli.py,sha256=BJ5zR3WCP0loUH5TlmOwJdQi3bL2r8cVUkRqmLGE7h4,11809
5
5
  pythonlings/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
- pythonlings/core/curriculum.py,sha256=VM4Z854vY-Udq511-7DHv1scPgvji0wTQnJkItmOMvQ,3449
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=HpFZ3pDcUxt62yZERGuIx9bs-xqaz6QKt3BCcnhi-xQ,3453
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.3.0.dist-info/METADATA,sha256=K7lJWJl4knMSDj4A4GhPLYMkqU4VWHHy_srQGhQuLus,9542
939
- pythonlings-0.3.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
940
- pythonlings-0.3.0.dist-info/entry_points.txt,sha256=x3HLcXJL_k5cqbwij4PL_Rr-q_BsBbm2ofJHMpeFbr8,53
941
- pythonlings-0.3.0.dist-info/licenses/LICENSE,sha256=FV2UAm_ETO065zJVIcDAWp2kWCShPts33fXrNASIMXs,1069
942
- pythonlings-0.3.0.dist-info/RECORD,,
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,,