codepraxis 0.1.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.
Files changed (43) hide show
  1. codepraxis/__init__.py +3 -0
  2. codepraxis/__main__.py +8 -0
  3. codepraxis/cli.py +219 -0
  4. codepraxis/commands/__init__.py +0 -0
  5. codepraxis/commands/login.py +75 -0
  6. codepraxis/commands/publish.py +136 -0
  7. codepraxis/commands/validate.py +67 -0
  8. codepraxis/domain/__init__.py +0 -0
  9. codepraxis/domain/contract.py +96 -0
  10. codepraxis/domain/pack.py +83 -0
  11. codepraxis/domain/results.py +160 -0
  12. codepraxis/errors.py +15 -0
  13. codepraxis/execution/__init__.py +0 -0
  14. codepraxis/execution/executor.py +39 -0
  15. codepraxis/execution/local/__init__.py +0 -0
  16. codepraxis/execution/local/backends.py +124 -0
  17. codepraxis/execution/local/executor.py +320 -0
  18. codepraxis/execution/local/worker.py +232 -0
  19. codepraxis/execution/local/workspace.py +73 -0
  20. codepraxis/execution/remote/__init__.py +0 -0
  21. codepraxis/execution/remote/client.py +131 -0
  22. codepraxis/execution/remote/config.py +85 -0
  23. codepraxis/execution/remote/executor.py +152 -0
  24. codepraxis/packio/__init__.py +0 -0
  25. codepraxis/packio/archive.py +69 -0
  26. codepraxis/packio/discovery.py +95 -0
  27. codepraxis/packio/loader.py +91 -0
  28. codepraxis/packio/toc.py +72 -0
  29. codepraxis/plugin/__init__.py +0 -0
  30. codepraxis/plugin/installer.py +121 -0
  31. codepraxis/plugin/templates/commands/new.md +32 -0
  32. codepraxis/plugin/templates/commands/validate.md +30 -0
  33. codepraxis/plugin/templates/marketplace.json +16 -0
  34. codepraxis/plugin/templates/plugin.json +8 -0
  35. codepraxis/plugin/templates/skills/pack-authoring/SKILL.md +103 -0
  36. codepraxis/reporting/__init__.py +0 -0
  37. codepraxis/reporting/human.py +164 -0
  38. codepraxis/reporting/json_reporter.py +85 -0
  39. codepraxis/reporting/reporter.py +32 -0
  40. codepraxis-0.1.0.dist-info/METADATA +193 -0
  41. codepraxis-0.1.0.dist-info/RECORD +43 -0
  42. codepraxis-0.1.0.dist-info/WHEEL +4 -0
  43. codepraxis-0.1.0.dist-info/entry_points.txt +2 -0
codepraxis/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Author and validate CodePraxis challenge packs."""
2
+
3
+ __version__ = "0.1.0"
codepraxis/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Allows ``python -m praxis``."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
codepraxis/cli.py ADDED
@@ -0,0 +1,219 @@
1
+ """Command-line entry point.
2
+
3
+ Argument parsing and dependency wiring, nothing else. Concrete executors and
4
+ reporters are chosen here and injected into commands, so commands stay
5
+ independent of how results are produced or rendered.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ from . import __version__
15
+ from .commands import login as login_command
16
+ from .commands import publish as publish_command
17
+ from .commands import validate as validate_command
18
+ from .domain.results import Fixture
19
+ from .errors import PraxisError
20
+ from .execution.local.executor import LocalExecutor
21
+ from .plugin import installer
22
+ from .reporting.human import HumanReporter
23
+ from .reporting.json_reporter import JsonReporter
24
+
25
+ EXIT_USAGE = 2
26
+
27
+ INSTALLABLES = ("claude-plugin",)
28
+
29
+
30
+ def build_parser() -> argparse.ArgumentParser:
31
+ parser = argparse.ArgumentParser(
32
+ prog="codepraxis",
33
+ description="Author, validate and publish CodePraxis challenge packs.",
34
+ epilog=(
35
+ "Typical flow:\n"
36
+ " codepraxis --login\n"
37
+ " codepraxis validate --local my-challenge # fast, advisory\n"
38
+ " codepraxis validate --remote my-challenge # the real runner\n"
39
+ " codepraxis --publish my-challenge # validates, then publishes\n"
40
+ ),
41
+ formatter_class=argparse.RawDescriptionHelpFormatter,
42
+ )
43
+ parser.add_argument("--version", action="version", version=f"codepraxis {__version__}")
44
+
45
+ # Top-level actions. Mutually exclusive because each is a whole task.
46
+ actions = parser.add_mutually_exclusive_group()
47
+ actions.add_argument(
48
+ "--login",
49
+ action="store_true",
50
+ help="Store an API key and show which company it publishes as.",
51
+ )
52
+ actions.add_argument(
53
+ "--publish",
54
+ nargs="?",
55
+ const="",
56
+ metavar="PACK",
57
+ help="Validate a pack in the runner, then publish it to your company.",
58
+ )
59
+ actions.add_argument(
60
+ "--install",
61
+ choices=INSTALLABLES,
62
+ metavar="TARGET",
63
+ help="Install an integration. Currently: claude-plugin.",
64
+ )
65
+
66
+ parser.add_argument(
67
+ "--root",
68
+ type=Path,
69
+ default=Path.cwd(),
70
+ help="Directory to search for packs (default: current directory).",
71
+ )
72
+ parser.add_argument("--force", action="store_true", help="With --install, overwrite an existing install.")
73
+ parser.add_argument("--yes", action="store_true", help="With --publish, skip the confirmation prompt.")
74
+ parser.add_argument(
75
+ "--live",
76
+ action="store_true",
77
+ help="With --publish, publish straight to candidates instead of as a draft.",
78
+ )
79
+ parser.add_argument(
80
+ "--validation-run-id",
81
+ help="With --publish, reuse a passing validation run instead of running a new one.",
82
+ )
83
+ parser.add_argument(
84
+ "-v",
85
+ "--verbose",
86
+ action="store_true",
87
+ help="Show passing cases and anything the tier cannot verify.",
88
+ )
89
+ parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON.")
90
+
91
+ subparsers = parser.add_subparsers(dest="command")
92
+
93
+ check = subparsers.add_parser(
94
+ "validate",
95
+ help="Validate a pack: the solution must pass, the starter must fail.",
96
+ description=(
97
+ "Runs a pack's testCases against the reference solution and the starter. "
98
+ "--local is a fast advisory check on your machine; --remote runs the real "
99
+ "runner image on CodePraxis and is what publishing requires."
100
+ ),
101
+ )
102
+ check.add_argument(
103
+ "selector",
104
+ nargs="?",
105
+ help="Pack directory or name. Defaults to every pack found under --root.",
106
+ )
107
+ check.add_argument("--root", type=Path, default=None, help="Directory to search for packs.")
108
+
109
+ tier = check.add_mutually_exclusive_group()
110
+ tier.add_argument(
111
+ "--local",
112
+ dest="tier",
113
+ action="store_const",
114
+ const="local",
115
+ help="Run on this machine (default). Fast, advisory, no network.",
116
+ )
117
+ tier.add_argument(
118
+ "--remote",
119
+ dest="tier",
120
+ action="store_const",
121
+ const="remote",
122
+ help="Run in the real runner image on CodePraxis. Required before publishing.",
123
+ )
124
+ check.set_defaults(tier="local")
125
+
126
+ check.add_argument(
127
+ "--fixture",
128
+ choices=[fixture.value for fixture in Fixture],
129
+ action="append",
130
+ dest="fixtures",
131
+ help="Run only this fixture. Repeatable. Defaults to solution + starter.",
132
+ )
133
+ check.add_argument(
134
+ "--llm-base-url",
135
+ help=(
136
+ "OpenAI-compatible endpoint for packs that call a model. Defaults to "
137
+ "OPENAI_BASE_URL. Without a key, model-dependent cases are reported "
138
+ "unverifiable rather than failed."
139
+ ),
140
+ )
141
+ check.add_argument(
142
+ "--llm-api-key",
143
+ help="API key for --llm-base-url. Defaults to OPENAI_API_KEY (preferred: keep it in the environment).",
144
+ )
145
+ check.add_argument("--json", action="store_true", help="Emit machine-readable JSON.")
146
+ check.add_argument("-v", "--verbose", action="store_true", help="Show passing cases and notes.")
147
+ check.set_defaults(handler=_handle_validate)
148
+
149
+ return parser
150
+
151
+
152
+ def _reporter(args: argparse.Namespace):
153
+ return JsonReporter() if args.json else HumanReporter(verbose=args.verbose)
154
+
155
+
156
+ def _build_executor(args: argparse.Namespace):
157
+ if args.tier == "remote":
158
+ # Imported lazily so a local run never pays for the network stack, and
159
+ # a missing credential file cannot break an offline run.
160
+ from .execution.remote.executor import RemoteExecutor
161
+
162
+ return RemoteExecutor()
163
+ return LocalExecutor(
164
+ llm_base_url=getattr(args, "llm_base_url", None),
165
+ llm_api_key=getattr(args, "llm_api_key", None),
166
+ )
167
+
168
+
169
+ def _handle_validate(args: argparse.Namespace) -> int:
170
+ fixtures = [Fixture(value) for value in args.fixtures] if args.fixtures else None
171
+ return validate_command.run(
172
+ root=args.root or Path.cwd(),
173
+ selector=args.selector,
174
+ executor=_build_executor(args),
175
+ reporter=_reporter(args),
176
+ fixtures=fixtures,
177
+ )
178
+
179
+
180
+ def _handle_install(target: str, force: bool) -> int:
181
+ if target == "claude-plugin":
182
+ result = installer.install(Path.cwd(), force=force)
183
+ print(installer.describe(result))
184
+ return 0
185
+ raise PraxisError(f"Unknown install target: {target}")
186
+
187
+
188
+ def main(argv: list[str] | None = None) -> int:
189
+ parser = build_parser()
190
+ args = parser.parse_args(argv)
191
+
192
+ try:
193
+ if args.login:
194
+ return login_command.run()
195
+ if args.publish is not None:
196
+ return publish_command.run(
197
+ root=args.root,
198
+ selector=args.publish or None,
199
+ reporter=_reporter(args),
200
+ assume_yes=args.yes,
201
+ live=args.live,
202
+ validation_run_id=args.validation_run_id,
203
+ )
204
+ if args.install:
205
+ return _handle_install(args.install, args.force)
206
+ if not getattr(args, "handler", None):
207
+ parser.print_help()
208
+ return EXIT_USAGE
209
+ return args.handler(args)
210
+ except PraxisError as exc:
211
+ print(f"error: {exc}", file=sys.stderr)
212
+ return EXIT_USAGE
213
+ except KeyboardInterrupt: # pragma: no cover
214
+ print("interrupted", file=sys.stderr)
215
+ return 130
216
+
217
+
218
+ if __name__ == "__main__": # pragma: no cover
219
+ sys.exit(main())
File without changes
@@ -0,0 +1,75 @@
1
+ """``codepraxis --login`` — store an API key and confirm who it belongs to.
2
+
3
+ The key is verified against the platform before it is written, so a typo fails
4
+ here rather than at the next publish. The response also tells the author which
5
+ company they are acting for, which is what every later publish is scoped to.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import getpass
11
+ import os
12
+ import sys
13
+
14
+ from ..errors import PraxisError
15
+ from ..execution.remote.client import ApiClient
16
+ from ..execution.remote.config import (
17
+ DEFAULT_API_URL,
18
+ ENV_API_URL,
19
+ RemoteConfig,
20
+ read_stored,
21
+ write_stored,
22
+ )
23
+
24
+ EXIT_OK = 0
25
+
26
+
27
+ def run(api_url: str | None = None, token: str | None = None) -> int:
28
+ stored = read_stored()
29
+ resolved_url = api_url or os.environ.get(ENV_API_URL) or stored.get("api_url") or DEFAULT_API_URL
30
+ resolved_url = resolved_url.rstrip("/")
31
+
32
+ key = token or _prompt(resolved_url)
33
+ if not key:
34
+ raise PraxisError("No API key entered.")
35
+
36
+ # Verify before persisting: a stored bad key produces confusing failures
37
+ # much later, in commands that look unrelated to authentication.
38
+ client = ApiClient(RemoteConfig(api_url=resolved_url, token=key))
39
+ identity = client.get("/me")
40
+
41
+ company = identity.get("company") or {}
42
+ company_name = company.get("name") or identity.get("company_name")
43
+ company_id = company.get("id") or identity.get("company_id")
44
+
45
+ path = write_stored(
46
+ {
47
+ "api_url": resolved_url,
48
+ "token": key,
49
+ "company": company_name,
50
+ "company_id": company_id,
51
+ }
52
+ )
53
+
54
+ who = identity.get("email") or identity.get("user") or "this key"
55
+ print(f"Logged in as {who}")
56
+ if company_name:
57
+ print(f"Publishing as: {company_name}" + (f" (id {company_id})" if company_id else ""))
58
+ else:
59
+ # Without a company the key cannot publish; say so now rather than
60
+ # letting the author discover it at the end of an authoring session.
61
+ print("warning: this key is not associated with a company; publishing will be rejected.")
62
+ print(f"Credentials written to {path} (permissions 0600)")
63
+ return EXIT_OK
64
+
65
+
66
+ def _prompt(api_url: str) -> str:
67
+ if not sys.stdin.isatty():
68
+ raise PraxisError(
69
+ "No API key given and stdin is not a terminal. "
70
+ "Set CODEPRAXIS_TOKEN in the environment instead (this is the CI path)."
71
+ )
72
+ print(f"Signing in to {api_url}")
73
+ print("Create a key at your CodePraxis dashboard -> Settings -> API keys.")
74
+ # getpass keeps the key out of the terminal scrollback and shell history.
75
+ return getpass.getpass("API key: ").strip()
@@ -0,0 +1,136 @@
1
+ """``codepraxis --publish`` — publish a validated pack to your company.
2
+
3
+ Publishing is outward-facing and effectively irreversible: candidates can be
4
+ assigned the challenge immediately. So the flow is deliberately strict.
5
+
6
+ 1. **Validate remotely first.** A pack is only publishable off the back of a
7
+ passing run in the real runner. Local results never qualify — they cannot
8
+ observe ``setup.sh``, the image's packages, or the LLM proxy.
9
+ 2. **Show the company and confirm.** The server derives ownership from the API
10
+ key; the CLI never sends a company id, because a client-supplied owner is
11
+ exactly the tenancy hole this design avoids. The name is displayed so the
12
+ author can catch a wrong-key mistake before it lands.
13
+ 3. **Publish as a draft by default.** Reaching candidates is a separate,
14
+ deliberate act in the dashboard unless ``--live`` is passed.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ from ..domain.results import Fixture
23
+ from ..errors import PraxisError
24
+ from ..execution.remote.client import ApiClient
25
+ from ..execution.remote.config import RemoteConfig
26
+ from ..execution.remote.executor import RemoteExecutor
27
+ from ..packio.discovery import resolve_pack_dir
28
+ from ..packio.loader import load_pack
29
+ from ..reporting.reporter import Reporter
30
+
31
+ EXIT_OK = 0
32
+ EXIT_FAILED = 1
33
+ EXIT_ABORTED = 2
34
+
35
+
36
+ def run(
37
+ root: Path,
38
+ selector: str | None,
39
+ reporter: Reporter,
40
+ assume_yes: bool = False,
41
+ live: bool = False,
42
+ validation_run_id: str | None = None,
43
+ client: ApiClient | None = None,
44
+ ) -> int:
45
+ if not selector:
46
+ raise PraxisError(
47
+ "Publishing needs an explicit pack: `codepraxis --publish <name>`. "
48
+ "Refusing to publish every pack it can find."
49
+ )
50
+
51
+ pack = load_pack(resolve_pack_dir(root, selector))
52
+
53
+ if not pack.has_solution:
54
+ raise PraxisError(
55
+ f"{pack.name} has no solution/ directory. A reference solution is required "
56
+ f"before publishing — it is what proves the challenge is solvable."
57
+ )
58
+
59
+ # Credentials are resolved lazily so an injected client (tests, and any
60
+ # future caller that already authenticated) never touches the filesystem.
61
+ config = None
62
+ if client is None:
63
+ config = RemoteConfig.resolve()
64
+ client = ApiClient(config)
65
+
66
+ run_id = validation_run_id
67
+ if run_id is None:
68
+ run_id = _validate(pack, client, reporter)
69
+
70
+ company = _company_name(client, config)
71
+ if not _confirm(pack.name, company, live, assume_yes):
72
+ print("Aborted.", file=sys.stderr)
73
+ return EXIT_ABORTED
74
+
75
+ payload = client.post_json(
76
+ "/challenges",
77
+ {
78
+ # No company_id: the server derives ownership from the API key.
79
+ "name": pack.name,
80
+ "validation_run_id": run_id,
81
+ "status": "published" if live else "draft",
82
+ },
83
+ )
84
+
85
+ challenge_id = payload.get("challenge_id")
86
+ version_id = payload.get("challenge_version_id")
87
+ state = "published" if live else "draft"
88
+
89
+ print(f"{pack.name} {state} for {company}")
90
+ if challenge_id:
91
+ print(f" challenge {challenge_id}, version {version_id}")
92
+ if not live:
93
+ print(" Publish it to candidates from the dashboard when you are ready.")
94
+ return EXIT_OK
95
+
96
+
97
+ def _validate(pack, client: ApiClient, reporter: Reporter) -> str:
98
+ """Run remote validation and refuse to continue unless it passes."""
99
+ print(f"Validating {pack.name} in the runner before publishing…")
100
+ executor = RemoteExecutor(client=client)
101
+ result = executor.execute(pack, [Fixture.SOLUTION, Fixture.STARTER])
102
+ reporter.report(result)
103
+
104
+ if not result.ok:
105
+ raise PraxisError(
106
+ "Remote validation failed, so nothing was published. "
107
+ "Fix the pack and try again, or re-run with --validation-run-id to reuse a passing run."
108
+ )
109
+
110
+ if not executor.last_run_id:
111
+ raise PraxisError("Validation passed but the platform returned no run id to publish against.")
112
+ return executor.last_run_id
113
+
114
+
115
+ def _company_name(client: ApiClient, config: RemoteConfig | None) -> str:
116
+ """Ask the server who this key acts for; fall back to what login cached."""
117
+ cached = config.company if config else None
118
+ try:
119
+ identity = client.get("/me")
120
+ except PraxisError:
121
+ return cached or "your company"
122
+ company = identity.get("company") or {}
123
+ return company.get("name") or cached or "your company"
124
+
125
+
126
+ def _confirm(pack_name: str, company: str, live: bool, assume_yes: bool) -> bool:
127
+ if assume_yes:
128
+ return True
129
+ if not sys.stdin.isatty():
130
+ raise PraxisError(
131
+ "Publishing needs confirmation, but stdin is not a terminal. "
132
+ "Pass --yes to publish non-interactively (CI)."
133
+ )
134
+ visibility = "LIVE to candidates" if live else "as a draft"
135
+ print(f"\nAbout to publish {pack_name} to {company}, {visibility}.")
136
+ return input("Continue? [y/N] ").strip().lower() in {"y", "yes"}
@@ -0,0 +1,67 @@
1
+ """``codepraxis validate`` — run a pack's tests.
2
+
3
+ The command orchestrates and nothing else: it resolves packs, asks an executor
4
+ to run them, hands results to a reporter, and turns them into an exit code.
5
+ Which executor (local or remote) is chosen in ``cli.py``, so this code is
6
+ identical for both tiers and testable without a subprocess or a network.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Sequence
12
+ from pathlib import Path
13
+
14
+ from ..domain.results import Fixture
15
+ from ..errors import PackError
16
+ from ..execution.executor import Executor
17
+ from ..packio.discovery import find_packs, resolve_pack_dir
18
+ from ..packio.loader import load_pack
19
+ from ..reporting.reporter import Reporter
20
+
21
+ EXIT_OK = 0
22
+ EXIT_FAILED = 1
23
+
24
+
25
+ def run(
26
+ root: Path,
27
+ selector: str | None,
28
+ executor: Executor,
29
+ reporter: Reporter,
30
+ fixtures: Sequence[Fixture] | None = None,
31
+ ) -> int:
32
+ """Validate one pack, or every pack under ``root`` when ``selector`` is None."""
33
+ pack_dirs = [resolve_pack_dir(root, selector)] if selector else find_packs(root)
34
+
35
+ if not pack_dirs:
36
+ raise PackError(
37
+ f"No challenge packs found under {root}. "
38
+ f"A pack is a directory containing metadata.json and backend.conf."
39
+ )
40
+
41
+ exit_code = EXIT_OK
42
+ try:
43
+ for pack_dir in pack_dirs:
44
+ pack = load_pack(pack_dir)
45
+ chosen = list(fixtures) if fixtures else _default_fixtures(pack)
46
+ result = executor.execute(pack, chosen)
47
+ reporter.report(result)
48
+ # An inconclusive run means this tier lacked the infrastructure to
49
+ # judge the pack, not that the pack is wrong. Failing the command
50
+ # would block an inner loop over something the author cannot fix.
51
+ if not result.ok and not result.inconclusive:
52
+ exit_code = EXIT_FAILED
53
+ finally:
54
+ reporter.close()
55
+ return exit_code
56
+
57
+
58
+ def _default_fixtures(pack) -> list[Fixture]:
59
+ """Run both fixtures when a solution exists.
60
+
61
+ The starter run is what proves the tests discriminate, so it is never
62
+ opt-in; the solution run is skipped only when there is no reference
63
+ solution yet.
64
+ """
65
+ if pack.has_solution:
66
+ return [Fixture.SOLUTION, Fixture.STARTER]
67
+ return [Fixture.STARTER]
File without changes
@@ -0,0 +1,96 @@
1
+ """The pack execution contract, mirrored from the production runner.
2
+
3
+ Every constant here has a counterpart in the runner image. When the runner
4
+ changes, this file changes with it, and ``tests/conformance`` is what catches
5
+ the drift. Provenance is recorded per constant so the mirror can be re-checked
6
+ against the source rather than trusted.
7
+
8
+ Sources (paths relative to the ``docker-image`` repo):
9
+ - ``setupCodeBase.py`` — pack mounting, test-file selection, workspace rsync
10
+ - ``koro/test_loader.py`` — testCases loading, test-case discovery and ordering
11
+ - ``koro/test_runner.py`` — per-override execution semantics
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ # --- Pack layout -----------------------------------------------------------
17
+ # setupCodeBase.py mounts the pack at /praxis/codeFromServer/{foldername}/ and
18
+ # reads these paths beneath it. The leading "._" is literal; the runner does not
19
+ # tolerate renamed directories.
20
+ TESTS_DIR = "._tests"
21
+ COURSE_DATA_DIR = "._course_data"
22
+ SOURCE_DIR = "source"
23
+ METADATA_FILE = "metadata.json"
24
+ BACKEND_CONF_FILE = "backend.conf"
25
+ COURSE_TOC_FILE = "course_toc.json"
26
+
27
+ #: Files/dirs a pack must contain. Mirrors REQUIRED_PACK_FILES in
28
+ #: question-bank/tools/common.py so the CLI and the existing CI agree.
29
+ REQUIRED_PACK_PATHS = (
30
+ METADATA_FILE,
31
+ BACKEND_CONF_FILE,
32
+ SOURCE_DIR,
33
+ f"{TESTS_DIR}/test_1.py",
34
+ f"{COURSE_DATA_DIR}/{COURSE_TOC_FILE}",
35
+ f"{COURSE_DATA_DIR}/feature.md",
36
+ )
37
+
38
+ # --- Workspace -------------------------------------------------------------
39
+ #: setupCodeBase.py rsyncs ``source/`` *contents* (note the trailing slash in the
40
+ #: runner's rsync) into /home/praxis/{foldername}/ — a flat copy, not a nested
41
+ #: ``source`` directory. ``foldername`` comes from metadata.json's "name".
42
+ CONTAINER_USER = "praxis"
43
+ CONTAINER_WORKSPACE_TEMPLATE = "/home/{user}/{foldername}"
44
+
45
+ # --- testCases contract ----------------------------------------------------
46
+ #: koro/test_loader.py:87 — ``getattr(test_module, 'testCases')(question_folder)``.
47
+ #: Exactly one argument after ``self``: the workspace path.
48
+ TEST_CLASS_NAME = "testCases"
49
+
50
+ #: koro/test_loader.py:18 — checked only in developer mode, but authors should
51
+ #: satisfy all of them; a missing attribute here is a warning, not a hard error.
52
+ EXPECTED_TEST_CASE_ATTRS = (
53
+ "RUN",
54
+ "RunCaseInputs",
55
+ "exe",
56
+ "userWxpace",
57
+ "default_timeout_window",
58
+ "msg",
59
+ )
60
+
61
+ #: koro/test_loader.py:115 — discovery is ``name.startswith('test_case')``,
62
+ #: not a regex match, so ``test_caseFoo`` would also be collected.
63
+ TEST_CASE_PREFIX = "test_case"
64
+
65
+ #: koro/test_loader.py:130 — ordering key is ``int(re.search(r'\d+', name).group())``:
66
+ #: the FIRST run of digits anywhere in the method name. This is why zero-padded
67
+ #: names collide (``test_case_01`` and ``test_case_1`` both sort as 1) and why
68
+ #: the authoring guide forbids them.
69
+ TEST_CASE_ORDER_PATTERN = r"\d+"
70
+
71
+ #: koro/test_loader.py:84 — the runner injects ``execute_bin`` into the test
72
+ #: module's namespace before instantiation. Tests may reference it at module
73
+ #: scope, so the harness must provide a binding or import fails.
74
+ INJECTED_EXECUTE_BIN = "execute_bin"
75
+
76
+ #: setupCodeBase.py:~225 — for EMB and LINUX question types the runner injects a
77
+ #: dummy ``cmd`` handler as well.
78
+ INJECTED_CMD = "cmd"
79
+
80
+ # --- Execution semantics ---------------------------------------------------
81
+ #: koro/test_runner.py:151 — ``timeout_secs = timeout / 1000.0``. Both
82
+ #: ``default_timeout_window`` and a case's ``timeout_window`` default are in
83
+ #: MILLISECONDS.
84
+ TIMEOUT_UNIT_MS = True
85
+
86
+ #: koro/test_runner.py:227 — a case passes if and only if ``self.msg == "PASS"``
87
+ #: after the method returns. The return value never decides pass/fail.
88
+ PASS_SENTINEL = "PASS"
89
+
90
+ #: koro/test_runner.py:206 — ``._bad_input`` anywhere in ``str(result)`` forces
91
+ #: a failure regardless of ``msg``.
92
+ BAD_INPUT_SENTINEL = "._bad_input"
93
+
94
+ #: koro/test_runner.py:220 — override 1 reads the returned tuple as
95
+ #: ``(panel_text, success_message)`` and reports them as (expected, output).
96
+ OVERRIDE_DEFAULT = 1