codepraxis 0.2.1__tar.gz → 0.3.0__tar.gz

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.
Files changed (70) hide show
  1. {codepraxis-0.2.1 → codepraxis-0.3.0}/PKG-INFO +41 -2
  2. {codepraxis-0.2.1 → codepraxis-0.3.0}/README.md +40 -1
  3. {codepraxis-0.2.1 → codepraxis-0.3.0}/src/codepraxis/__init__.py +1 -1
  4. {codepraxis-0.2.1 → codepraxis-0.3.0}/src/codepraxis/cli.py +70 -0
  5. codepraxis-0.3.0/src/codepraxis/commands/example.py +73 -0
  6. codepraxis-0.3.0/src/codepraxis/commands/lint.py +43 -0
  7. {codepraxis-0.2.1 → codepraxis-0.3.0}/src/codepraxis/commands/validate.py +21 -1
  8. {codepraxis-0.2.1 → codepraxis-0.3.0}/src/codepraxis/execution/remote/client.py +4 -1
  9. {codepraxis-0.2.1 → codepraxis-0.3.0}/src/codepraxis/execution/remote/config.py +11 -0
  10. {codepraxis-0.2.1 → codepraxis-0.3.0}/src/codepraxis/reporting/human.py +10 -0
  11. codepraxis-0.3.0/src/codepraxis/scaffold/__init__.py +0 -0
  12. codepraxis-0.3.0/src/codepraxis/scaffold/generator.py +139 -0
  13. codepraxis-0.3.0/src/codepraxis/scaffold/templates/README.md +7 -0
  14. codepraxis-0.3.0/src/codepraxis/scaffold/templates/backend.conf +4 -0
  15. codepraxis-0.3.0/src/codepraxis/scaffold/templates/course_toc.json +8 -0
  16. codepraxis-0.3.0/src/codepraxis/scaffold/templates/feature.md +21 -0
  17. codepraxis-0.3.0/src/codepraxis/scaffold/templates/main.py +12 -0
  18. codepraxis-0.3.0/src/codepraxis/scaffold/templates/metadata.json +3 -0
  19. codepraxis-0.3.0/src/codepraxis/scaffold/templates/solution.py +12 -0
  20. codepraxis-0.3.0/src/codepraxis/scaffold/templates/test_1.py +74 -0
  21. codepraxis-0.3.0/src/codepraxis/validation/__init__.py +0 -0
  22. codepraxis-0.3.0/src/codepraxis/validation/registry.py +63 -0
  23. codepraxis-0.3.0/src/codepraxis/validation/rule.py +36 -0
  24. codepraxis-0.3.0/src/codepraxis/validation/rules/__init__.py +0 -0
  25. codepraxis-0.3.0/src/codepraxis/validation/rules/hygiene.py +98 -0
  26. codepraxis-0.3.0/src/codepraxis/validation/rules/instructions.py +66 -0
  27. codepraxis-0.3.0/src/codepraxis/validation/rules/testcases.py +171 -0
  28. {codepraxis-0.2.1 → codepraxis-0.3.0}/tests/test_classification.py +31 -0
  29. codepraxis-0.3.0/tests/test_validation_rules.py +221 -0
  30. {codepraxis-0.2.1 → codepraxis-0.3.0}/.gitignore +0 -0
  31. {codepraxis-0.2.1 → codepraxis-0.3.0}/CONTRIBUTING.md +0 -0
  32. {codepraxis-0.2.1 → codepraxis-0.3.0}/RELEASING.md +0 -0
  33. {codepraxis-0.2.1 → codepraxis-0.3.0}/pyproject.toml +0 -0
  34. {codepraxis-0.2.1 → codepraxis-0.3.0}/src/codepraxis/__main__.py +0 -0
  35. {codepraxis-0.2.1 → codepraxis-0.3.0}/src/codepraxis/commands/__init__.py +0 -0
  36. {codepraxis-0.2.1 → codepraxis-0.3.0}/src/codepraxis/commands/login.py +0 -0
  37. {codepraxis-0.2.1 → codepraxis-0.3.0}/src/codepraxis/commands/publish.py +0 -0
  38. {codepraxis-0.2.1 → codepraxis-0.3.0}/src/codepraxis/domain/__init__.py +0 -0
  39. {codepraxis-0.2.1 → codepraxis-0.3.0}/src/codepraxis/domain/contract.py +0 -0
  40. {codepraxis-0.2.1 → codepraxis-0.3.0}/src/codepraxis/domain/pack.py +0 -0
  41. {codepraxis-0.2.1 → codepraxis-0.3.0}/src/codepraxis/domain/results.py +0 -0
  42. {codepraxis-0.2.1 → codepraxis-0.3.0}/src/codepraxis/errors.py +0 -0
  43. {codepraxis-0.2.1 → codepraxis-0.3.0}/src/codepraxis/execution/__init__.py +0 -0
  44. {codepraxis-0.2.1 → codepraxis-0.3.0}/src/codepraxis/execution/executor.py +0 -0
  45. {codepraxis-0.2.1 → codepraxis-0.3.0}/src/codepraxis/execution/local/__init__.py +0 -0
  46. {codepraxis-0.2.1 → codepraxis-0.3.0}/src/codepraxis/execution/local/backends.py +0 -0
  47. {codepraxis-0.2.1 → codepraxis-0.3.0}/src/codepraxis/execution/local/executor.py +0 -0
  48. {codepraxis-0.2.1 → codepraxis-0.3.0}/src/codepraxis/execution/local/worker.py +0 -0
  49. {codepraxis-0.2.1 → codepraxis-0.3.0}/src/codepraxis/execution/local/workspace.py +0 -0
  50. {codepraxis-0.2.1 → codepraxis-0.3.0}/src/codepraxis/execution/remote/__init__.py +0 -0
  51. {codepraxis-0.2.1 → codepraxis-0.3.0}/src/codepraxis/execution/remote/executor.py +0 -0
  52. {codepraxis-0.2.1 → codepraxis-0.3.0}/src/codepraxis/packio/__init__.py +0 -0
  53. {codepraxis-0.2.1 → codepraxis-0.3.0}/src/codepraxis/packio/archive.py +0 -0
  54. {codepraxis-0.2.1 → codepraxis-0.3.0}/src/codepraxis/packio/discovery.py +0 -0
  55. {codepraxis-0.2.1 → codepraxis-0.3.0}/src/codepraxis/packio/loader.py +0 -0
  56. {codepraxis-0.2.1 → codepraxis-0.3.0}/src/codepraxis/packio/toc.py +0 -0
  57. {codepraxis-0.2.1 → codepraxis-0.3.0}/src/codepraxis/plugin/__init__.py +0 -0
  58. {codepraxis-0.2.1 → codepraxis-0.3.0}/src/codepraxis/plugin/installer.py +0 -0
  59. {codepraxis-0.2.1 → codepraxis-0.3.0}/src/codepraxis/plugin/templates/commands/new.md +0 -0
  60. {codepraxis-0.2.1 → codepraxis-0.3.0}/src/codepraxis/plugin/templates/commands/validate.md +0 -0
  61. {codepraxis-0.2.1 → codepraxis-0.3.0}/src/codepraxis/plugin/templates/marketplace.json +0 -0
  62. {codepraxis-0.2.1 → codepraxis-0.3.0}/src/codepraxis/plugin/templates/plugin.json +0 -0
  63. {codepraxis-0.2.1 → codepraxis-0.3.0}/src/codepraxis/plugin/templates/skills/pack-authoring/SKILL.md +0 -0
  64. {codepraxis-0.2.1 → codepraxis-0.3.0}/src/codepraxis/reporting/__init__.py +0 -0
  65. {codepraxis-0.2.1 → codepraxis-0.3.0}/src/codepraxis/reporting/json_reporter.py +0 -0
  66. {codepraxis-0.2.1 → codepraxis-0.3.0}/src/codepraxis/reporting/reporter.py +0 -0
  67. {codepraxis-0.2.1 → codepraxis-0.3.0}/tests/conformance/test_corpus.py +0 -0
  68. {codepraxis-0.2.1 → codepraxis-0.3.0}/tests/conftest.py +0 -0
  69. {codepraxis-0.2.1 → codepraxis-0.3.0}/tests/test_publish.py +0 -0
  70. {codepraxis-0.2.1 → codepraxis-0.3.0}/tests/test_toc.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codepraxis
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: Author and validate CodePraxis challenge packs.
5
5
  Project-URL: Documentation, https://docs.codepraxis.com/authoring
6
6
  Author: CodePraxis
@@ -29,8 +29,11 @@ Author and validate CodePraxis challenge packs from your own repository.
29
29
 
30
30
  ```bash
31
31
  pip install codepraxis
32
+ codepraxis --example # see a real challenge, no account needed
33
+ codepraxis new my-challenge # scaffold one that already validates
34
+ codepraxis lint my-challenge # static checks, no execution
35
+ codepraxis validate --local my-challenge # run it, fast and advisory
32
36
  codepraxis --login
33
- codepraxis validate --local my-challenge # fast, advisory
34
37
  codepraxis --publish my-challenge # validates in the runner, then publishes
35
38
  ```
36
39
 
@@ -68,6 +71,42 @@ codepraxis validate --local my-challenge
68
71
  Once a key is configured the leniency stops: a model failure is then a real
69
72
  failure, because it can be judged.
70
73
 
74
+ ## Try it first
75
+
76
+ ```bash
77
+ codepraxis --example
78
+ ```
79
+
80
+ Starts a throwaway container with the featured challenge already cloned into it
81
+ and prints a URL. That is exactly what a candidate sees. No login, no account,
82
+ nothing recorded as an attempt. Add `--open` to launch it in your browser.
83
+
84
+ ## Starting a new pack
85
+
86
+ ```bash
87
+ codepraxis new my-challenge
88
+ codepraxis validate --local my-challenge # → PASSED
89
+ ```
90
+
91
+ The generated pack is complete and already passing — solution passes, starter
92
+ fails. Edit `._course_data/feature.md`, `source/`, `._tests/test_1.py` and
93
+ `../solution/` to make it yours.
94
+
95
+ ## Two kinds of checking
96
+
97
+ | Command | Runs | Cost |
98
+ |---|---|---|
99
+ | `codepraxis lint` | Static rules over the files | milliseconds, no execution |
100
+ | `codepraxis validate` | The tests, against both fixtures | seconds (local) or ~1 min (remote) |
101
+
102
+ `lint` never imports pack code, so it is safe on a pack you did not write and
103
+ fast enough for every save. It catches things that otherwise only surface inside
104
+ a container — a two-argument `testCases.__init__`, zero-padded case names, a
105
+ `solution/` directory nested inside the pack, LaTeX in the Instructions brief.
106
+ `validate` runs it automatically first and stops if it finds an error, because
107
+ executing a pack whose class cannot be constructed only produces a confusing
108
+ traceback.
109
+
71
110
  ## The two fixtures
72
111
 
73
112
  Every pack is validated twice:
@@ -4,8 +4,11 @@ Author and validate CodePraxis challenge packs from your own repository.
4
4
 
5
5
  ```bash
6
6
  pip install codepraxis
7
+ codepraxis --example # see a real challenge, no account needed
8
+ codepraxis new my-challenge # scaffold one that already validates
9
+ codepraxis lint my-challenge # static checks, no execution
10
+ codepraxis validate --local my-challenge # run it, fast and advisory
7
11
  codepraxis --login
8
- codepraxis validate --local my-challenge # fast, advisory
9
12
  codepraxis --publish my-challenge # validates in the runner, then publishes
10
13
  ```
11
14
 
@@ -43,6 +46,42 @@ codepraxis validate --local my-challenge
43
46
  Once a key is configured the leniency stops: a model failure is then a real
44
47
  failure, because it can be judged.
45
48
 
49
+ ## Try it first
50
+
51
+ ```bash
52
+ codepraxis --example
53
+ ```
54
+
55
+ Starts a throwaway container with the featured challenge already cloned into it
56
+ and prints a URL. That is exactly what a candidate sees. No login, no account,
57
+ nothing recorded as an attempt. Add `--open` to launch it in your browser.
58
+
59
+ ## Starting a new pack
60
+
61
+ ```bash
62
+ codepraxis new my-challenge
63
+ codepraxis validate --local my-challenge # → PASSED
64
+ ```
65
+
66
+ The generated pack is complete and already passing — solution passes, starter
67
+ fails. Edit `._course_data/feature.md`, `source/`, `._tests/test_1.py` and
68
+ `../solution/` to make it yours.
69
+
70
+ ## Two kinds of checking
71
+
72
+ | Command | Runs | Cost |
73
+ |---|---|---|
74
+ | `codepraxis lint` | Static rules over the files | milliseconds, no execution |
75
+ | `codepraxis validate` | The tests, against both fixtures | seconds (local) or ~1 min (remote) |
76
+
77
+ `lint` never imports pack code, so it is safe on a pack you did not write and
78
+ fast enough for every save. It catches things that otherwise only surface inside
79
+ a container — a two-argument `testCases.__init__`, zero-padded case names, a
80
+ `solution/` directory nested inside the pack, LaTeX in the Instructions brief.
81
+ `validate` runs it automatically first and stops if it finds an error, because
82
+ executing a pack whose class cannot be constructed only produces a confusing
83
+ traceback.
84
+
46
85
  ## The two fixtures
47
86
 
48
87
  Every pack is validated twice:
@@ -1,3 +1,3 @@
1
1
  """Author and validate CodePraxis challenge packs."""
2
2
 
3
- __version__ = "0.2.1"
3
+ __version__ = "0.3.0"
@@ -12,6 +12,8 @@ import sys
12
12
  from pathlib import Path
13
13
 
14
14
  from . import __version__
15
+ from .commands import example as example_command
16
+ from .commands import lint as lint_command
15
17
  from .commands import login as login_command
16
18
  from .commands import publish as publish_command
17
19
  from .commands import validate as validate_command
@@ -21,6 +23,7 @@ from .execution.local.executor import LocalExecutor
21
23
  from .plugin import installer
22
24
  from .reporting.human import HumanReporter
23
25
  from .reporting.json_reporter import JsonReporter
26
+ from .scaffold import generator
24
27
 
25
28
  EXIT_USAGE = 2
26
29
 
@@ -62,6 +65,11 @@ def build_parser() -> argparse.ArgumentParser:
62
65
  metavar="TARGET",
63
66
  help="Install an integration. Currently: claude-plugin.",
64
67
  )
68
+ actions.add_argument(
69
+ "--example",
70
+ action="store_true",
71
+ help="Open the featured challenge in a live container. No account needed.",
72
+ )
65
73
 
66
74
  parser.add_argument(
67
75
  "--root",
@@ -87,6 +95,12 @@ def build_parser() -> argparse.ArgumentParser:
87
95
  help="Show passing cases and anything the tier cannot verify.",
88
96
  )
89
97
  parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON.")
98
+ parser.add_argument(
99
+ "--open",
100
+ action="store_true",
101
+ dest="open_browser",
102
+ help="With --example, open the container in your browser.",
103
+ )
90
104
 
91
105
  subparsers = parser.add_subparsers(dest="command")
92
106
 
@@ -146,6 +160,37 @@ def build_parser() -> argparse.ArgumentParser:
146
160
  check.add_argument("-v", "--verbose", action="store_true", help="Show passing cases and notes.")
147
161
  check.set_defaults(handler=_handle_validate)
148
162
 
163
+ static = subparsers.add_parser(
164
+ "lint",
165
+ help="Static checks only — no execution, no container.",
166
+ description=(
167
+ "Reads the pack and reports problems without importing or running any "
168
+ "of its code. Fast enough to run on every save, and safe on a pack you "
169
+ "did not write."
170
+ ),
171
+ )
172
+ static.add_argument("selector", nargs="?", help="Pack directory or name.")
173
+ static.add_argument("--root", type=Path, default=None, help="Directory to search for packs.")
174
+ static.add_argument("--json", action="store_true", help="Emit machine-readable JSON.")
175
+ static.add_argument("-v", "--verbose", action="store_true", help="Show notes as well as problems.")
176
+ static.set_defaults(handler=_handle_lint)
177
+
178
+ scaffold = subparsers.add_parser(
179
+ "new",
180
+ help="Scaffold a new pack that already validates.",
181
+ description=(
182
+ "Writes a complete, passing pack you can edit: solution passes, starter "
183
+ "fails. Running `codepraxis validate --local` on it should go green "
184
+ "immediately, which also proves your setup works."
185
+ ),
186
+ )
187
+ scaffold.add_argument("name", help="Pack name (lowercase letters, digits, underscores).")
188
+ scaffold.add_argument("--root", type=Path, default=None, help="Where to create it (default: ./challenges).")
189
+ scaffold.add_argument("--backend", default="AI", help="BACKEND value: AI, DSA, EMB or LNX.")
190
+ scaffold.add_argument("--language", default="PYTHON", help="LANGUAGE value.")
191
+ scaffold.add_argument("--force", action="store_true", help="Overwrite an existing directory.")
192
+ scaffold.set_defaults(handler=_handle_new)
193
+
149
194
  return parser
150
195
 
151
196
 
@@ -177,6 +222,29 @@ def _handle_validate(args: argparse.Namespace) -> int:
177
222
  )
178
223
 
179
224
 
225
+ def _handle_lint(args: argparse.Namespace) -> int:
226
+ return lint_command.run(
227
+ root=args.root or Path.cwd(),
228
+ selector=args.selector,
229
+ reporter=_reporter(args),
230
+ )
231
+
232
+
233
+ def _handle_new(args: argparse.Namespace) -> int:
234
+ # Default to ./challenges so the solution/ sibling lands outside the pack
235
+ # without the author having to think about layout.
236
+ root = args.root or (Path.cwd() / "challenges")
237
+ result = generator.create(
238
+ root=root,
239
+ raw_name=args.name,
240
+ backend=args.backend,
241
+ language=args.language,
242
+ force=args.force,
243
+ )
244
+ print(generator.describe(result, Path.cwd()))
245
+ return 0
246
+
247
+
180
248
  def _handle_install(target: str, force: bool) -> int:
181
249
  if target == "claude-plugin":
182
250
  result = installer.install(Path.cwd(), force=force)
@@ -203,6 +271,8 @@ def main(argv: list[str] | None = None) -> int:
203
271
  )
204
272
  if args.install:
205
273
  return _handle_install(args.install, args.force)
274
+ if args.example:
275
+ return example_command.run(open_browser=args.open_browser)
206
276
  if not getattr(args, "handler", None):
207
277
  parser.print_help()
208
278
  return EXIT_USAGE
@@ -0,0 +1,73 @@
1
+ """``codepraxis --example`` — open the featured challenge in a live container.
2
+
3
+ The fastest way to understand what a pack becomes: the platform allocates a
4
+ throwaway container, clones the featured challenge into it, and returns a URL
5
+ you can open in a browser to see exactly what a candidate sees.
6
+
7
+ Deliberately unauthenticated. This is the first thing someone runs after
8
+ installing, usually before they have a key, so requiring login would put a
9
+ signup in front of the demo.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import webbrowser
15
+
16
+ from ..errors import PraxisError
17
+ from ..execution.remote.client import ApiClient
18
+ from ..execution.remote.config import RemoteConfig
19
+
20
+ EXIT_OK = 0
21
+
22
+ FEATURED_PATH = "/challenges/featured"
23
+ TRIAL_PATH = "/challenges/trial/setup-codebase"
24
+
25
+
26
+ def run(client: ApiClient | None = None, open_browser: bool = False) -> int:
27
+ client = client or ApiClient(RemoteConfig.resolve_public())
28
+
29
+ challenge = (client.get(FEATURED_PATH) or {}).get("challenge") or {}
30
+ _describe(challenge)
31
+
32
+ print("\nStarting a container… this usually takes under a minute.")
33
+ session = client.post_json(TRIAL_PATH, {})
34
+
35
+ url = session.get("container_url")
36
+ if not url:
37
+ raise PraxisError("The platform started a session but returned no container URL.")
38
+
39
+ print(f"\n {url}\n")
40
+ _footer(session)
41
+
42
+ if open_browser:
43
+ webbrowser.open(url)
44
+
45
+ return EXIT_OK
46
+
47
+
48
+ def _describe(challenge: dict) -> None:
49
+ name = challenge.get("challenge_name") or "Featured challenge"
50
+ print(name)
51
+
52
+ description = challenge.get("description")
53
+ if description:
54
+ print(f" {str(description).strip().splitlines()[0]}")
55
+
56
+ difficulty = {1: "Easy", 2: "Medium", 3: "Hard"}.get(challenge.get("difficulty"))
57
+ # Drop blanks: the field frequently carries an empty trailing entry.
58
+ stack = [item for item in (challenge.get("tech_stack") or []) if str(item).strip()]
59
+ bits = [bit for bit in (difficulty, ", ".join(stack) if stack else None) if bit]
60
+ if bits:
61
+ print(f" {' · '.join(bits)}")
62
+
63
+ if challenge.get("max_time"):
64
+ print(f" {challenge['max_time']} minutes")
65
+
66
+
67
+ def _footer(session: dict) -> None:
68
+ expires = session.get("attempt_expires_at")
69
+ if expires:
70
+ print(f"The container is temporary and expires at {expires}.")
71
+ else:
72
+ print("The container is temporary and will be reclaimed when idle.")
73
+ print("Nothing you do in it is recorded as an attempt.")
@@ -0,0 +1,43 @@
1
+ """``codepraxis lint`` — static checks only, no execution.
2
+
3
+ Fast enough to run on every save, and safe to run against a pack you did not
4
+ write, because no pack code is imported.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ from ..domain.results import RunResult
12
+ from ..errors import PackError
13
+ from ..packio.discovery import find_packs, resolve_pack_dir
14
+ from ..packio.loader import load_pack
15
+ from ..reporting.reporter import Reporter
16
+ from ..validation.registry import has_errors
17
+ from ..validation.registry import lint as run_rules
18
+
19
+ EXIT_OK = 0
20
+ EXIT_FAILED = 1
21
+
22
+
23
+ def run(root: Path, selector: str | None, reporter: Reporter) -> int:
24
+ pack_dirs = [resolve_pack_dir(root, selector)] if selector else find_packs(root)
25
+ if not pack_dirs:
26
+ raise PackError(
27
+ f"No challenge packs found under {root}. "
28
+ f"A pack is a directory containing metadata.json and backend.conf."
29
+ )
30
+
31
+ exit_code = EXIT_OK
32
+ try:
33
+ for pack_dir in pack_dirs:
34
+ pack = load_pack(pack_dir)
35
+ findings = run_rules(pack)
36
+ reporter.report(
37
+ RunResult(pack_name=pack.name, executor="lint", diagnostics=tuple(findings))
38
+ )
39
+ if has_errors(findings):
40
+ exit_code = EXIT_FAILED
41
+ finally:
42
+ reporter.close()
43
+ return exit_code
@@ -8,15 +8,18 @@ identical for both tiers and testable without a subprocess or a network.
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
+ import dataclasses
11
12
  from collections.abc import Sequence
12
13
  from pathlib import Path
13
14
 
14
- from ..domain.results import Fixture
15
+ from ..domain.results import Fixture, RunResult
15
16
  from ..errors import PackError
16
17
  from ..execution.executor import Executor
17
18
  from ..packio.discovery import find_packs, resolve_pack_dir
18
19
  from ..packio.loader import load_pack
19
20
  from ..reporting.reporter import Reporter
21
+ from ..validation.registry import has_errors
22
+ from ..validation.registry import lint as run_rules
20
23
 
21
24
  EXIT_OK = 0
22
25
  EXIT_FAILED = 1
@@ -42,8 +45,25 @@ def run(
42
45
  try:
43
46
  for pack_dir in pack_dirs:
44
47
  pack = load_pack(pack_dir)
48
+
49
+ # Static rules first. A pack whose testCases cannot be constructed
50
+ # produces an opaque traceback when executed, so there is nothing to
51
+ # learn from running it — report the real cause and move on.
52
+ findings = run_rules(pack)
53
+ if has_errors(findings):
54
+ reporter.report(
55
+ RunResult(pack_name=pack.name, executor="lint", diagnostics=tuple(findings))
56
+ )
57
+ exit_code = EXIT_FAILED
58
+ continue
59
+
45
60
  chosen = list(fixtures) if fixtures else _default_fixtures(pack)
46
61
  result = executor.execute(pack, chosen)
62
+ # Warnings from the static pass travel with the execution result so
63
+ # the author sees one report, not two.
64
+ result = dataclasses.replace(
65
+ result, diagnostics=tuple(findings) + tuple(result.diagnostics)
66
+ )
47
67
  reporter.report(result)
48
68
  # An inconclusive run means this tier lacked the infrastructure to
49
69
  # judge the pack, not that the pack is wrong. Failing the command
@@ -61,12 +61,15 @@ class ApiClient:
61
61
 
62
62
  def _headers(self, content_type: str | None = None) -> dict:
63
63
  headers = {
64
- "Authorization": f"Bearer {self._config.token}",
65
64
  # Lets the server reject or warn on a CLI too old for the current
66
65
  # pack contract, instead of failing somewhere obscure.
67
66
  "X-Praxis-CLI-Version": __version__,
68
67
  "Accept": "application/json",
69
68
  }
69
+ # Public endpoints are called without credentials; sending an empty
70
+ # bearer token would be rejected as a malformed header.
71
+ if self._config.token:
72
+ headers["Authorization"] = f"Bearer {self._config.token}"
70
73
  if content_type:
71
74
  headers["Content-Type"] = content_type
72
75
  return headers
@@ -67,6 +67,17 @@ class RemoteConfig:
67
67
  #: so the CLI can show it before a publish. The server is authoritative.
68
68
  company: str | None = None
69
69
 
70
+ @classmethod
71
+ def resolve_public(cls, api_url: str | None = None) -> RemoteConfig:
72
+ """Config for endpoints that need no key.
73
+
74
+ Used by ``--example``, which someone runs before they have credentials;
75
+ requiring a login there would put a signup in front of the demo.
76
+ """
77
+ stored = read_stored()
78
+ resolved_url = api_url or os.environ.get(ENV_API_URL) or stored.get("api_url") or DEFAULT_API_URL
79
+ return cls(api_url=resolved_url.rstrip("/"), token="", company=stored.get("company"))
80
+
70
81
  @classmethod
71
82
  def resolve(cls, api_url: str | None = None, token: str | None = None) -> RemoteConfig:
72
83
  """Build a config, or explain precisely what is missing."""
@@ -124,6 +124,16 @@ class HumanReporter:
124
124
  style = self._style
125
125
  seconds = result.duration_ms / 1000.0
126
126
 
127
+ # A static-only result has no fixtures, so the pass/fail rules below —
128
+ # which are about solution vs starter — do not apply. Its verdict is
129
+ # simply whether any rule reported an error.
130
+ if not result.runs:
131
+ if result.errors:
132
+ self._line(style.bad(" FAILED") + f" {len(result.errors)} problem(s) must be fixed")
133
+ else:
134
+ self._line(style.good(" OK") + style.dim(" no problems found"))
135
+ return
136
+
127
137
  starter = result.run_for(Fixture.STARTER)
128
138
  if starter is not None and starter.all_passed:
129
139
  self._line(
File without changes
@@ -0,0 +1,139 @@
1
+ """Generate a new pack from templates.
2
+
3
+ The generated pack is deliberately *complete and passing*: run
4
+ ``codepraxis validate --local`` on it straight away and it goes green. That
5
+ gives an author a known-good starting point and proves their toolchain works
6
+ before they have written anything.
7
+
8
+ Templates ship inside the wheel and are read through ``importlib.resources`` so
9
+ this works from an installed package as well as a source checkout.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from dataclasses import dataclass
16
+ from importlib import resources
17
+ from pathlib import Path
18
+
19
+ from ..domain import contract
20
+ from ..errors import PraxisError
21
+
22
+ #: metadata.json "name" becomes a directory name inside the container, so it is
23
+ #: restricted to what a shell and a filesystem both handle without quoting.
24
+ _NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_]{2,49}$")
25
+
26
+ KNOWN_BACKENDS = ("AI", "DSA", "EMB", "LNX")
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class ScaffoldResult:
31
+ pack_dir: Path
32
+ solution_dir: Path
33
+ files: list
34
+
35
+
36
+ def normalize_name(raw: str) -> str:
37
+ """Turn a user-supplied slug into a valid pack name."""
38
+ name = raw.strip().lower().replace("-", "_").replace(" ", "_")
39
+ name = re.sub(r"_+", "_", name).strip("_")
40
+ if not _NAME_PATTERN.match(name):
41
+ raise PraxisError(
42
+ f"{raw!r} is not a usable pack name. Use 3-50 characters, lowercase "
43
+ f"letters, digits and underscores, starting with a letter."
44
+ )
45
+ return name
46
+
47
+
48
+ def title_from(name: str) -> str:
49
+ return name.replace("_", " ").strip().title()
50
+
51
+
52
+ def _template(filename: str) -> str:
53
+ return (resources.files("codepraxis.scaffold") / "templates" / filename).read_text(encoding="utf-8")
54
+
55
+
56
+ def _render(filename: str, name: str, backend: str, language: str) -> str:
57
+ return (
58
+ _template(filename)
59
+ .replace("__PACK_NAME__", name)
60
+ .replace("__TITLE__", title_from(name))
61
+ .replace("__BACKEND__", backend)
62
+ .replace("__LANGUAGE__", language)
63
+ )
64
+
65
+
66
+ def create(
67
+ root: Path,
68
+ raw_name: str,
69
+ backend: str = "AI",
70
+ language: str = "PYTHON",
71
+ force: bool = False,
72
+ ) -> ScaffoldResult:
73
+ """Write a new pack under ``root`` and return what was created.
74
+
75
+ The pack directory and its ``solution/`` sibling are laid out the way the
76
+ tooling expects: the solution is *beside* the pack so it can never be
77
+ packaged and shipped to a candidate.
78
+ """
79
+ name = normalize_name(raw_name)
80
+ backend = backend.strip().upper()
81
+ language = language.strip().upper()
82
+
83
+ if backend not in KNOWN_BACKENDS:
84
+ raise PraxisError(f"Unknown BACKEND {backend!r}. Expected one of: {', '.join(KNOWN_BACKENDS)}")
85
+
86
+ pack_dir = (root / name).resolve()
87
+ solution_dir = pack_dir.parent / "solution"
88
+
89
+ if pack_dir.exists() and not force:
90
+ raise PraxisError(f"{pack_dir} already exists. Choose another name, or pass --force.")
91
+
92
+ written = []
93
+
94
+ def write(path: Path, content: str) -> None:
95
+ path.parent.mkdir(parents=True, exist_ok=True)
96
+ path.write_text(content, encoding="utf-8")
97
+ written.append(path)
98
+
99
+ write(pack_dir / contract.METADATA_FILE, _render("metadata.json", name, backend, language))
100
+ write(pack_dir / contract.BACKEND_CONF_FILE, _render("backend.conf", name, backend, language))
101
+ write(pack_dir / contract.SOURCE_DIR / "main.py", _render("main.py", name, backend, language))
102
+ write(pack_dir / contract.SOURCE_DIR / "README.md", _render("README.md", name, backend, language))
103
+ write(pack_dir / contract.TESTS_DIR / "test_1.py", _render("test_1.py", name, backend, language))
104
+ write(
105
+ pack_dir / contract.COURSE_DATA_DIR / contract.COURSE_TOC_FILE,
106
+ _render("course_toc.json", name, backend, language),
107
+ )
108
+ write(pack_dir / contract.COURSE_DATA_DIR / "feature.md", _render("feature.md", name, backend, language))
109
+
110
+ # The reference solution overlays source/, so it mirrors those paths.
111
+ write(solution_dir / "main.py", _render("solution.py", name, backend, language))
112
+
113
+ return ScaffoldResult(pack_dir=pack_dir, solution_dir=solution_dir, files=written)
114
+
115
+
116
+ def describe(result: ScaffoldResult, root: Path) -> str:
117
+ def relative(path: Path) -> str:
118
+ try:
119
+ return str(path.relative_to(root))
120
+ except ValueError:
121
+ return str(path)
122
+
123
+ return "\n".join(
124
+ [
125
+ f"Created {relative(result.pack_dir)} ({len(result.files)} files)",
126
+ f" solution: {relative(result.solution_dir)} (beside the pack — never uploaded)",
127
+ "",
128
+ "It already validates. Check your setup:",
129
+ "",
130
+ f" codepraxis validate --local {result.pack_dir.name}",
131
+ "",
132
+ "Then make it your own:",
133
+ "",
134
+ " ._course_data/feature.md what the candidate reads",
135
+ " source/ what they start from",
136
+ " ._tests/test_1.py how it is graded",
137
+ " ../solution/ the reference answer",
138
+ ]
139
+ )
@@ -0,0 +1,7 @@
1
+ # __TITLE__
2
+
3
+ `total()` in `main.py` is not implemented yet — it raises.
4
+
5
+ Run it:
6
+
7
+ python3 main.py 1 2 3
@@ -0,0 +1,4 @@
1
+ {
2
+ "BACKEND": "__BACKEND__",
3
+ "LANGUAGE": "__LANGUAGE__"
4
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "instruction_1": {
3
+ "file": "feature.md",
4
+ "metadata": {
5
+ "STATUS": "IN_PROGRESS"
6
+ }
7
+ }
8
+ }
@@ -0,0 +1,21 @@
1
+ # __TITLE__
2
+
3
+ Replace this with the task the candidate has to do.
4
+
5
+ ## What to build
6
+
7
+ `main.py` currently raises instead of returning a result. Make it work.
8
+
9
+ ## Constraints
10
+
11
+ - Read arguments from the command line.
12
+ - Print one line to stdout and nothing else.
13
+
14
+ ## Example
15
+
16
+ Running with the arguments 1, 2 and 3 prints 6.
17
+
18
+ ## Acceptance criteria
19
+
20
+ - Summing 1, 2 and 3 prints 6.
21
+ - Summing -5 and 5 prints 0.
@@ -0,0 +1,12 @@
1
+ """__TITLE__"""
2
+
3
+ import sys
4
+
5
+
6
+ def total(numbers):
7
+ """Return the sum of ``numbers``."""
8
+ raise NotImplementedError("implement total()")
9
+
10
+
11
+ if __name__ == "__main__":
12
+ print(total([int(argument) for argument in sys.argv[1:]]))
@@ -0,0 +1,3 @@
1
+ {
2
+ "name": "__PACK_NAME__"
3
+ }