@dbx-tools/projen 0.9.93 → 0.9.99

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.
@@ -0,0 +1,339 @@
1
+ #!/usr/bin/env -S bun
2
+ /** Restart a development command after relevant workspace changes settle. */
3
+ import { isAbsolute, relative, resolve } from "node:path";
4
+ import { configUtils } from "@dbx-tools/core";
5
+ import * as exec from "@dbx-tools/core/exec";
6
+ import { watch as pathWatch } from "@dbx-tools/path";
7
+ import { log } from "@dbx-tools/shared-core";
8
+ import { Command, CommanderError, InvalidArgumentError, Option } from "commander";
9
+ import {
10
+ DEV_RESTART_DEBOUNCE_MS,
11
+ DEV_RESTART_KEY,
12
+ DEV_WATCH_TASK,
13
+ SERVER_WATCH_DISABLED_ENV,
14
+ } from "../src/dev-watch.ts";
15
+ import {
16
+ readPackageManifest,
17
+ recordedPackages,
18
+ resolveRepoRoot,
19
+ workspaceDependencyDirectories,
20
+ } from "../src/packages.ts";
21
+
22
+ const logger = log.logger("projen:dev-watch");
23
+ const SHUTDOWN_TIMEOUT_MS = 2_000;
24
+
25
+ export interface DevWatchOptions {
26
+ readonly command: readonly string[];
27
+ readonly debounceMs: number;
28
+ readonly restartKey: string;
29
+ /** When true, run the command once and skip file-change restarts. */
30
+ readonly serverWatchDisabled: boolean;
31
+ }
32
+
33
+ interface WorkspacePackage {
34
+ readonly dir: string;
35
+ readonly name?: string;
36
+ }
37
+
38
+ function debounceMilliseconds(value: string): number {
39
+ const parsed = configUtils.toNumber(value);
40
+ if (parsed === undefined || parsed < 0) {
41
+ throw new InvalidArgumentError("must be a non-negative number of milliseconds");
42
+ }
43
+ return parsed;
44
+ }
45
+
46
+ function singleKey(value: string): string {
47
+ if (Array.from(value).length !== 1) {
48
+ throw new InvalidArgumentError("must be exactly one character");
49
+ }
50
+ return value;
51
+ }
52
+
53
+ /** Parse watcher flags while passing every token after the command through unchanged. */
54
+ export function parseDevWatchOptions(args: string[]): DevWatchOptions {
55
+ const program = new Command()
56
+ .name(DEV_WATCH_TASK)
57
+ .description("Restart a development command after watched changes settle")
58
+ .exitOverride()
59
+ .configureOutput({
60
+ writeErr: (message) => {
61
+ if (import.meta.main) process.stderr.write(message);
62
+ },
63
+ })
64
+ .enablePositionalOptions()
65
+ .passThroughOptions()
66
+ .addOption(
67
+ new Option("--debounce-ms <milliseconds>", "quiet period before restart")
68
+ .default(DEV_RESTART_DEBOUNCE_MS)
69
+ .argParser(debounceMilliseconds),
70
+ )
71
+ .addOption(
72
+ new Option("--restart-key <key>", "interactive immediate-restart key")
73
+ .default(DEV_RESTART_KEY)
74
+ .argParser(singleKey),
75
+ )
76
+ .addOption(
77
+ new Option(
78
+ "--server-watch-disabled",
79
+ `run the command once without watching (${SERVER_WATCH_DISABLED_ENV})`,
80
+ ),
81
+ )
82
+ .argument("<command>")
83
+ .argument("[args...]")
84
+ .parse(["bun", DEV_WATCH_TASK, ...args]);
85
+ const options = program.opts<{
86
+ debounceMs: number;
87
+ restartKey: string;
88
+ serverWatchDisabled?: boolean;
89
+ }>();
90
+ const [command, commandArgs = []] = program.processedArgs as [string, string[]?];
91
+ const serverWatchDisabled =
92
+ options.serverWatchDisabled === true ||
93
+ configUtils.boolean(undefined, SERVER_WATCH_DISABLED_ENV, configUtils.ENV_ONLY) === true;
94
+ if (serverWatchDisabled) process.env[SERVER_WATCH_DISABLED_ENV] = "1";
95
+ return {
96
+ command: [command, ...commandArgs],
97
+ debounceMs: options.debounceMs,
98
+ restartKey: options.restartKey,
99
+ serverWatchDisabled,
100
+ };
101
+ }
102
+
103
+ function containsPath(parent: string, candidate: string): boolean {
104
+ const rel = relative(parent, candidate);
105
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
106
+ }
107
+
108
+ function workspacePackages(root: string): WorkspacePackage[] {
109
+ return recordedPackages(root)
110
+ .map(({ dir }) => {
111
+ const name = readPackageManifest(dir)?.name;
112
+ return { dir, name: typeof name === "string" ? name : undefined };
113
+ })
114
+ .sort((left, right) => right.dir.length - left.dir.length);
115
+ }
116
+
117
+ function optionArgument(
118
+ command: readonly string[],
119
+ longName: string,
120
+ shortName?: string,
121
+ ): string | undefined {
122
+ for (let index = 0; index < command.length; index++) {
123
+ const token = command[index]!;
124
+ if (token === longName || (shortName !== undefined && token === shortName)) {
125
+ return command[index + 1];
126
+ }
127
+ if (token.startsWith(`${longName}=`)) return token.slice(longName.length + 1);
128
+ if (shortName !== undefined && token.startsWith(`${shortName}=`)) {
129
+ return token.slice(shortName.length + 1);
130
+ }
131
+ }
132
+ return undefined;
133
+ }
134
+
135
+ function commandWorkingDirectory(command: readonly string[], cwd: string): string {
136
+ const configured = optionArgument(command, "--cwd");
137
+ return configured ? resolve(cwd, configured) : cwd;
138
+ }
139
+
140
+ function commandPackage(
141
+ command: readonly string[],
142
+ cwd: string,
143
+ packages: readonly WorkspacePackage[],
144
+ ): WorkspacePackage | undefined {
145
+ const selected = optionArgument(command, "--filter", "-F");
146
+ if (selected) {
147
+ const filtered = packages.find(({ name }) => name === selected);
148
+ if (filtered) return filtered;
149
+ }
150
+
151
+ const commandCwd = commandWorkingDirectory(command, cwd);
152
+ for (const token of command) {
153
+ if (token.startsWith("-")) continue;
154
+ const candidate = resolve(commandCwd, token);
155
+ const owner = packages.find(({ dir }) => containsPath(dir, candidate));
156
+ if (owner) return owner;
157
+ }
158
+ return packages.find(({ dir }) => containsPath(dir, commandCwd));
159
+ }
160
+
161
+ /** Source directories most likely to affect the command, using workspace dependency metadata. */
162
+ export function devWatchDirectories(
163
+ command: readonly string[],
164
+ cwd: string = process.cwd(),
165
+ ): string[] {
166
+ const root = resolveRepoRoot(cwd);
167
+ const packages = workspacePackages(root);
168
+ const owner = commandPackage(command, cwd, packages);
169
+ if (!owner) return [commandWorkingDirectory(command, cwd)];
170
+ const dependencies = owner.name ? workspaceDependencyDirectories(owner.name, root) : [];
171
+ return dependencies.length > 0 ? dependencies : [owner.dir];
172
+ }
173
+
174
+ /** Compare one raw terminal character with the configured restart key. */
175
+ export function isRestartKey(key: string, restartKey: string): boolean {
176
+ return key === restartKey || key.toLocaleLowerCase() === restartKey.toLocaleLowerCase();
177
+ }
178
+
179
+ /** Supervise one command until the user or host stops the task. */
180
+ export async function main(args: string[] = process.argv.slice(2)): Promise<void> {
181
+ let options: DevWatchOptions;
182
+ try {
183
+ options = parseDevWatchOptions(args);
184
+ } catch (err) {
185
+ if (!(err instanceof CommanderError)) throw err;
186
+ if (err.exitCode !== 0) process.exitCode = err.exitCode;
187
+ return;
188
+ }
189
+ const cwd = process.cwd();
190
+ if (options.serverWatchDisabled) {
191
+ logger.info(`${SERVER_WATCH_DISABLED_ENV} is set; running without file watching`);
192
+ const [command, ...commandArgs] = options.command;
193
+ const { exitCode } = await exec.spawn(command!, commandArgs, {
194
+ cwd,
195
+ stdin: "ignore",
196
+ stdout: "inherit",
197
+ stderr: "inherit",
198
+ check: false,
199
+ });
200
+ if (exitCode !== 0) process.exitCode = exitCode;
201
+ return;
202
+ }
203
+
204
+ const root = resolveRepoRoot(cwd);
205
+ const watchDirectories = devWatchDirectories(options.command, cwd);
206
+ const commandLabel = options.command.map((part) => JSON.stringify(part)).join(" ");
207
+ let child: exec.ChildProcessResult | undefined;
208
+ let timer: ReturnType<typeof setTimeout> | undefined;
209
+ let restartReason: string | undefined;
210
+ let restarting = false;
211
+ let stopping = false;
212
+
213
+ const watcher = pathWatch.watchFiles(watchDirectories, {
214
+ cwd: root,
215
+ ignoreInitial: true,
216
+ });
217
+
218
+ async function stopChild(): Promise<void> {
219
+ const current = child;
220
+ if (!current) return;
221
+ child = undefined;
222
+ try {
223
+ await exec.kill(current, {
224
+ gracefulTimeoutMs: SHUTDOWN_TIMEOUT_MS,
225
+ forceTimeoutMs: SHUTDOWN_TIMEOUT_MS,
226
+ });
227
+ } catch (err) {
228
+ logger.error("failed to stop command process tree:", err);
229
+ }
230
+ }
231
+
232
+ function startChild(reason: string): void {
233
+ logger.info(`${reason}; starting ${commandLabel}`);
234
+ const [command, ...commandArgs] = options.command;
235
+ const current = exec.spawn(command!, commandArgs, {
236
+ cwd,
237
+ stdin: "ignore",
238
+ stdout: "inherit",
239
+ stderr: "inherit",
240
+ check: false,
241
+ });
242
+ child = current;
243
+ void current
244
+ .then(({ exitCode }) => {
245
+ if (child !== current || stopping) return;
246
+ child = undefined;
247
+ logger.warn(
248
+ `command exited with code ${exitCode}; press ${JSON.stringify(options.restartKey)} or change a watched file to restart`,
249
+ );
250
+ })
251
+ .catch((err) => {
252
+ if (child !== current || stopping) return;
253
+ child = undefined;
254
+ logger.error("command failed:", err);
255
+ });
256
+ }
257
+
258
+ async function drainRestarts(): Promise<void> {
259
+ if (restarting) return;
260
+ restarting = true;
261
+ try {
262
+ while (restartReason !== undefined && !stopping) {
263
+ const reason = restartReason;
264
+ restartReason = undefined;
265
+ await stopChild();
266
+ if (!stopping) startChild(reason);
267
+ }
268
+ } finally {
269
+ restarting = false;
270
+ }
271
+ }
272
+
273
+ function requestRestart(reason: string): void {
274
+ restartReason = reason;
275
+ void drainRestarts();
276
+ }
277
+
278
+ function scheduleRestart(): void {
279
+ clearTimeout(timer);
280
+ timer = setTimeout(
281
+ () => requestRestart(`changes settled for ${options.debounceMs}ms`),
282
+ options.debounceMs,
283
+ );
284
+ }
285
+
286
+ watcher.on("all", scheduleRestart);
287
+ watcher.on("error", (err) => logger.error("watcher error:", err));
288
+ watcher.on("ready", () => {
289
+ const packages = watchDirectories.length;
290
+ logger.info(
291
+ `watching ${packages} workspace ${packages === 1 ? "directory" : "directories"} with ${options.debounceMs}ms restart debounce`,
292
+ );
293
+ logger.info(`press ${JSON.stringify(options.restartKey)} to restart; Ctrl-C to stop`);
294
+ });
295
+
296
+ const stdin = process.stdin;
297
+ const wasRaw = stdin.isTTY ? Boolean(stdin.isRaw) : false;
298
+ const onInput = (chunk: Buffer | string): void => {
299
+ for (const key of String(chunk)) {
300
+ if (key === "\u0003") {
301
+ void shutdown();
302
+ return;
303
+ }
304
+ if (isRestartKey(key, options.restartKey)) {
305
+ clearTimeout(timer);
306
+ requestRestart("manual restart requested");
307
+ }
308
+ }
309
+ };
310
+ if (stdin.isTTY) stdin.setRawMode(true);
311
+ stdin.resume();
312
+ stdin.on("data", onInput);
313
+
314
+ let finish!: () => void;
315
+ const complete = new Promise<void>((resolveComplete) => {
316
+ finish = resolveComplete;
317
+ });
318
+
319
+ async function shutdown(): Promise<void> {
320
+ if (stopping) return;
321
+ stopping = true;
322
+ clearTimeout(timer);
323
+ restartReason = undefined;
324
+ stdin.off("data", onInput);
325
+ if (stdin.isTTY) stdin.setRawMode(wasRaw);
326
+ stdin.pause();
327
+ await Promise.allSettled([watcher.close(), stopChild()]);
328
+ finish();
329
+ }
330
+
331
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
332
+ process.once(signal, () => void shutdown());
333
+ }
334
+
335
+ requestRestart("initial start");
336
+ await complete;
337
+ }
338
+
339
+ if (import.meta.main) await main();
@@ -12,11 +12,18 @@ import type { BunPlugin } from "bun";
12
12
  import stdLibBrowser from "node-stdlib-browser";
13
13
  import ts from "typescript";
14
14
  import { header, makeReadonly, makeWritable } from "../src/generated.ts";
15
- import { publicFunctionExports, publicNamespaceExports } from "../src/module-exports.ts";
15
+ import { publicFunctionExports } from "../src/module-exports.ts";
16
16
  import { resolveRepoRoot } from "../src/packages.ts";
17
17
  import {
18
18
  generatePythonNodeBindings,
19
+ fingerprintPythonNodeBindingOutput,
20
+ isPythonNodeBindingIdentifier,
21
+ pythonNodeBindingFunctionName as pythonFunctionName,
22
+ pythonNodeBindingInputFingerprint,
23
+ pythonNodeBindingOutputs,
24
+ pythonNodeBindingOutputsCurrent,
19
25
  resolvePythonNodeBindingModule,
26
+ resolvePythonNodeBindingModules,
20
27
  resolvePythonNodeBindings,
21
28
  type ResolvedPythonNodeBindings,
22
29
  type ResolvedPythonNodeFunctionOverride,
@@ -35,50 +42,13 @@ function bindingIndex(value: string, count: number, project: string): number {
35
42
  return index;
36
43
  }
37
44
 
38
- const PYTHON_KEYWORDS = new Set([
39
- "and",
40
- "as",
41
- "assert",
42
- "async",
43
- "await",
44
- "break",
45
- "class",
46
- "continue",
47
- "def",
48
- "del",
49
- "elif",
50
- "else",
51
- "except",
52
- "False",
53
- "finally",
54
- "for",
55
- "from",
56
- "global",
57
- "if",
58
- "import",
59
- "in",
60
- "is",
61
- "lambda",
62
- "None",
63
- "nonlocal",
64
- "not",
65
- "or",
66
- "pass",
67
- "raise",
68
- "return",
69
- "True",
70
- "try",
71
- "while",
72
- "with",
73
- "yield",
74
- ]);
75
-
76
45
  export async function main(args: string[] = process.argv.slice(2)): Promise<void> {
77
46
  const { values } = parseArgs({
78
47
  args,
79
48
  options: {
80
49
  binding: { type: "string" },
81
50
  check: { type: "boolean" },
51
+ force: { type: "boolean" },
82
52
  module: { type: "string" },
83
53
  project: { type: "string" },
84
54
  root: { type: "string" },
@@ -89,11 +59,13 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
89
59
  if (values.binding !== undefined || values.module !== undefined) {
90
60
  throw new Error("--binding and --module require --project");
91
61
  }
92
- generatePythonNodeBindings(root, { check: values.check });
62
+ generatePythonNodeBindings(root, { check: values.check, force: values.force });
93
63
  return;
94
64
  }
95
65
 
96
- const configs = resolvePythonNodeBindings(root, values.project).map(discoverBindingModules);
66
+ const configs = resolvePythonNodeBindings(root, values.project).map(
67
+ resolvePythonNodeBindingModules,
68
+ );
97
69
  if (configs.length > 1 && configs.some(({ modules }) => modules.length === 0)) {
98
70
  throw new Error(
99
71
  "Every Node binding must export a namespace with bindable functions or configure modules",
@@ -105,6 +77,18 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
105
77
  : bindingIndex(values.binding, configs.length, values.project);
106
78
  const config = selectedBinding === undefined ? configs[0] : configs[selectedBinding];
107
79
  if (!config) throw new Error("Expected at least one Node binding configuration");
80
+ const inputFingerprint = pythonNodeBindingInputFingerprint(root, configs);
81
+ if (
82
+ selectedBinding === undefined &&
83
+ !values.force &&
84
+ pythonNodeBindingOutputsCurrent(configs, inputFingerprint)
85
+ ) {
86
+ for (const output of pythonNodeBindingOutputs(configs)) makeReadonly(output);
87
+ if (values.check) {
88
+ logger.info(`verified ${relative(root, dirname(config.runtimeOutput))} from fingerprints`);
89
+ }
90
+ return;
91
+ }
108
92
  if (selectedBinding === undefined && (configs.length > 1 || config.modules.length > 0)) {
109
93
  prepareBindingDirectory(configs);
110
94
  writeRuntimeLoader(configs);
@@ -115,6 +99,7 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
115
99
  values.project,
116
100
  ...(values.root ? ["--root", values.root] : []),
117
101
  ...(values.check ? ["--check"] : []),
102
+ ...(values.force ? ["--force"] : []),
118
103
  "--binding",
119
104
  String(binding),
120
105
  "--module",
@@ -217,9 +202,9 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
217
202
  const entrypoint = Bun.resolveSync(binding.entrypoint, binding.projectDirectory);
218
203
  return binding.modules.length > 0
219
204
  ? binding.modules.map((module) => ({
220
- key: runtimeModuleName(binding, module),
221
- entrypoint: resolvePythonNodeBindingModule(entrypoint, module),
222
- }))
205
+ key: runtimeModuleName(binding, module),
206
+ entrypoint: resolvePythonNodeBindingModule(entrypoint, module),
207
+ }))
223
208
  : [{ key: runtimeModuleName(binding), entrypoint }];
224
209
  });
225
210
  const runtimeExports = [
@@ -304,18 +289,6 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
304
289
  }
305
290
  await generate();
306
291
 
307
- function discoverBindingModules(
308
- candidate: ResolvedPythonNodeBindings,
309
- ): ResolvedPythonNodeBindings {
310
- if (candidate.modules.length > 0) return candidate;
311
- const packageEntrypoint = Bun.resolveSync(candidate.entrypoint, candidate.projectDirectory);
312
- const modules = publicNamespaceExports(packageEntrypoint).filter(
313
- (module) =>
314
- publicFunctionExports(resolvePythonNodeBindingModule(packageEntrypoint, module)).length > 0,
315
- );
316
- return { ...candidate, modules };
317
- }
318
-
319
292
  async function generate(): Promise<void> {
320
293
  const result = await Bun.build({
321
294
  entrypoints: [shimEntry],
@@ -367,8 +340,8 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
367
340
  ...bindings.flatMap((binding) => [
368
341
  ...(binding.modules.length > 0
369
342
  ? binding.modules.map((module) =>
370
- join(binding.bindingDirectory, `${pythonFunctionName(module)}.py`),
371
- )
343
+ join(binding.bindingDirectory, `${pythonFunctionName(module)}.py`),
344
+ )
372
345
  : [join(binding.bindingDirectory, "index.py")]),
373
346
  ]),
374
347
  ].filter((file): file is string => Boolean(file)),
@@ -414,16 +387,6 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
414
387
  return grouped;
415
388
  }
416
389
 
417
- function pythonFunctionName(javascriptName: string): string {
418
- const name = stringUtils.toIdentifierWithOptions({ delimiter: "_" }, javascriptName);
419
- if (!/^[_A-Za-z]\w*$/.test(name) || PYTHON_KEYWORDS.has(name)) {
420
- throw new Error(
421
- `JavaScript export ${javascriptName} does not map to a valid Python function name`,
422
- );
423
- }
424
- return name;
425
- }
426
-
427
390
  function runtimeModuleName(binding: ResolvedPythonNodeBindings, module?: string): string {
428
391
  return [binding.bindingName, ...(module ? [pythonFunctionName(module)] : [])].join("__");
429
392
  }
@@ -987,18 +950,18 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
987
950
  function pythonDataclass(record: PythonRecord): string {
988
951
  const body = record.fields.length
989
952
  ? record.fields
990
- .map((field) => {
991
- const defaultArgument = Object.hasOwn(field, "defaultValue")
992
- ? pythonDefault(field.defaultValue)
993
- : "default=None";
994
- return [
995
- ` ${field.pythonName}: ${pythonOptionalType(field.type)} = field(`,
996
- ` ${defaultArgument},`,
997
- ` metadata={"javascript_name": ${JSON.stringify(field.javascriptName)}},`,
998
- " )",
999
- ].join("\n");
1000
- })
1001
- .join("\n")
953
+ .map((field) => {
954
+ const defaultArgument = Object.hasOwn(field, "defaultValue")
955
+ ? pythonDefault(field.defaultValue)
956
+ : "default=None";
957
+ return [
958
+ ` ${field.pythonName}: ${pythonOptionalType(field.type)} = field(`,
959
+ ` ${defaultArgument},`,
960
+ ` metadata={"javascript_name": ${JSON.stringify(field.javascriptName)}},`,
961
+ " )",
962
+ ].join("\n");
963
+ })
964
+ .join("\n")
1002
965
  : " pass";
1003
966
  return `@dataclass(kw_only=True)\nclass ${record.name}:\n${body}`;
1004
967
  }
@@ -1026,17 +989,17 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
1026
989
  }
1027
990
  const body = response.fields.length
1028
991
  ? response.fields
1029
- .map(
1030
- ({ name, required, type }) =>
1031
- ` ${name}: ${required ? type : `NotRequired[${type}]`}`,
1032
- )
1033
- .join("\n")
992
+ .map(
993
+ ({ name, required, type }) =>
994
+ ` ${name}: ${required ? type : `NotRequired[${type}]`}`,
995
+ )
996
+ .join("\n")
1034
997
  : " pass";
1035
998
  return `class ${response.name}(TypedDict):\n${body}`;
1036
999
  }
1037
1000
 
1038
1001
  function isPythonIdentifier(value: string): boolean {
1039
- return /^[_A-Za-z]\w*$/.test(value) && !PYTHON_KEYWORDS.has(value);
1002
+ return isPythonNodeBindingIdentifier(value);
1040
1003
  }
1041
1004
 
1042
1005
  function pythonProtocol(protocol: PythonProtocol): string {
@@ -1045,18 +1008,18 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
1045
1008
  );
1046
1009
  const methods = protocol.methods.length
1047
1010
  ? protocol.methods
1048
- .map((method) => {
1049
- const parameters = method.parameters.map(
1050
- ({ name, required, type }) => ` ${name}: ${type}${required ? "" : " = ..."},`,
1051
- );
1052
- return [
1053
- ` async def ${method.name}(`,
1054
- " self,",
1055
- ...parameters,
1056
- ` ) -> ${method.returnType}: ...`,
1057
- ].join("\n");
1058
- })
1059
- .join("\n\n")
1011
+ .map((method) => {
1012
+ const parameters = method.parameters.map(
1013
+ ({ name, required, type }) => ` ${name}: ${type}${required ? "" : " = ..."},`,
1014
+ );
1015
+ return [
1016
+ ` async def ${method.name}(`,
1017
+ " self,",
1018
+ ...parameters,
1019
+ ` ) -> ${method.returnType}: ...`,
1020
+ ].join("\n");
1021
+ })
1022
+ .join("\n\n")
1060
1023
  : "";
1061
1024
  const body = [...properties, methods].filter(Boolean).join("\n\n") || " pass";
1062
1025
  return `class ${protocol.name}(Protocol):\n${body}`;
@@ -1149,7 +1112,8 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
1149
1112
 
1150
1113
  function writeGenerated(output: string, contents: string, kind: string): void {
1151
1114
  const destination = relative(root, output);
1152
- if (existsSync(output) && readFileSync(output, "utf8") === contents) {
1115
+ const generated = fingerprintPythonNodeBindingOutput(contents, inputFingerprint, output);
1116
+ if (existsSync(output) && readFileSync(output, "utf8") === generated) {
1153
1117
  if (values.check) logger.info(`verified ${destination}`);
1154
1118
  return;
1155
1119
  }
@@ -1158,7 +1122,7 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
1158
1122
  }
1159
1123
  mkdirSync(dirname(output), { recursive: true });
1160
1124
  makeWritable(output);
1161
- writeFileSync(output, contents);
1125
+ writeFileSync(output, generated);
1162
1126
  makeReadonly(output);
1163
1127
  logger.info(`generated ${destination}`);
1164
1128
  }
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
- import { dirname, join } from "node:path";
11
+ import { dirname, join, resolve } from "node:path";
12
12
  import { log } from "@dbx-tools/shared-core";
13
13
 
14
14
  import { captureTaskCommand, tryTaskCommand } from "../src/_task-command.ts";
@@ -20,6 +20,10 @@ const GENIE_PROMPT =
20
20
 
21
21
  /** Inputs for one notes write. */
22
22
  export interface WriteReleaseNotesOptions {
23
+ /** Complete release-note content supplied by the caller instead of Genie. */
24
+ readonly content?: string;
25
+ /** File containing complete release notes supplied by the caller instead of Genie. */
26
+ readonly file?: string;
23
27
  /** Extra agent instructions appended after the standard release-note requirements. */
24
28
  readonly instructions?: string;
25
29
  readonly prefix: string;
@@ -60,6 +64,25 @@ export function writeReleaseNotes(options: WriteReleaseNotesOptions): string {
60
64
  const destination = releaseNotesPath(root, version);
61
65
  mkdirSync(dirname(destination), { recursive: true });
62
66
  const relative = join("docs", "releases", `v${version}.md`);
67
+ const suppliedContent = options.content?.trim();
68
+ const suppliedFile = options.file?.trim();
69
+ if (suppliedContent && suppliedFile) {
70
+ throw new Error("release notes content and file are mutually exclusive");
71
+ }
72
+ if (options.content !== undefined) {
73
+ if (!suppliedContent) throw new Error("release notes content must not be empty");
74
+ writeFileSync(destination, `${suppliedContent}\n`);
75
+ logger.info("wrote supplied release notes", { path: relative });
76
+ return destination;
77
+ }
78
+ if (options.file !== undefined) {
79
+ if (!suppliedFile) throw new Error("release notes file must not be empty");
80
+ const content = readNotes(resolve(root, suppliedFile));
81
+ if (!content) throw new Error(`release notes file is empty or unreadable: ${suppliedFile}`);
82
+ writeFileSync(destination, `${content}\n`);
83
+ logger.info("copied supplied release notes", { path: relative, source: suppliedFile });
84
+ return destination;
85
+ }
63
86
  const genieArgs = [
64
87
  "exec",
65
88
  "-C",