patchground 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 (194) hide show
  1. patchground/__init__.py +10 -0
  2. patchground/__main__.py +3 -0
  3. patchground/authoring/__init__.py +5 -0
  4. patchground/authoring/create.py +179 -0
  5. patchground/authoring/templates/python-debug/TICKET.md.tmpl +11 -0
  6. patchground/authoring/templates/python-debug/challenge.yaml.tmpl +21 -0
  7. patchground/authoring/templates/python-debug/checks/hidden/tests/test_starter_hidden.py.tmpl +13 -0
  8. patchground/authoring/templates/python-debug/checks/visible/tests/test_starter.py.tmpl +9 -0
  9. patchground/authoring/templates/python-debug/environment/Dockerfile.tmpl +3 -0
  10. patchground/authoring/templates/python-debug/solution/reference.patch.tmpl +8 -0
  11. patchground/authoring/templates/python-debug/workspace/pyproject.toml.tmpl +8 -0
  12. patchground/authoring/templates/python-debug/workspace/src/starter.py.tmpl +3 -0
  13. patchground/authoring/templates/python-feature/TICKET.md.tmpl +13 -0
  14. patchground/authoring/templates/python-feature/challenge.yaml.tmpl +22 -0
  15. patchground/authoring/templates/python-feature/checks/hidden/tests/test_starter_hidden.py.tmpl +13 -0
  16. patchground/authoring/templates/python-feature/checks/visible/tests/test_starter.py.tmpl +9 -0
  17. patchground/authoring/templates/python-feature/environment/Dockerfile.tmpl +3 -0
  18. patchground/authoring/templates/python-feature/solution/reference.patch.tmpl +9 -0
  19. patchground/authoring/templates/python-feature/workspace/pyproject.toml.tmpl +8 -0
  20. patchground/authoring/templates/python-feature/workspace/src/starter.py.tmpl +8 -0
  21. patchground/authoring/templates/typescript-debug/TICKET.md.tmpl +11 -0
  22. patchground/authoring/templates/typescript-debug/challenge.yaml.tmpl +21 -0
  23. patchground/authoring/templates/typescript-debug/checks/hidden/tests/starter-hidden.test.ts.tmpl +14 -0
  24. patchground/authoring/templates/typescript-debug/checks/visible/tests/starter.test.ts.tmpl +10 -0
  25. patchground/authoring/templates/typescript-debug/solution/reference.patch.tmpl +9 -0
  26. patchground/authoring/templates/typescript-debug/workspace/package.json.tmpl +6 -0
  27. patchground/authoring/templates/typescript-debug/workspace/src/starter.ts.tmpl +4 -0
  28. patchground/authoring/templates/typescript-feature/TICKET.md.tmpl +13 -0
  29. patchground/authoring/templates/typescript-feature/challenge.yaml.tmpl +22 -0
  30. patchground/authoring/templates/typescript-feature/checks/hidden/tests/starter-hidden.test.ts.tmpl +14 -0
  31. patchground/authoring/templates/typescript-feature/checks/visible/tests/starter.test.ts.tmpl +10 -0
  32. patchground/authoring/templates/typescript-feature/solution/reference.patch.tmpl +10 -0
  33. patchground/authoring/templates/typescript-feature/workspace/package.json.tmpl +6 -0
  34. patchground/authoring/templates/typescript-feature/workspace/src/starter.ts.tmpl +9 -0
  35. patchground/automation/__init__.py +5 -0
  36. patchground/automation/__main__.py +131 -0
  37. patchground/automation/workflow.py +214 -0
  38. patchground/catalog/README.md +10 -0
  39. patchground/catalog/cache-expiry/TICKET.md +49 -0
  40. patchground/catalog/cache-expiry/challenge.yaml +32 -0
  41. patchground/catalog/cache-expiry/checks/hidden/tests/test_cache_hidden.py +164 -0
  42. patchground/catalog/cache-expiry/checks/visible/tests/test_cache.py +56 -0
  43. patchground/catalog/cache-expiry/debrief/questions.md +4 -0
  44. patchground/catalog/cache-expiry/environment/Dockerfile +3 -0
  45. patchground/catalog/cache-expiry/solution/reference.patch +41 -0
  46. patchground/catalog/cache-expiry/workspace/pyproject.toml +8 -0
  47. patchground/catalog/cache-expiry/workspace/src/profilecache/__init__.py +6 -0
  48. patchground/catalog/cache-expiry/workspace/src/profilecache/cache.py +67 -0
  49. patchground/catalog/cache-expiry/workspace/src/profilecache/client.py +38 -0
  50. patchground/catalog/csv-import-row-errors/TICKET.md +97 -0
  51. patchground/catalog/csv-import-row-errors/challenge.yaml +33 -0
  52. patchground/catalog/csv-import-row-errors/checks/hidden/tests/test_import_hidden.py +359 -0
  53. patchground/catalog/csv-import-row-errors/checks/visible/tests/test_import.py +34 -0
  54. patchground/catalog/csv-import-row-errors/debrief/questions.md +5 -0
  55. patchground/catalog/csv-import-row-errors/environment/Dockerfile +3 -0
  56. patchground/catalog/csv-import-row-errors/solution/reference.patch +74 -0
  57. patchground/catalog/csv-import-row-errors/workspace/pyproject.toml +8 -0
  58. patchground/catalog/csv-import-row-errors/workspace/src/stockimport/__init__.py +4 -0
  59. patchground/catalog/csv-import-row-errors/workspace/src/stockimport/importer.py +69 -0
  60. patchground/catalog/csv-import-row-errors/workspace/src/stockimport/models.py +32 -0
  61. patchground/catalog/duplicate-charges-on-retry/TICKET.md +28 -0
  62. patchground/catalog/duplicate-charges-on-retry/challenge.yaml +30 -0
  63. patchground/catalog/duplicate-charges-on-retry/checks/hidden/tests/test_payment_regressions.py +87 -0
  64. patchground/catalog/duplicate-charges-on-retry/checks/visible/tests/test_checkout.py +34 -0
  65. patchground/catalog/duplicate-charges-on-retry/debrief/questions.md +6 -0
  66. patchground/catalog/duplicate-charges-on-retry/environment/Dockerfile +3 -0
  67. patchground/catalog/duplicate-charges-on-retry/solution/reference.patch +11 -0
  68. patchground/catalog/duplicate-charges-on-retry/workspace/pyproject.toml +8 -0
  69. patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/__init__.py +1 -0
  70. patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/gateway.py +38 -0
  71. patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/models.py +19 -0
  72. patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/service.py +23 -0
  73. patchground/catalog/event-bus-subscriptions/TICKET.md +108 -0
  74. patchground/catalog/event-bus-subscriptions/challenge.yaml +33 -0
  75. patchground/catalog/event-bus-subscriptions/checks/hidden/tests/event-bus-contract.test.ts +566 -0
  76. patchground/catalog/event-bus-subscriptions/checks/visible/tests/event-bus.test.ts +70 -0
  77. patchground/catalog/event-bus-subscriptions/debrief/questions.md +5 -0
  78. patchground/catalog/event-bus-subscriptions/environment/Dockerfile +7 -0
  79. patchground/catalog/event-bus-subscriptions/environment/package-lock.json +1054 -0
  80. patchground/catalog/event-bus-subscriptions/environment/package.json +13 -0
  81. patchground/catalog/event-bus-subscriptions/environment/vitest-runner.mjs +124 -0
  82. patchground/catalog/event-bus-subscriptions/solution/reference.patch +82 -0
  83. patchground/catalog/event-bus-subscriptions/workspace/package.json +6 -0
  84. patchground/catalog/event-bus-subscriptions/workspace/src/bus.ts +32 -0
  85. patchground/catalog/order-notifications-boundary/TICKET.md +51 -0
  86. patchground/catalog/order-notifications-boundary/challenge.yaml +32 -0
  87. patchground/catalog/order-notifications-boundary/checks/hidden/tests/test_boundary_hidden.py +87 -0
  88. patchground/catalog/order-notifications-boundary/checks/visible/tests/test_confirmation.py +41 -0
  89. patchground/catalog/order-notifications-boundary/debrief/questions.md +5 -0
  90. patchground/catalog/order-notifications-boundary/environment/Dockerfile +3 -0
  91. patchground/catalog/order-notifications-boundary/solution/reference.patch +77 -0
  92. patchground/catalog/order-notifications-boundary/workspace/pyproject.toml +8 -0
  93. patchground/catalog/order-notifications-boundary/workspace/src/orders/__init__.py +6 -0
  94. patchground/catalog/order-notifications-boundary/workspace/src/orders/app.py +9 -0
  95. patchground/catalog/order-notifications-boundary/workspace/src/orders/models.py +25 -0
  96. patchground/catalog/order-notifications-boundary/workspace/src/orders/service.py +31 -0
  97. patchground/catalog/order-notifications-boundary/workspace/src/orders/smtp_gateway.py +19 -0
  98. patchground/catalog/order-notifications-boundary/workspace/src/orders/sqlite_store.py +44 -0
  99. patchground/catalog/pagination-last-page/TICKET.md +44 -0
  100. patchground/catalog/pagination-last-page/challenge.yaml +31 -0
  101. patchground/catalog/pagination-last-page/checks/hidden/tests/test_pagination_hidden.py +32 -0
  102. patchground/catalog/pagination-last-page/checks/visible/tests/test_pagination.py +37 -0
  103. patchground/catalog/pagination-last-page/debrief/questions.md +9 -0
  104. patchground/catalog/pagination-last-page/environment/Dockerfile +3 -0
  105. patchground/catalog/pagination-last-page/solution/reference.patch +22 -0
  106. patchground/catalog/pagination-last-page/workspace/pyproject.toml +8 -0
  107. patchground/catalog/pagination-last-page/workspace/src/catalog/__init__.py +1 -0
  108. patchground/catalog/pagination-last-page/workspace/src/catalog/pagination.py +52 -0
  109. patchground/catalog/release-version-ordering/TICKET.md +72 -0
  110. patchground/catalog/release-version-ordering/challenge.yaml +31 -0
  111. patchground/catalog/release-version-ordering/checks/hidden/tests/releasekit_contract.test.ts +92 -0
  112. patchground/catalog/release-version-ordering/checks/visible/tests/releasekit.test.ts +31 -0
  113. patchground/catalog/release-version-ordering/debrief/questions.md +9 -0
  114. patchground/catalog/release-version-ordering/environment/Dockerfile +7 -0
  115. patchground/catalog/release-version-ordering/environment/package-lock.json +1054 -0
  116. patchground/catalog/release-version-ordering/environment/package.json +13 -0
  117. patchground/catalog/release-version-ordering/environment/vitest-runner.mjs +124 -0
  118. patchground/catalog/release-version-ordering/solution/reference.patch +36 -0
  119. patchground/catalog/release-version-ordering/workspace/README.md +7 -0
  120. patchground/catalog/release-version-ordering/workspace/src/releasekit/index.ts +2 -0
  121. patchground/catalog/release-version-ordering/workspace/src/releasekit/ordering.ts +41 -0
  122. patchground/catalog/release-version-ordering/workspace/src/releasekit/parse.ts +24 -0
  123. patchground/cli/__init__.py +1 -0
  124. patchground/cli/app.py +59 -0
  125. patchground/cli/authoring.py +41 -0
  126. patchground/cli/consent.py +77 -0
  127. patchground/cli/discovery.py +189 -0
  128. patchground/cli/doctor.py +249 -0
  129. patchground/cli/packs.py +142 -0
  130. patchground/cli/practice.py +141 -0
  131. patchground/cli/render.py +58 -0
  132. patchground/cli/reports.py +53 -0
  133. patchground/cli/sessions.py +195 -0
  134. patchground/cli/validate.py +117 -0
  135. patchground/packs/__init__.py +23 -0
  136. patchground/packs/builtin.py +53 -0
  137. patchground/packs/discovery.py +285 -0
  138. patchground/packs/execution.py +420 -0
  139. patchground/packs/models.py +98 -0
  140. patchground/packs/registry.py +318 -0
  141. patchground/packs/source.py +230 -0
  142. patchground/packs/store.py +420 -0
  143. patchground/py.typed +0 -0
  144. patchground/report/__init__.py +6 -0
  145. patchground/report/analysis.py +188 -0
  146. patchground/report/models.py +59 -0
  147. patchground/report/regression.py +597 -0
  148. patchground/report/render.py +92 -0
  149. patchground/report/workflow.py +84 -0
  150. patchground/runner_assets/Dockerfile +7 -0
  151. patchground/runner_assets/__init__.py +16 -0
  152. patchground/runner_assets/package-lock.json +1054 -0
  153. patchground/runner_assets/package.json +13 -0
  154. patchground/runner_assets/vitest-runner.mjs +124 -0
  155. patchground/safeio/__init__.py +115 -0
  156. patchground/sandbox/__init__.py +39 -0
  157. patchground/sandbox/_engine.py +23 -0
  158. patchground/sandbox/build.py +202 -0
  159. patchground/sandbox/errors.py +23 -0
  160. patchground/sandbox/junit.py +76 -0
  161. patchground/sandbox/probe.py +118 -0
  162. patchground/sandbox/run.py +535 -0
  163. patchground/sessions/__init__.py +44 -0
  164. patchground/sessions/diagnostics.py +145 -0
  165. patchground/sessions/files.py +113 -0
  166. patchground/sessions/git.py +70 -0
  167. patchground/sessions/history.py +386 -0
  168. patchground/sessions/history_ignore.py +279 -0
  169. patchground/sessions/models.py +126 -0
  170. patchground/sessions/practice.py +202 -0
  171. patchground/sessions/reset.py +121 -0
  172. patchground/sessions/store.py +203 -0
  173. patchground/sessions/submission.py +344 -0
  174. patchground/sessions/workflow.py +297 -0
  175. patchground/spec/__init__.py +35 -0
  176. patchground/spec/_schema.py +53 -0
  177. patchground/spec/loader.py +713 -0
  178. patchground/spec/models.py +394 -0
  179. patchground/spec/schema.py +50 -0
  180. patchground/verify/__init__.py +29 -0
  181. patchground/verify/errors.py +10 -0
  182. patchground/verify/pytest_adapter.py +95 -0
  183. patchground/verify/record.py +94 -0
  184. patchground/verify/reference.py +158 -0
  185. patchground/verify/result.py +199 -0
  186. patchground/verify/runners.py +47 -0
  187. patchground/verify/tampering.py +106 -0
  188. patchground/verify/validate.py +686 -0
  189. patchground/verify/vitest_adapter.py +252 -0
  190. patchground-0.1.0.dist-info/METADATA +258 -0
  191. patchground-0.1.0.dist-info/RECORD +194 -0
  192. patchground-0.1.0.dist-info/WHEEL +4 -0
  193. patchground-0.1.0.dist-info/entry_points.txt +3 -0
  194. patchground-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "patchground-vitest-runtime",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "dependencies": {
7
+ "vitest": "5.0.5",
8
+ "vite": "8.3.5"
9
+ },
10
+ "scripts": {
11
+ "test": "node --check vitest-runner.mjs"
12
+ }
13
+ }
@@ -0,0 +1,124 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { mkdtemp, readFile, writeFile, rename, rm, lstat, realpath } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const root = '/workspace';
8
+ const final = '/results/junit.xml';
9
+ const self = fileURLToPath(import.meta.url);
10
+
11
+ async function child(destination, files) {
12
+ const { startVitest } = await import('vitest/node');
13
+ let completed;
14
+ const reporter = {
15
+ onTestModuleCollected(module) {
16
+ for (const test of module.children.allTests()) {
17
+ const retry = test.options.retry;
18
+ const count = typeof retry === 'number' ? retry : retry?.count ?? 0;
19
+ if (count > 0 || (test.options.repeats ?? 0) > 0) {
20
+ throw Error('Retries and repeats are unsupported');
21
+ }
22
+ }
23
+ },
24
+ onTestRunEnd(modules, errors, reason) {
25
+ const cases = [];
26
+ let trustworthy = errors.length === 0 && reason !== 'interrupted';
27
+ for (const module of modules) {
28
+ if (module.errors().length) trustworthy = false;
29
+ for (const suite of module.children.allSuites()) {
30
+ if (suite.errors().length) trustworthy = false;
31
+ }
32
+ for (const test of module.children.allTests()) {
33
+ const state = test.result().state;
34
+ if (!['passed', 'failed', 'skipped'].includes(state)) trustworthy = false;
35
+ cases.push({ file: module.relativeModuleId, name: test.fullName, state });
36
+ }
37
+ }
38
+ completed = { trustworthy, cases };
39
+ },
40
+ };
41
+ const ctx = await startVitest([], {
42
+ root, config: false, watch: false, run: true,
43
+ include: files.length ? files : ['**/*.test.ts'], exclude: ['**/node_modules/**', '**/.git/**'],
44
+ reporters: [reporter], environment: 'node', pool: 'forks', maxWorkers: 1,
45
+ fileParallelism: false, isolate: true, retry: 0, allowOnly: false, maxConcurrency: 1,
46
+ passWithNoTests: false, testTimeout: 5000, hookTimeout: 5000,
47
+ sequence: { shuffle: false },
48
+ }, {
49
+ configFile: false, envDir: false, cacheDir: '/tmp/patchground-vite',
50
+ resolve: { alias: { vitest: '/opt/patchground/node_modules/vitest/dist/index.js' } },
51
+ });
52
+ await ctx.close();
53
+ if (!completed || ctx.state.getUnhandledErrors().length) return;
54
+ await writeFile(destination, JSON.stringify(completed), { flag: 'wx' });
55
+ }
56
+
57
+ function escape(value) {
58
+ return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
59
+ .replaceAll('"', '&quot;').replaceAll("'", '&apos;').replaceAll("\n", "&#10;")
60
+ .replaceAll("\r", "&#13;").replaceAll("\t", "&#9;");
61
+ }
62
+
63
+ async function parent() {
64
+ await rm(final, { force: true });
65
+ const temporary = await mkdtemp(path.join(tmpdir(), 'patchground-vitest-'));
66
+ try {
67
+ const receipt = path.join(temporary, 'receipt.json');
68
+ const args = process.argv.slice(2);
69
+ const files = args.length ? args.slice(1) : [];
70
+ if (args.length && (args[0] !== '--files' || !files.length)) return;
71
+ for (const file of files) {
72
+ const parts = file.split('/');
73
+ if (!file.endsWith('.test.ts') || parts.some(part =>
74
+ !/^[\p{L}\p{N}_. -]+$/u.test(part) || ['.', '..', '.git', 'node_modules'].includes(part))) return;
75
+ }
76
+ if (new Set(files).size !== files.length) return;
77
+ for (const file of files) {
78
+ const full = path.join(root, file);
79
+ if (!(await lstat(full)).isFile() || !(await realpath(full)).startsWith(root + '/')) return;
80
+ }
81
+ const code = await new Promise((resolve, reject) => {
82
+ const process = spawn(globalThis.process.execPath, [self, '--child', receipt, ...files], {
83
+ stdio: 'ignore', env: { ...globalThis.process.env, CI: 'true' },
84
+ });
85
+ process.once('error', reject);
86
+ process.once('exit', (code, signal) => resolve(signal ? null : code));
87
+ });
88
+ const result = JSON.parse(await readFile(receipt, 'utf8'));
89
+ if (!result.trustworthy || !result.cases.length) return;
90
+ const failed = result.cases.some(test => test.state === 'failed');
91
+ if (code !== (failed ? 1 : 0)) return;
92
+ const seen = new Set();
93
+ result.cases.sort((a, b) => {
94
+ const left = `${a.file}::${a.name}`;
95
+ const right = `${b.file}::${b.name}`;
96
+ return left < right ? -1 : left > right ? 1 : 0;
97
+ });
98
+ const cases = result.cases.map(test => {
99
+ if (!test.file || path.posix.isAbsolute(test.file) || test.file.split('/').includes('..')
100
+ || test.file.includes('\\') || !test.file.endsWith('.test.ts') || !test.name
101
+ || !test.file.isWellFormed() || !test.name.isWellFormed()
102
+ || /[\u0000-\u0008\u000b\u000c\u000e-\u001f\ufffe\uffff]/u.test(test.file + test.name)) throw Error('unsafe identity');
103
+ const id = `${test.file}::${test.name}`;
104
+ if (seen.has(id)) throw Error('duplicate identity');
105
+ seen.add(id);
106
+ const status = test.state === 'failed' ? '<failure message="test failed"/>'
107
+ : test.state === 'skipped' ? '<skipped/>' : '';
108
+ return `<testcase classname="${escape(test.file)}" name="${escape(test.name)}">${status}</testcase>`;
109
+ });
110
+ const staging = '/results/.patchground-junit.xml';
111
+ await writeFile(staging, `<?xml version="1.0" encoding="UTF-8"?><testsuites><testsuite name="vitest">${cases.join('')}</testsuite></testsuites>`);
112
+ await rename(staging, final);
113
+ process.exitCode = failed ? 1 : 0;
114
+ } finally {
115
+ await rm(temporary, { recursive: true, force: true });
116
+ }
117
+ }
118
+
119
+ try {
120
+ if (process.argv[2] === '--child') await child(process.argv[3], process.argv.slice(4));
121
+ else { process.exitCode = 1; await parent(); }
122
+ } catch {
123
+ process.exitCode = 1;
124
+ }
@@ -0,0 +1,82 @@
1
+ diff --git a/src/bus.ts b/src/bus.ts
2
+ index 60d54f7..86d7dec 100644
3
+ --- a/src/bus.ts
4
+ +++ b/src/bus.ts
5
+ @@ -3,30 +3,65 @@
6
+ export type Handler = (payload: unknown) => void;
7
+ export type Unsubscribe = () => void;
8
+
9
+ +interface Subscription {
10
+ + readonly handler: Handler;
11
+ + readonly once: boolean;
12
+ + consumed: boolean;
13
+ +}
14
+ +
15
+ export class EventBus {
16
+ - private readonly handlers = new Map<string, Handler[]>();
17
+ + private readonly subscriptions = new Map<string, Subscription[]>();
18
+
19
+ /** Register `handler` for exactly `topic`. Handlers run in registration order. */
20
+ on(topic: string, handler: Handler): Unsubscribe {
21
+ - const list = this.handlers.get(topic) ?? [];
22
+ - list.push(handler);
23
+ - this.handlers.set(topic, list);
24
+ - // Not implemented yet: there is no way to take a subscription back.
25
+ - return () => {};
26
+ + return this.subscribe(topic, handler, false);
27
+ }
28
+
29
+ - /** Not implemented yet. */
30
+ - once(_topic: string, _handler: Handler): Unsubscribe {
31
+ - throw new Error("EventBus.once is not implemented");
32
+ + /** Like `on`, but the handler is consumed immediately before its first call. */
33
+ + once(topic: string, handler: Handler): Unsubscribe {
34
+ + return this.subscribe(topic, handler, true);
35
+ }
36
+
37
+ - /** Call every handler registered for `topic` and return how many were called. */
38
+ + /**
39
+ + * Call the subscriptions that exist when the emit starts and return how many handlers were called.
40
+ + * Throws an AggregateError after the last turn if any handler threw.
41
+ + */
42
+ emit(topic: string, payload?: unknown): number {
43
+ + const captured = [...(this.subscriptions.get(topic) ?? [])];
44
+ + const errors: unknown[] = [];
45
+ let called = 0;
46
+ - for (const handler of this.handlers.get(topic) ?? []) {
47
+ - handler(payload);
48
+ + for (const subscription of captured) {
49
+ + if (subscription.once) {
50
+ + if (subscription.consumed) continue;
51
+ + subscription.consumed = true;
52
+ + this.remove(topic, subscription);
53
+ + }
54
+ called += 1;
55
+ + try {
56
+ + subscription.handler(payload);
57
+ + } catch (error) {
58
+ + errors.push(error);
59
+ + }
60
+ + }
61
+ + if (errors.length > 0) {
62
+ + throw new AggregateError(errors, `${errors.length} handler(s) threw while emitting "${topic}"`);
63
+ }
64
+ return called;
65
+ }
66
+ +
67
+ + private subscribe(topic: string, handler: Handler, once: boolean): Unsubscribe {
68
+ + const subscription: Subscription = { handler, once, consumed: false };
69
+ + const list = this.subscriptions.get(topic) ?? [];
70
+ + list.push(subscription);
71
+ + this.subscriptions.set(topic, list);
72
+ + return () => this.remove(topic, subscription);
73
+ + }
74
+ +
75
+ + private remove(topic: string, subscription: Subscription): void {
76
+ + const list = this.subscriptions.get(topic);
77
+ + const index = list === undefined ? -1 : list.indexOf(subscription);
78
+ + if (list === undefined || index === -1) return;
79
+ + list.splice(index, 1);
80
+ + if (list.length === 0) this.subscriptions.delete(topic);
81
+ + }
82
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "localbus",
3
+ "version": "0.2.0",
4
+ "private": true,
5
+ "type": "module"
6
+ }
@@ -0,0 +1,32 @@
1
+ /** A small synchronous in-process event bus used by a command-line tool. */
2
+
3
+ export type Handler = (payload: unknown) => void;
4
+ export type Unsubscribe = () => void;
5
+
6
+ export class EventBus {
7
+ private readonly handlers = new Map<string, Handler[]>();
8
+
9
+ /** Register `handler` for exactly `topic`. Handlers run in registration order. */
10
+ on(topic: string, handler: Handler): Unsubscribe {
11
+ const list = this.handlers.get(topic) ?? [];
12
+ list.push(handler);
13
+ this.handlers.set(topic, list);
14
+ // Not implemented yet: there is no way to take a subscription back.
15
+ return () => {};
16
+ }
17
+
18
+ /** Not implemented yet. */
19
+ once(_topic: string, _handler: Handler): Unsubscribe {
20
+ throw new Error("EventBus.once is not implemented");
21
+ }
22
+
23
+ /** Call every handler registered for `topic` and return how many were called. */
24
+ emit(topic: string, payload?: unknown): number {
25
+ let called = 0;
26
+ for (const handler of this.handlers.get(topic) ?? []) {
27
+ handler(payload);
28
+ called += 1;
29
+ }
30
+ return called;
31
+ }
32
+ }
@@ -0,0 +1,51 @@
1
+ # Order confirmations can't get an SMS channel or a fast test suite
2
+
3
+ **Reported by:** the notifications team and the storefront on-call rota
4
+ **Component:** `orders`
5
+
6
+ ## Symptom
7
+
8
+ Two pieces of work are stuck on the same module:
9
+
10
+ 1. Marketing wants confirmations by SMS as well as email. The SMS provider's client is ready, but
11
+ there is nowhere to plug it in: `orders/service.py` imports `SmtpNotifier` and constructs it
12
+ inside `confirm`.
13
+ 2. The checkout team's tests take minutes because every `OrderService` test writes a SQLite file
14
+ and an outbox file. They asked for an in-memory setup and were told "the service builds its own
15
+ store, there's nothing to swap".
16
+
17
+ Both teams have proposed copy-pasting `OrderService`. We would rather fix the shape of the module.
18
+
19
+ ## Reproduction
20
+
21
+ ```python
22
+ from orders.service import OrderService
23
+
24
+ OrderService("orders.db", "outbox.jsonl") # the only way to build one
25
+ ```
26
+
27
+ Open `src/orders/service.py`: it imports `orders.sqlite_store` and `orders.smtp_gateway` directly
28
+ and instantiates both on every call.
29
+
30
+ ## Expected
31
+
32
+ - `src/orders/ports.py` defines two `typing.Protocol`s, decorated `@runtime_checkable`:
33
+ `OrderRepository` (`save_confirmed(order)`, `get(order_id)`) and `Notifier`
34
+ (`send(to, subject, body)`).
35
+ - `OrderService` takes its collaborators by keyword: `OrderService(repository=..., notifier=...)`.
36
+ It no longer accepts paths and no longer imports `orders.sqlite_store`, `orders.smtp_gateway`,
37
+ `sqlite3` or `smtplib`.
38
+ - `SqliteOrderRepository` and `SmtpNotifier` keep their names and behaviour and satisfy the ports.
39
+ - `orders.app.build_service(db_path, outbox)` is the one place that wires the real adapters, so
40
+ the existing end-to-end tests keep passing unchanged.
41
+ - The confirmation message format, the empty-order rejection and `find` behave exactly as before.
42
+
43
+ ## Notes
44
+
45
+ - This is a refactor: no behaviour change is wanted. Visible tests go through `build_service` and
46
+ must stay green throughout.
47
+ - Add a test that builds `OrderService` with in-memory fakes and checks a confirmation end to end,
48
+ without touching the filesystem.
49
+ - Run `pgnd check` from the session workspace to check your changes in Docker.
50
+ - You may use your own editor and AI tools. The goal is a module whose boundaries are explicit,
51
+ not merely tests that pass.
@@ -0,0 +1,32 @@
1
+ spec_version: "0.1"
2
+ id: order-notifications-boundary
3
+ version: 1.0.0
4
+ title: Put a port between the order service and its storage and email
5
+ type: refactor
6
+ summary: >-
7
+ OrderService imports and constructs SQLite and SMTP adapters itself, so nothing can be swapped
8
+ for SMS or for fast tests. Introduce ports, inject the adapters and keep behaviour identical.
9
+ languages: [python]
10
+ tags: [architecture, ports-and-adapters, dependency-inversion, refactoring]
11
+ difficulty: intermediate
12
+ timebox_minutes: 40
13
+ ai_policy: allowed
14
+ environment:
15
+ dockerfile: environment/Dockerfile
16
+ services: []
17
+ test_command: pytest -q -p no:cacheprovider --junitxml=$PG_RESULTS/junit.xml
18
+ checks:
19
+ visible: checks/visible
20
+ hidden: checks/hidden
21
+ solution: solution/reference.patch
22
+ expected_paths: [src/orders/, tests/]
23
+ hints:
24
+ - Draw the arrows. Today service -> sqlite_store and service -> smtp_gateway. Which module should own the interface the service calls, so that the arrows point towards the service instead?
25
+ - A Protocol describes what the service needs; the adapters already have those methods. Make the service receive them instead of building them, and move the construction to app.build_service.
26
+ debrief:
27
+ questions: debrief/questions.md
28
+ focus: [design_judgment, test_quality, scope_judgment]
29
+ author: {name: Fran Delicado, github: franndeli}
30
+ content_license: CC-BY-4.0
31
+ provenance: generated
32
+ generated_by: {model: "Claude (Anthropic) via pi coding agent, model id unverified", prompt_version: launch-boundary-v1}
@@ -0,0 +1,87 @@
1
+ """Architecture checks: the service depends on ports, not on SQLite or SMTP."""
2
+
3
+ import ast
4
+ import importlib
5
+ import inspect
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ from orders import Order, OrderLine
11
+
12
+ SRC = Path(__file__).resolve().parents[1] / "src" / "orders"
13
+ INFRASTRUCTURE = {"orders.sqlite_store", "orders.smtp_gateway", "sqlite3", "smtplib"}
14
+
15
+
16
+ def imported_modules(path: Path) -> set[str]:
17
+ tree = ast.parse(path.read_text(encoding="utf-8"))
18
+ found: set[str] = set()
19
+ for node in ast.walk(tree):
20
+ if isinstance(node, ast.Import):
21
+ found.update(alias.name for alias in node.names)
22
+ elif isinstance(node, ast.ImportFrom) and node.module:
23
+ found.add(node.module)
24
+ return found
25
+
26
+
27
+ def test_service_module_has_no_infrastructure_imports():
28
+ assert not imported_modules(SRC / "service.py") & INFRASTRUCTURE
29
+
30
+
31
+ def test_ports_module_defines_runtime_checkable_protocols():
32
+ ports = importlib.import_module("orders.ports")
33
+ for name in ("OrderRepository", "Notifier"):
34
+ port = getattr(ports, name)
35
+ assert getattr(port, "_is_protocol", False), f"{name} must be a typing.Protocol"
36
+ assert getattr(port, "_is_runtime_protocol", False), f"{name} must be @runtime_checkable"
37
+
38
+
39
+ def test_adapters_satisfy_their_ports(tmp_path):
40
+ ports = importlib.import_module("orders.ports")
41
+ from orders.smtp_gateway import SmtpNotifier
42
+ from orders.sqlite_store import SqliteOrderRepository
43
+
44
+ assert isinstance(SqliteOrderRepository(tmp_path / "db"), ports.OrderRepository)
45
+ assert isinstance(SmtpNotifier(tmp_path / "out"), ports.Notifier)
46
+
47
+
48
+ class MemoryRepository:
49
+ def __init__(self):
50
+ self.saved = {}
51
+
52
+ def save_confirmed(self, order):
53
+ self.saved[order.order_id] = order
54
+
55
+ def get(self, order_id):
56
+ return self.saved.get(order_id)
57
+
58
+
59
+ class MemoryNotifier:
60
+ def __init__(self):
61
+ self.sent = []
62
+
63
+ def send(self, to, subject, body):
64
+ self.sent.append((to, subject, body))
65
+
66
+
67
+ def test_service_takes_injected_ports_by_keyword():
68
+ from orders.service import OrderService
69
+
70
+ parameters = inspect.signature(OrderService).parameters
71
+ assert {"repository", "notifier"} <= set(parameters)
72
+ repository, notifier = MemoryRepository(), MemoryNotifier()
73
+ service = OrderService(repository=repository, notifier=notifier)
74
+ order = Order("o-9", "grace@example.test", (OrderLine("desk", 1, 20000),))
75
+ service.confirm(order)
76
+ assert repository.saved == {"o-9": order}
77
+ assert notifier.sent == [
78
+ ("grace@example.test", "Order o-9 confirmed", "Thanks for your order.\n- 1 x desk @ 200.00\nTotal: 200.00")
79
+ ]
80
+ assert service.find("o-9") == order
81
+
82
+
83
+ def test_service_does_not_construct_adapters_itself():
84
+ from orders.service import OrderService
85
+
86
+ with pytest.raises(TypeError):
87
+ OrderService("orders.db", "outbox.jsonl")
@@ -0,0 +1,41 @@
1
+ import json
2
+
3
+ from orders import Order, OrderLine
4
+ from orders.app import build_service
5
+
6
+
7
+ def order(order_id="o-1"):
8
+ return Order(order_id, "ada@example.test", (OrderLine("chair", 2, 4999), OrderLine("lamp", 1, 1500)))
9
+
10
+
11
+ def test_confirm_persists_and_emails(tmp_path):
12
+ outbox = tmp_path / "outbox.jsonl"
13
+ service = build_service(str(tmp_path / "orders.db"), str(outbox))
14
+ service.confirm(order())
15
+ assert service.find("o-1") == order()
16
+ sent = [json.loads(line) for line in outbox.read_text().splitlines()]
17
+ assert sent == [
18
+ {
19
+ "to": "ada@example.test",
20
+ "subject": "Order o-1 confirmed",
21
+ "body": "Thanks for your order.\n- 2 x chair @ 49.99\n- 1 x lamp @ 15.00\nTotal: 114.98",
22
+ }
23
+ ]
24
+
25
+
26
+ def test_empty_order_is_rejected_before_any_side_effect(tmp_path):
27
+ outbox = tmp_path / "outbox.jsonl"
28
+ service = build_service(str(tmp_path / "orders.db"), str(outbox))
29
+ try:
30
+ service.confirm(Order("o-2", "ada@example.test", ()))
31
+ except ValueError:
32
+ pass
33
+ else:
34
+ raise AssertionError("expected ValueError")
35
+ assert service.find("o-2") is None
36
+ assert not outbox.exists()
37
+
38
+
39
+ def test_unknown_order_is_none(tmp_path):
40
+ service = build_service(str(tmp_path / "orders.db"), str(tmp_path / "outbox.jsonl"))
41
+ assert service.find("missing") is None
@@ -0,0 +1,5 @@
1
+ - Which dependency direction did you reverse, and what now knows about SQLite and SMTP?
2
+ - Why do the protocols belong to the service's side of the boundary rather than next to the adapters?
3
+ - What did you keep unchanged on purpose, and how did the visible tests help you prove it?
4
+ - Where would an SMS notifier go, and what, if anything, in `service.py` would have to change?
5
+ - If an AI assistant proposed the refactor, what did you verify yourself before accepting it?
@@ -0,0 +1,3 @@
1
+ FROM python:3.12-slim
2
+ ENV PYTHONDONTWRITEBYTECODE=1
3
+ RUN pip install --no-cache-dir pytest==8.3.5
@@ -0,0 +1,77 @@
1
+ diff --git a/src/orders/app.py b/src/orders/app.py
2
+ index 5ffe407..f814423 100644
3
+ --- a/src/orders/app.py
4
+ +++ b/src/orders/app.py
5
+ @@ -3,7 +3,9 @@
6
+ from __future__ import annotations
7
+
8
+ from orders.service import OrderService
9
+ +from orders.smtp_gateway import SmtpNotifier
10
+ +from orders.sqlite_store import SqliteOrderRepository
11
+
12
+
13
+ def build_service(db_path: str, outbox: str) -> OrderService:
14
+ - return OrderService(db_path, outbox)
15
+ + return OrderService(repository=SqliteOrderRepository(db_path), notifier=SmtpNotifier(outbox))
16
+ diff --git a/src/orders/ports.py b/src/orders/ports.py
17
+ new file mode 100644
18
+ index 0000000..437915e
19
+ --- /dev/null
20
+ +++ b/src/orders/ports.py
21
+ @@ -0,0 +1,19 @@
22
+ +"""What the order service needs from the outside world, as protocols."""
23
+ +
24
+ +from __future__ import annotations
25
+ +
26
+ +from typing import Protocol, runtime_checkable
27
+ +
28
+ +from orders.models import Order
29
+ +
30
+ +
31
+ +@runtime_checkable
32
+ +class OrderRepository(Protocol):
33
+ + def save_confirmed(self, order: Order) -> None: ...
34
+ +
35
+ + def get(self, order_id: str) -> Order | None: ...
36
+ +
37
+ +
38
+ +@runtime_checkable
39
+ +class Notifier(Protocol):
40
+ + def send(self, to: str, subject: str, body: str) -> None: ...
41
+ diff --git a/src/orders/service.py b/src/orders/service.py
42
+ index 91267c6..d5446de 100644
43
+ --- a/src/orders/service.py
44
+ +++ b/src/orders/service.py
45
+ @@ -3,8 +3,7 @@
46
+ from __future__ import annotations
47
+
48
+ from orders.models import Order
49
+ -from orders.smtp_gateway import SmtpNotifier
50
+ -from orders.sqlite_store import SqliteOrderRepository
51
+ +from orders.ports import Notifier, OrderRepository
52
+
53
+
54
+ def format_confirmation(order: Order) -> tuple[str, str]:
55
+ @@ -16,16 +15,16 @@ def format_confirmation(order: Order) -> tuple[str, str]:
56
+
57
+
58
+ class OrderService:
59
+ - def __init__(self, db_path: str, outbox: str) -> None:
60
+ - self._db_path = db_path
61
+ - self._outbox = outbox
62
+ + def __init__(self, *, repository: OrderRepository, notifier: Notifier) -> None:
63
+ + self._repository = repository
64
+ + self._notifier = notifier
65
+
66
+ def confirm(self, order: Order) -> None:
67
+ if not order.lines:
68
+ raise ValueError("an order needs at least one line")
69
+ - SqliteOrderRepository(self._db_path).save_confirmed(order)
70
+ + self._repository.save_confirmed(order)
71
+ subject, body = format_confirmation(order)
72
+ - SmtpNotifier(self._outbox).send(order.customer_email, subject, body)
73
+ + self._notifier.send(order.customer_email, subject, body)
74
+
75
+ def find(self, order_id: str) -> Order | None:
76
+ - return SqliteOrderRepository(self._db_path).get(order_id)
77
+ + return self._repository.get(order_id)
@@ -0,0 +1,8 @@
1
+ [project]
2
+ name = "orders"
3
+ version = "0.4.0"
4
+ requires-python = ">=3.12"
5
+
6
+ [tool.pytest.ini_options]
7
+ pythonpath = ["src"]
8
+ testpaths = ["tests"]
@@ -0,0 +1,6 @@
1
+ """Order confirmation for the storefront."""
2
+
3
+ from orders.models import Order, OrderLine
4
+ from orders.service import OrderService
5
+
6
+ __all__ = ["Order", "OrderLine", "OrderService"]
@@ -0,0 +1,9 @@
1
+ """Composition root used by the CLI and the tests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from orders.service import OrderService
6
+
7
+
8
+ def build_service(db_path: str, outbox: str) -> OrderService:
9
+ return OrderService(db_path, outbox)
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class OrderLine:
8
+ sku: str
9
+ quantity: int
10
+ unit_price_cents: int
11
+
12
+ @property
13
+ def total_cents(self) -> int:
14
+ return self.quantity * self.unit_price_cents
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Order:
19
+ order_id: str
20
+ customer_email: str
21
+ lines: tuple[OrderLine, ...] = field(default_factory=tuple)
22
+
23
+ @property
24
+ def total_cents(self) -> int:
25
+ return sum(line.total_cents for line in self.lines)
@@ -0,0 +1,31 @@
1
+ """Confirm orders: persist them and tell the customer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from orders.models import Order
6
+ from orders.smtp_gateway import SmtpNotifier
7
+ from orders.sqlite_store import SqliteOrderRepository
8
+
9
+
10
+ def format_confirmation(order: Order) -> tuple[str, str]:
11
+ """Subject and body of the confirmation message. Shared by every channel."""
12
+ subject = f"Order {order.order_id} confirmed"
13
+ lines = [f"- {line.quantity} x {line.sku} @ {line.unit_price_cents / 100:.2f}" for line in order.lines]
14
+ body = "\n".join(["Thanks for your order.", *lines, f"Total: {order.total_cents / 100:.2f}"])
15
+ return subject, body
16
+
17
+
18
+ class OrderService:
19
+ def __init__(self, db_path: str, outbox: str) -> None:
20
+ self._db_path = db_path
21
+ self._outbox = outbox
22
+
23
+ def confirm(self, order: Order) -> None:
24
+ if not order.lines:
25
+ raise ValueError("an order needs at least one line")
26
+ SqliteOrderRepository(self._db_path).save_confirmed(order)
27
+ subject, body = format_confirmation(order)
28
+ SmtpNotifier(self._outbox).send(order.customer_email, subject, body)
29
+
30
+ def find(self, order_id: str) -> Order | None:
31
+ return SqliteOrderRepository(self._db_path).get(order_id)
@@ -0,0 +1,19 @@
1
+ """Email delivery through the storefront's SMTP relay.
2
+
3
+ The relay is not reachable from developer machines, so this gateway writes each message to an
4
+ outbox file; production swaps in the real transport by configuration.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from pathlib import Path
11
+
12
+
13
+ class SmtpNotifier:
14
+ def __init__(self, outbox: str | Path) -> None:
15
+ self._outbox = Path(outbox)
16
+
17
+ def send(self, to: str, subject: str, body: str) -> None:
18
+ with self._outbox.open("a", encoding="utf-8") as stream:
19
+ stream.write(json.dumps({"to": to, "subject": subject, "body": body}) + "\n")