codepraxis 0.3.2__tar.gz → 0.3.3__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 (72) hide show
  1. {codepraxis-0.3.2 → codepraxis-0.3.3}/PKG-INFO +27 -1
  2. {codepraxis-0.3.2 → codepraxis-0.3.3}/README.md +26 -0
  3. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/__init__.py +1 -1
  4. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/cli.py +17 -0
  5. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/commands/catalog.py +33 -0
  6. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/commands/publish.py +23 -14
  7. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/execution/remote/client.py +3 -0
  8. {codepraxis-0.3.2 → codepraxis-0.3.3}/tests/test_publish.py +90 -0
  9. {codepraxis-0.3.2 → codepraxis-0.3.3}/.gitignore +0 -0
  10. {codepraxis-0.3.2 → codepraxis-0.3.3}/CONTRIBUTING.md +0 -0
  11. {codepraxis-0.3.2 → codepraxis-0.3.3}/RELEASING.md +0 -0
  12. {codepraxis-0.3.2 → codepraxis-0.3.3}/pyproject.toml +0 -0
  13. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/__main__.py +0 -0
  14. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/commands/__init__.py +0 -0
  15. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/commands/example.py +0 -0
  16. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/commands/lint.py +0 -0
  17. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/commands/login.py +0 -0
  18. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/commands/validate.py +0 -0
  19. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/domain/__init__.py +0 -0
  20. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/domain/contract.py +0 -0
  21. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/domain/pack.py +0 -0
  22. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/domain/results.py +0 -0
  23. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/errors.py +0 -0
  24. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/execution/__init__.py +0 -0
  25. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/execution/executor.py +0 -0
  26. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/execution/local/__init__.py +0 -0
  27. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/execution/local/backends.py +0 -0
  28. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/execution/local/executor.py +0 -0
  29. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/execution/local/worker.py +0 -0
  30. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/execution/local/workspace.py +0 -0
  31. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/execution/remote/__init__.py +0 -0
  32. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/execution/remote/config.py +0 -0
  33. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/execution/remote/executor.py +0 -0
  34. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/packio/__init__.py +0 -0
  35. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/packio/archive.py +0 -0
  36. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/packio/discovery.py +0 -0
  37. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/packio/loader.py +0 -0
  38. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/packio/toc.py +0 -0
  39. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/plugin/__init__.py +0 -0
  40. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/plugin/installer.py +0 -0
  41. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/plugin/templates/commands/new.md +0 -0
  42. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/plugin/templates/commands/validate.md +0 -0
  43. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/plugin/templates/marketplace.json +0 -0
  44. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/plugin/templates/plugin.json +0 -0
  45. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/plugin/templates/skills/pack-authoring/SKILL.md +0 -0
  46. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/reporting/__init__.py +0 -0
  47. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/reporting/human.py +0 -0
  48. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/reporting/json_reporter.py +0 -0
  49. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/reporting/reporter.py +0 -0
  50. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/scaffold/__init__.py +0 -0
  51. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/scaffold/generator.py +0 -0
  52. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/scaffold/templates/README.md +0 -0
  53. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/scaffold/templates/backend.conf +0 -0
  54. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/scaffold/templates/course_toc.json +0 -0
  55. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/scaffold/templates/feature.md +0 -0
  56. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/scaffold/templates/main.py +0 -0
  57. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/scaffold/templates/metadata.json +0 -0
  58. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/scaffold/templates/solution.py +0 -0
  59. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/scaffold/templates/test_1.py +0 -0
  60. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/validation/__init__.py +0 -0
  61. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/validation/registry.py +0 -0
  62. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/validation/rule.py +0 -0
  63. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/validation/rules/__init__.py +0 -0
  64. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/validation/rules/hygiene.py +0 -0
  65. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/validation/rules/instructions.py +0 -0
  66. {codepraxis-0.3.2 → codepraxis-0.3.3}/src/codepraxis/validation/rules/testcases.py +0 -0
  67. {codepraxis-0.3.2 → codepraxis-0.3.3}/tests/conformance/test_corpus.py +0 -0
  68. {codepraxis-0.3.2 → codepraxis-0.3.3}/tests/conftest.py +0 -0
  69. {codepraxis-0.3.2 → codepraxis-0.3.3}/tests/test_catalog.py +0 -0
  70. {codepraxis-0.3.2 → codepraxis-0.3.3}/tests/test_classification.py +0 -0
  71. {codepraxis-0.3.2 → codepraxis-0.3.3}/tests/test_toc.py +0 -0
  72. {codepraxis-0.3.2 → codepraxis-0.3.3}/tests/test_validation_rules.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codepraxis
3
- Version: 0.3.2
3
+ Version: 0.3.3
4
4
  Summary: Author and validate CodePraxis challenge packs.
5
5
  Project-URL: Documentation, https://docs.codepraxis.com/authoring
6
6
  Author: CodePraxis
@@ -37,6 +37,7 @@ codepraxis --login
37
37
  codepraxis --publish my-challenge # validates in the runner, then publishes
38
38
  codepraxis --list # list company questions
39
39
  codepraxis --edit 123 --open # update metadata/open a preview container
40
+ codepraxis --publish my-challenge --challenge-id 123 # publish an edit, not a duplicate
40
41
  ```
41
42
 
42
43
  ## What this is
@@ -214,6 +215,31 @@ codepraxis --edit 123 --open
214
215
 
215
216
  `--list` shows the questions owned by your API key's company, including drafts.
216
217
  `--edit` updates question metadata and returns a container URL for inspection.
218
+
219
+ ### Publishing an edit
220
+
221
+ Changing the *content* of a question means re-publishing the pack. Pass the
222
+ question's id, or you get a second copy:
223
+
224
+ ```bash
225
+ codepraxis --publish my-challenge --challenge-id 123
226
+ ```
227
+
228
+ With `--challenge-id` the platform adds a new version to that question and
229
+ keeps its id, assignments and history. Without it, publish always creates a
230
+ new question — which is the right behaviour the first time and a duplicate
231
+ every time after.
232
+
233
+ ### Deleting
234
+
235
+ ```bash
236
+ codepraxis --delete 123
237
+ ```
238
+
239
+ Asks first, and the platform refuses outright once the question has been
240
+ assigned to anyone — deleting it then would orphan attempts and reports that
241
+ candidates and reviewers still depend on. To take an assigned question out of
242
+ circulation, set it back to draft with `--edit 123 --status draft`.
217
243
  Code changes still go through the pack workflow: edit locally, validate, and
218
244
  publish a new version.
219
245
 
@@ -12,6 +12,7 @@ codepraxis --login
12
12
  codepraxis --publish my-challenge # validates in the runner, then publishes
13
13
  codepraxis --list # list company questions
14
14
  codepraxis --edit 123 --open # update metadata/open a preview container
15
+ codepraxis --publish my-challenge --challenge-id 123 # publish an edit, not a duplicate
15
16
  ```
16
17
 
17
18
  ## What this is
@@ -189,6 +190,31 @@ codepraxis --edit 123 --open
189
190
 
190
191
  `--list` shows the questions owned by your API key's company, including drafts.
191
192
  `--edit` updates question metadata and returns a container URL for inspection.
193
+
194
+ ### Publishing an edit
195
+
196
+ Changing the *content* of a question means re-publishing the pack. Pass the
197
+ question's id, or you get a second copy:
198
+
199
+ ```bash
200
+ codepraxis --publish my-challenge --challenge-id 123
201
+ ```
202
+
203
+ With `--challenge-id` the platform adds a new version to that question and
204
+ keeps its id, assignments and history. Without it, publish always creates a
205
+ new question — which is the right behaviour the first time and a duplicate
206
+ every time after.
207
+
208
+ ### Deleting
209
+
210
+ ```bash
211
+ codepraxis --delete 123
212
+ ```
213
+
214
+ Asks first, and the platform refuses outright once the question has been
215
+ assigned to anyone — deleting it then would orphan attempts and reports that
216
+ candidates and reviewers still depend on. To take an assigned question out of
217
+ circulation, set it back to draft with `--edit 123 --status draft`.
192
218
  Code changes still go through the pack workflow: edit locally, validate, and
193
219
  publish a new version.
194
220
 
@@ -1,3 +1,3 @@
1
1
  """Author and validate CodePraxis challenge packs."""
2
2
 
3
- __version__ = "0.3.2"
3
+ __version__ = "0.3.3"
@@ -71,6 +71,12 @@ def build_parser() -> argparse.ArgumentParser:
71
71
  metavar="CHALLENGE_ID",
72
72
  help="Edit question metadata and open its container preview.",
73
73
  )
74
+ actions.add_argument(
75
+ "--delete",
76
+ type=int,
77
+ metavar="CHALLENGE_ID",
78
+ help="Delete a question your company owns. Refused once it has been assigned.",
79
+ )
74
80
  actions.add_argument(
75
81
  "--install",
76
82
  choices=INSTALLABLES,
@@ -96,6 +102,14 @@ def build_parser() -> argparse.ArgumentParser:
96
102
  action="store_true",
97
103
  help="With --publish, publish straight to candidates instead of as a draft.",
98
104
  )
105
+ parser.add_argument(
106
+ "--challenge-id",
107
+ type=int,
108
+ help=(
109
+ "With --publish, publish a new version of this existing question "
110
+ "instead of creating another one. Get the id from --list."
111
+ ),
112
+ )
99
113
  parser.add_argument(
100
114
  "--validation-run-id",
101
115
  help="With --publish, reuse a passing validation run instead of running a new one.",
@@ -316,6 +330,7 @@ def main(argv: list[str] | None = None) -> int:
316
330
  assume_yes=args.yes,
317
331
  live=args.live,
318
332
  validation_run_id=args.validation_run_id,
333
+ challenge_id=args.challenge_id,
319
334
  )
320
335
  if args.list:
321
336
  return catalog_command.list_questions(as_json=args.json)
@@ -325,6 +340,8 @@ def main(argv: list[str] | None = None) -> int:
325
340
  updates=_edit_updates(args),
326
341
  open_browser=args.open_browser,
327
342
  )
343
+ if args.delete is not None:
344
+ return catalog_command.delete_question(args.delete, assume_yes=args.yes)
328
345
  if args.install:
329
346
  return _handle_install(args.install, args.force)
330
347
  if args.example:
@@ -11,6 +11,7 @@ from ..execution.remote.client import ApiClient
11
11
  from ..execution.remote.config import RemoteConfig
12
12
 
13
13
  EXIT_OK = 0
14
+ EXIT_ABORTED = 2
14
15
 
15
16
 
16
17
  def list_questions(client: ApiClient | None = None, as_json: bool = False) -> int:
@@ -79,3 +80,35 @@ def _print_challenge(challenge: dict[str, Any]) -> None:
79
80
  if updated:
80
81
  line += f"\t{updated}"
81
82
  print(line)
83
+
84
+
85
+ def delete_question(
86
+ challenge_id: int,
87
+ client: ApiClient | None = None,
88
+ assume_yes: bool = False,
89
+ ) -> int:
90
+ """Delete a question the company owns.
91
+
92
+ Confirmed by default: unlike a draft, this is not recoverable from the
93
+ dashboard. The platform refuses outright once the question has been assigned
94
+ to anyone, so the destructive case this guards is an unassigned question the
95
+ author still wanted.
96
+ """
97
+ import sys
98
+
99
+ client = client or ApiClient(RemoteConfig.resolve())
100
+
101
+ if not assume_yes:
102
+ if not sys.stdin.isatty():
103
+ raise PraxisError(
104
+ "Deleting needs confirmation, but stdin is not a terminal. Pass --yes."
105
+ )
106
+ print(f"Permanently delete question {challenge_id}? This cannot be undone.")
107
+ if input("Continue? [y/N] ").strip().lower() not in {"y", "yes"}:
108
+ print("Aborted.", file=sys.stderr)
109
+ return EXIT_ABORTED
110
+
111
+ payload = client.delete(f"/challenges/{challenge_id}")
112
+ removed = payload.get("versions_removed", 0)
113
+ print(f"Deleted question {challenge_id} ({removed} version(s) removed).")
114
+ return EXIT_OK
@@ -40,6 +40,7 @@ def run(
40
40
  assume_yes: bool = False,
41
41
  live: bool = False,
42
42
  validation_run_id: str | None = None,
43
+ challenge_id: int | None = None,
43
44
  client: ApiClient | None = None,
44
45
  ) -> int:
45
46
  if not selector:
@@ -68,29 +69,36 @@ def run(
68
69
  run_id = _validate(pack, client, reporter)
69
70
 
70
71
  company = _company_name(client, config)
71
- if not _confirm(pack.name, company, live, assume_yes):
72
+ if not _confirm(pack.name, company, live, assume_yes, challenge_id):
72
73
  print("Aborted.", file=sys.stderr)
73
74
  return EXIT_ABORTED
74
75
 
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
- )
76
+ body = {
77
+ # No company_id: the server derives ownership from the API key.
78
+ "name": pack.name,
79
+ "validation_run_id": run_id,
80
+ "status": "published" if live else "draft",
81
+ }
82
+ # With an id, this publishes a new version of an existing question. Without
83
+ # one the platform creates a new question — which is why editing a pack and
84
+ # re-publishing used to leave a duplicate behind.
85
+ if challenge_id is not None:
86
+ body["challenge_id"] = challenge_id
87
+
88
+ payload = client.post_json("/challenges", body)
84
89
 
85
90
  challenge_id = payload.get("challenge_id")
86
91
  version_id = payload.get("challenge_version_id")
87
92
  container_url = payload.get("container_url")
88
93
  preview_error = payload.get("preview_error")
89
94
  state = "published" if live else "draft"
95
+ created = payload.get("created", True)
90
96
 
91
- print(f"{pack.name} {state} for {company}")
97
+ verb = "published" if created else "updated"
98
+ print(f"{pack.name} {verb} for {company} ({state})")
92
99
  if challenge_id:
93
- print(f" challenge {challenge_id}, version {version_id}")
100
+ label = "challenge" if created else "challenge (new version of)"
101
+ print(f" {label} {challenge_id}, version {version_id}")
94
102
  if container_url:
95
103
  print(f" {container_url}")
96
104
  elif preview_error:
@@ -129,7 +137,7 @@ def _company_name(client: ApiClient, config: RemoteConfig | None) -> str:
129
137
  return company.get("name") or cached or "your company"
130
138
 
131
139
 
132
- def _confirm(pack_name: str, company: str, live: bool, assume_yes: bool) -> bool:
140
+ def _confirm(pack_name: str, company: str, live: bool, assume_yes: bool, challenge_id: int | None = None) -> bool:
133
141
  if assume_yes:
134
142
  return True
135
143
  if not sys.stdin.isatty():
@@ -138,5 +146,6 @@ def _confirm(pack_name: str, company: str, live: bool, assume_yes: bool) -> bool
138
146
  "Pass --yes to publish non-interactively (CI)."
139
147
  )
140
148
  visibility = "LIVE to candidates" if live else "as a draft"
141
- print(f"\nAbout to publish {pack_name} to {company}, {visibility}.")
149
+ target = f"as a new version of question {challenge_id}" if challenge_id else "as a new question"
150
+ print(f"\nAbout to publish {pack_name} to {company} {target}, {visibility}.")
142
151
  return input("Continue? [y/N] ").strip().lower() in {"y", "yes"}
@@ -125,6 +125,9 @@ class ApiClient:
125
125
  content_type="application/json",
126
126
  )
127
127
 
128
+ def delete(self, path: str) -> dict[str, Any]:
129
+ return self.request("DELETE", path)
130
+
128
131
  def post_bytes(self, path: str, blob: bytes) -> dict[str, Any]:
129
132
  return self.request("POST", path, body=blob, content_type="application/zip")
130
133
 
@@ -227,3 +227,93 @@ def test_prints_container_url_when_platform_returns_one(tmp_path: Path, capsys):
227
227
  )
228
228
 
229
229
  assert "https://container.example" in capsys.readouterr().out
230
+
231
+
232
+ class TestRepublishingAnEdit:
233
+ """Editing a pack and publishing again must update, not duplicate.
234
+
235
+ Without a challenge_id the platform creates a new question every time, which
236
+ is how a typo fix used to leave two copies in the catalog.
237
+ """
238
+
239
+ def test_challenge_id_is_sent_when_given(self, tmp_path):
240
+ build_pack(tmp_path)
241
+ client = FakeClient()
242
+
243
+ publish.run(
244
+ root=tmp_path,
245
+ selector="demo",
246
+ reporter=NullReporter(),
247
+ assume_yes=True,
248
+ validation_run_id="vr_1",
249
+ challenge_id=64,
250
+ client=client,
251
+ )
252
+
253
+ assert client.posted[0][1]["challenge_id"] == 64
254
+
255
+ def test_no_challenge_id_means_create(self, tmp_path):
256
+ build_pack(tmp_path)
257
+ client = FakeClient()
258
+
259
+ publish.run(
260
+ root=tmp_path,
261
+ selector="demo",
262
+ reporter=NullReporter(),
263
+ assume_yes=True,
264
+ validation_run_id="vr_1",
265
+ client=client,
266
+ )
267
+
268
+ assert "challenge_id" not in client.posted[0][1]
269
+
270
+ def test_output_says_updated_when_the_platform_reused_a_question(self, tmp_path, capsys):
271
+ build_pack(tmp_path)
272
+ client = FakeClient()
273
+ client.post_json = lambda path, payload: {
274
+ "challenge_id": 64,
275
+ "challenge_version_id": 91,
276
+ "created": False,
277
+ }
278
+
279
+ publish.run(
280
+ root=tmp_path,
281
+ selector="demo",
282
+ reporter=NullReporter(),
283
+ assume_yes=True,
284
+ validation_run_id="vr_1",
285
+ challenge_id=64,
286
+ client=client,
287
+ )
288
+
289
+ output = capsys.readouterr().out
290
+ assert "updated" in output
291
+ assert "published for" not in output
292
+
293
+
294
+ class TestDelete:
295
+ class Client:
296
+ def __init__(self, payload=None):
297
+ self.deleted = []
298
+ self.payload = payload or {"challenge_id": 7, "versions_removed": 2}
299
+
300
+ def delete(self, path):
301
+ self.deleted.append(path)
302
+ return self.payload
303
+
304
+ def test_deletes_after_confirmation_is_waived(self, capsys):
305
+ from codepraxis.commands import catalog
306
+
307
+ client = self.Client()
308
+ assert catalog.delete_question(7, client=client, assume_yes=True) == 0
309
+ assert client.deleted == ["/challenges/7"]
310
+ assert "Deleted question 7" in capsys.readouterr().out
311
+
312
+ def test_refuses_without_a_terminal_and_without_yes(self):
313
+ from codepraxis.commands import catalog
314
+ from codepraxis.errors import PraxisError
315
+
316
+ client = self.Client()
317
+ with pytest.raises(PraxisError, match="not a terminal"):
318
+ catalog.delete_question(7, client=client)
319
+ assert client.deleted == []
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes