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 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=Path.cwd(),
25
- help="Project root containing info.toml (default: cwd).",
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=Path.cwd())
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=Path.cwd())
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"initialized: {root}")
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
- 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:
272
297
  try:
273
- (args.root / ".pythonlings_debug.log").write_text(
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(args.root, args.topic)
312
+ return _cmd_verify(root, args.topic)
287
313
  if args.command == "list":
288
- return _cmd_list(args.root, args.topic)
314
+ return _cmd_list(root, args.topic)
289
315
  if args.command == "hint":
290
- return _cmd_hint(args.root, args.name)
316
+ return _cmd_hint(root, args.name)
291
317
  if args.command == "run":
292
- return _cmd_run(args.root, args.name)
318
+ return _cmd_run(root, args.name)
293
319
  if args.command == "dry-run":
294
- return _cmd_run(args.root, args.name)
320
+ return _cmd_run(root, args.name)
295
321
  if args.command in {"solution", "sol"}:
296
- return _cmd_solution(args.root, args.name)
322
+ return _cmd_solution(root, args.name)
297
323
  if args.command == "reset":
298
- return _cmd_reset(args.root, args.name, args.yes)
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(args.root), start_topic) is None:
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
- args.root,
334
+ root,
309
335
  start_topic,
310
336
  force_picker=args.command == "topics",
311
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.1
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.1`, 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.1`, 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=UtsMFibL5_ZwTRW6e9ZYT9hXv4yG9w3m_5CCSjCg7wU,10838
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.1.dist-info/METADATA,sha256=-4kMLeSYt69qsvsjOsRRg1KiCqDv1a_LOq4H_mPBVto,9542
939
- pythonlings-0.3.1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
940
- pythonlings-0.3.1.dist-info/entry_points.txt,sha256=x3HLcXJL_k5cqbwij4PL_Rr-q_BsBbm2ofJHMpeFbr8,53
941
- pythonlings-0.3.1.dist-info/licenses/LICENSE,sha256=FV2UAm_ETO065zJVIcDAWp2kWCShPts33fXrNASIMXs,1069
942
- pythonlings-0.3.1.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,,