@dbx-tools/projen 0.9.51 → 0.9.54

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.
@@ -4,6 +4,7 @@ import * as fileLock from "@dbx-tools/core/file-lock";
4
4
  import { log } from "@dbx-tools/shared-core";
5
5
 
6
6
  const MUTATION_LOCK_SCOPE = "dbx-tools-workspace-mutation";
7
+ const WAIT_LOG_INTERVAL_MS = 5_000;
7
8
  const logger = log.logger("projen:workspace-lock");
8
9
 
9
10
  /** Optional preflight used before and after acquiring the workspace mutation lock. */
@@ -33,17 +34,34 @@ export async function withWorkspaceMutationLock<T>(
33
34
  if (check && !(await check())) {
34
35
  return undefined;
35
36
  }
36
- return fileLock.withFileLock(
37
- [MUTATION_LOCK_SCOPE, repository],
38
- async () => {
39
- if (check && !(await check())) {
40
- return undefined;
41
- }
42
- return callback();
43
- },
44
- {
45
- backends: process.platform === "win32" ? ["file"] : ["flock", "file"],
46
- onWait: () => logger.info("waiting for workspace mutation lock", { repository }),
47
- },
48
- );
37
+ let waitLogTimer: ReturnType<typeof setInterval> | undefined;
38
+ try {
39
+ return await fileLock.withFileLock(
40
+ [MUTATION_LOCK_SCOPE, repository],
41
+ async () => {
42
+ if (check && !(await check())) {
43
+ return undefined;
44
+ }
45
+ return callback();
46
+ },
47
+ {
48
+ backends: process.platform === "win32" ? ["file"] : ["flock", "file"],
49
+ onWait: () => {
50
+ if (waitLogTimer) return;
51
+ const startedAt = Date.now();
52
+ waitLogTimer = setInterval(
53
+ () =>
54
+ logger.info("waiting for workspace mutation lock", {
55
+ repository,
56
+ elapsedMs: Date.now() - startedAt,
57
+ }),
58
+ WAIT_LOG_INTERVAL_MS,
59
+ );
60
+ waitLogTimer.unref?.();
61
+ },
62
+ },
63
+ );
64
+ } finally {
65
+ if (waitLogTimer) clearInterval(waitLogTimer);
66
+ }
49
67
  }
@@ -79,11 +79,9 @@ export function publishPythonProjects(options: {
79
79
  readonly root: string;
80
80
  readonly version: string;
81
81
  }): void {
82
- const root = resolve(options.root);
83
- const workspace = projectUtils.root(root) ?? dirname(root);
84
- const output = mkdtempSync(join(workspace, ".projen-python-publish-"));
82
+ const output = mkdtempSync(join(tmpdir(), "projen-python-publish-"));
85
83
  try {
86
- buildPythonProjects({ ...options, output });
84
+ buildPythonProjectsInternal({ ...options, output }, true);
87
85
  runTaskCommand(
88
86
  process.cwd(),
89
87
  "uvx",
@@ -101,7 +99,7 @@ export function publishPythonProjects(options: {
101
99
  { env: { ...process.env, UV_DEFAULT_INDEX: options.indexUrl } },
102
100
  );
103
101
  } finally {
104
- rmSync(output, { recursive: true, force: true });
102
+ removeTemporaryTree(output);
105
103
  }
106
104
  }
107
105
 
@@ -115,11 +113,21 @@ export function buildPythonProjects(options: {
115
113
  readonly root: string;
116
114
  readonly version: string;
117
115
  }): void {
116
+ buildPythonProjectsInternal(options, false);
117
+ }
118
+
119
+ function buildPythonProjectsInternal(
120
+ options: Parameters<typeof buildPythonProjects>[0],
121
+ allowOutputOutsideWorkspace: boolean,
122
+ ): void {
118
123
  const root = resolve(options.root);
119
124
  const output = resolve(options.output);
120
125
  const workspace = projectUtils.root(root) ?? dirname(root);
121
126
  const outputPath = relative(workspace, output);
122
- if (!outputPath || outputPath.startsWith("..") || isAbsolute(outputPath)) {
127
+ if (
128
+ !allowOutputOutsideWorkspace &&
129
+ (!outputPath || outputPath.startsWith("..") || isAbsolute(outputPath))
130
+ ) {
123
131
  throw new Error("Python release output must be a non-root directory inside the workspace");
124
132
  }
125
133
  rmSync(output, { recursive: true, force: true });
@@ -0,0 +1,188 @@
1
+ """Rewrite absolute imports of synchronized upstream modules to their generated package.
2
+
3
+ Usage: python-localize-imports.py ROOT TARGET SOURCE [MODULE ...]
4
+
5
+ ROOT holds the synchronized ``.py`` tree and is importable as the dotted package
6
+ TARGET. SOURCE is the dotted path ROOT had upstream (empty when it has none), so
7
+ upstream may import a synchronized module either bare (``config.schema``) or under
8
+ SOURCE (``graphiti_core.driver.postgraph``). MODULE names the upstream modules to
9
+ rewrite; without any, every top-level module in ROOT is rewritten in both forms.
10
+
11
+ Covers ``import``, ``from ... import``, and constant-string ``__import__`` /
12
+ ``importlib.import_module`` calls. Prints a JSON object mapping each MODULE to the
13
+ number of references rewritten.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import ast
19
+ import json
20
+ import re
21
+ import sys
22
+ from pathlib import Path
23
+
24
+ FROM_MODULE = re.compile(r"from\s+([^\W\d]\w*(?:\s*\.\s*[^\W\d]\w*)*)")
25
+ DYNAMIC_IMPORTS = {"__import__", "import_module"}
26
+
27
+
28
+ class Localizer:
29
+ def __init__(self, root: Path, target: str, source: str, modules: list[str]) -> None:
30
+ self.target = target
31
+ self.source = source
32
+ local = local_modules(root)
33
+ if modules:
34
+ self.modules = modules
35
+ for module in modules:
36
+ if self.local_name(module).split(".", 1)[0] not in local:
37
+ raise SystemExit(f"localize_imports module {module!r} is not synchronized")
38
+ else:
39
+ shadowed = sorted(local & set(sys.stdlib_module_names))
40
+ if shadowed:
41
+ raise SystemExit(
42
+ f"synchronized modules shadow the standard library: {', '.join(shadowed)}; "
43
+ "list localize_imports modules explicitly"
44
+ )
45
+ self.modules = sorted(local) + (
46
+ [f"{source}.{name}" for name in sorted(local)] if source else []
47
+ )
48
+ # Longest first, so a nested entry owns its imports over its parent.
49
+ self.ordered = sorted(self.modules, key=len, reverse=True)
50
+ self.counts = dict.fromkeys(self.modules, 0)
51
+
52
+ def local_name(self, name: str) -> str:
53
+ if self.source and name.startswith(f"{self.source}."):
54
+ return name[len(self.source) + 1 :]
55
+ return name
56
+
57
+ def owner(self, name: str) -> str | None:
58
+ return next(
59
+ (module for module in self.ordered if name == module or name.startswith(f"{module}.")),
60
+ None,
61
+ )
62
+
63
+ def rewrite(self, name: str) -> str | None:
64
+ module = self.owner(name)
65
+ if module is None:
66
+ return None
67
+ self.counts[module] += 1
68
+ return f"{self.target}.{self.local_name(name)}"
69
+
70
+ def localize(self, path: Path) -> None:
71
+ data = path.read_bytes()
72
+ tree = ast.parse(data, filename=str(path))
73
+ starts = [0]
74
+ for line in data.splitlines(keepends=True):
75
+ starts.append(starts[-1] + len(line))
76
+
77
+ def span(node: ast.AST) -> tuple[int, int]:
78
+ # AST columns are UTF-8 byte offsets within their line.
79
+ return (
80
+ starts[node.lineno - 1] + node.col_offset,
81
+ starts[node.end_lineno - 1] + node.end_col_offset,
82
+ )
83
+
84
+ edits: list[tuple[int, int, bytes]] = []
85
+ for node in ast.walk(tree):
86
+ if isinstance(node, ast.ImportFrom):
87
+ if node.level or not node.module:
88
+ continue
89
+ local = self.rewrite(node.module)
90
+ if local is None:
91
+ continue
92
+ start, end = span(node)
93
+ text = data[start:end].decode()
94
+ found = FROM_MODULE.match(text)
95
+ if not found or re.sub(r"\s", "", found[1]) != node.module:
96
+ raise SystemExit(f"{path}:{node.lineno}: cannot locate module in {text!r}")
97
+ edits.append(
98
+ (
99
+ start + len(text[: found.start(1)].encode()),
100
+ start + len(text[: found.end(1)].encode()),
101
+ local.encode(),
102
+ )
103
+ )
104
+ elif isinstance(node, ast.Import):
105
+ if not any(self.owner(alias.name) for alias in node.names):
106
+ continue
107
+ statements = [self.import_statement(path, node, alias) for alias in node.names]
108
+ start, end = span(node)
109
+ edits.append((start, end, "; ".join(statements).encode()))
110
+ elif isinstance(node, ast.Call) and self.is_dynamic_import(node):
111
+ argument = node.args[0]
112
+ local = self.rewrite(argument.value)
113
+ if local is None:
114
+ continue
115
+ if self.returns_top_level(node):
116
+ # Without a fromlist, `__import__` returns the top-level package.
117
+ raise SystemExit(
118
+ f"{path}:{node.lineno}: pass a fromlist to localize __import__"
119
+ )
120
+ start, end = span(argument)
121
+ text = data[start:end].decode()
122
+ if argument.value not in text:
123
+ raise SystemExit(f"{path}:{node.lineno}: cannot rewrite escaped {text!r}")
124
+ edits.append((start, end, text.replace(argument.value, local, 1).encode()))
125
+ if not edits:
126
+ return
127
+ for start, end, replacement in sorted(edits, reverse=True):
128
+ data = data[:start] + replacement + data[end:]
129
+ ast.parse(data, filename=str(path))
130
+ path.write_bytes(data)
131
+
132
+ def import_statement(self, path: Path, node: ast.Import, alias: ast.alias) -> str:
133
+ local = self.rewrite(alias.name)
134
+ if local is None:
135
+ return f"import {alias.name}" + (f" as {alias.asname}" if alias.asname else "")
136
+ if alias.asname:
137
+ return f"import {local} as {alias.asname}"
138
+ binding, _, submodule = self.local_name(alias.name).partition(".")
139
+ if binding != alias.name.split(".", 1)[0]:
140
+ # `import pkg.sub` binds `pkg`, which no longer names the synchronized tree.
141
+ raise SystemExit(f"{path}:{node.lineno}: alias `import {alias.name}` to localize it")
142
+ if not submodule:
143
+ return f"from {self.target} import {binding}"
144
+ return f"import {local}; from {self.target} import {binding}"
145
+
146
+ @staticmethod
147
+ def is_dynamic_import(node: ast.Call) -> bool:
148
+ function = node.func
149
+ name = function.id if isinstance(function, ast.Name) else getattr(function, "attr", None)
150
+ return (
151
+ name in DYNAMIC_IMPORTS
152
+ and bool(node.args)
153
+ and isinstance(node.args[0], ast.Constant)
154
+ and isinstance(node.args[0].value, str)
155
+ and not node.args[0].value.startswith(".")
156
+ )
157
+
158
+ @staticmethod
159
+ def returns_top_level(node: ast.Call) -> bool:
160
+ if not isinstance(node.func, ast.Name) or node.func.id != "__import__":
161
+ return False
162
+ return len(node.args) < 4 and not any(
163
+ keyword.arg == "fromlist" for keyword in node.keywords
164
+ )
165
+
166
+
167
+ def local_modules(root: Path) -> set[str]:
168
+ names = set()
169
+ for path in root.rglob("*.py"):
170
+ parts = path.relative_to(root).parts
171
+ name = parts[0] if len(parts) > 1 else path.stem
172
+ if name.isidentifier() and name != "__init__":
173
+ names.add(name)
174
+ return names
175
+
176
+
177
+ def main(argv: list[str]) -> None:
178
+ if len(argv) < 3:
179
+ raise SystemExit(__doc__)
180
+ root, target, source, *modules = argv
181
+ localizer = Localizer(Path(root), target, source, modules)
182
+ for path in sorted(Path(root).rglob("*.py")):
183
+ localizer.localize(path)
184
+ print(json.dumps(localizer.counts))
185
+
186
+
187
+ if __name__ == "__main__":
188
+ main(sys.argv[1:])
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env -S bun
2
+ /** Keep every configured Python Node bridge current with one repository watcher. */
3
+ import { dirname, resolve, sep } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { log } from "@dbx-tools/shared-core";
6
+ import { runTaskCommand } from "../src/_task-command.ts";
7
+ import { repoRoot } from "../src/packages.ts";
8
+ import {
9
+ affectedPythonNodeBindingProjects,
10
+ generatePythonNodeBindings,
11
+ pythonNodeBindingWatchInputs,
12
+ resolveAllPythonNodeBindings,
13
+ type ResolvedPythonNodeBindings,
14
+ } from "../src/python-node-bindings.ts";
15
+ import { watchLoop, watchRoots } from "../src/watch.ts";
16
+ import { withWorkspaceMutationLock } from "../src/workspace-lock.ts";
17
+
18
+ const logger = log.logger("projen:python-node-bindings-watch");
19
+ const generator = fileURLToPath(new URL("./python-node-bindings.ts", import.meta.url));
20
+
21
+ function generate(project: string): void {
22
+ runTaskCommand(repoRoot, "bun", [generator, "--root", repoRoot, "--project", project]);
23
+ }
24
+
25
+ function containsPath(parent: string, candidate: string): boolean {
26
+ const resolvedParent = resolve(parent);
27
+ const resolvedCandidate = resolve(candidate);
28
+ return (
29
+ resolvedCandidate === resolvedParent || resolvedCandidate.startsWith(`${resolvedParent}${sep}`)
30
+ );
31
+ }
32
+
33
+ /**
34
+ * Watch broad package roots, each configured Python workspace root, and only
35
+ * exceptional inputs such as function overrides that live outside those roots.
36
+ */
37
+ function watchPaths(configs: readonly ResolvedPythonNodeBindings[]): string[] {
38
+ const roots = watchRoots();
39
+ const pythonRoots = configs.map(({ projectDirectory }) => dirname(projectDirectory));
40
+ const covered = [...roots, ...pythonRoots];
41
+ const explicitInputs = configs
42
+ .flatMap(pythonNodeBindingWatchInputs)
43
+ .filter((input) => !covered.some((root) => containsPath(root, input)));
44
+ return [...new Set([...covered, ...explicitInputs])].sort();
45
+ }
46
+
47
+ function selection(
48
+ previous: readonly ResolvedPythonNodeBindings[],
49
+ changed: readonly string[],
50
+ ): {
51
+ readonly all: boolean;
52
+ readonly configs: ResolvedPythonNodeBindings[];
53
+ readonly projects: string[];
54
+ } {
55
+ const configs = resolveAllPythonNodeBindings(repoRoot);
56
+ const manifests = new Set([...previous, ...configs].map(({ pyproject }) => resolve(pyproject)));
57
+ const all = changed.some((path) => manifests.has(resolve(path)));
58
+ return {
59
+ all,
60
+ configs,
61
+ projects: all ? [] : affectedPythonNodeBindingProjects(repoRoot, configs, changed),
62
+ };
63
+ }
64
+
65
+ /** Generate once, then rebuild only bridges affected by each debounced change batch. */
66
+ export async function main(): Promise<void> {
67
+ let configs = resolveAllPythonNodeBindings(repoRoot);
68
+ await withWorkspaceMutationLock(repoRoot, () => generatePythonNodeBindings(repoRoot));
69
+ logger.success("generated all Python Node bindings");
70
+
71
+ watchLoop(
72
+ "python-node-bindings",
73
+ watchPaths(configs),
74
+ (changed) => {
75
+ const next = selection(configs, changed);
76
+ configs = next.configs;
77
+ if (next.all) {
78
+ generatePythonNodeBindings(repoRoot);
79
+ logger.success("regenerated all Python Node bindings");
80
+ return;
81
+ }
82
+ for (const project of next.projects) {
83
+ generate(project);
84
+ logger.success(`regenerated ${project} Node bindings`);
85
+ }
86
+ },
87
+ {
88
+ check: (changed) => {
89
+ const next = selection(configs, changed);
90
+ return next.all || next.projects.length > 0;
91
+ },
92
+ },
93
+ );
94
+ }
95
+
96
+ if (import.meta.main) await main();
@@ -13,12 +13,14 @@ import stdLibBrowser from "node-stdlib-browser";
13
13
  import ts from "typescript";
14
14
  import { header, makeReadonly, makeWritable } from "../src/generated.ts";
15
15
  import { publicFunctionExports, publicNamespaceExports } from "../src/module-exports.ts";
16
+ import { resolveRepoRoot } from "../src/packages.ts";
16
17
  import {
17
- PYTHON_NODE_SHIM_ROOT,
18
+ generatePythonNodeBindings,
18
19
  resolvePythonNodeBindings,
19
20
  type ResolvedPythonNodeBindings,
20
21
  type ResolvedPythonNodeFunctionOverride,
21
22
  } from "../src/python-node-bindings.ts";
23
+ import { PYTHON_NODE_RUNTIME_MODULE } from "../src/python-node-runtime.ts";
22
24
 
23
25
  const logger = log.logger("projen:python-node-bindings");
24
26
 
@@ -81,11 +83,15 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
81
83
  root: { type: "string" },
82
84
  },
83
85
  });
84
- if (!values.project) throw new Error("Expected --project <python-project-directory>");
86
+ const root = values.root ? resolve(values.root) : resolveRepoRoot();
87
+ if (!values.project) {
88
+ if (values.binding !== undefined || values.module !== undefined) {
89
+ throw new Error("--binding and --module require --project");
90
+ }
91
+ generatePythonNodeBindings(root, { check: values.check });
92
+ return;
93
+ }
85
94
 
86
- const root = values.root
87
- ? resolve(values.root)
88
- : resolve(dirname(fileURLToPath(import.meta.url)), "../..");
89
95
  const configs = resolvePythonNodeBindings(root, values.project).map(discoverBindingModules);
90
96
  if (configs.length > 1 && configs.some(({ modules }) => modules.length === 0)) {
91
97
  throw new Error(
@@ -161,21 +167,21 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
161
167
  functionsByPythonName.set(functionName.pythonName, functionName.javascriptName);
162
168
  }
163
169
 
164
- const shimRoot = PYTHON_NODE_SHIM_ROOT;
165
170
  const overridesByModule = groupOverrides(
166
171
  configs.flatMap(({ functionOverrides }) => functionOverrides),
167
172
  );
168
173
  const shimEntry = "dbx-tools:python-entry";
169
174
  const functionOverrideNamespace = "dbx-tools-function-override";
170
- const shimAliases = new Map([
171
- ["child_process", resolve(shimRoot, "child-process.ts")],
172
- ["crypto", resolve(shimRoot, "crypto.ts")],
173
- ["fs", resolve(shimRoot, "fs.ts")],
174
- ["fs/promises", resolve(shimRoot, "fs-promises.ts")],
175
- ["os", resolve(shimRoot, "os.ts")],
176
- ["process", resolve(shimRoot, "process.ts")],
177
- ["readline", resolve(shimRoot, "readline.ts")],
175
+ const runtimeBuiltins = new Set([
176
+ "child_process",
177
+ "crypto",
178
+ "fs",
179
+ "fs/promises",
180
+ "os",
181
+ "process",
182
+ "readline",
178
183
  ]);
184
+ const runtimeBuiltinNamespace = "dbx-tools-node-runtime-builtin";
179
185
  const bridgeSource = [
180
186
  "export const __pythonGet = (target, name) => target[name];",
181
187
  "export const __pythonInvokePositioned = (fn, entries) => {",
@@ -240,12 +246,18 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
240
246
  namespace: "dbx-tools-python",
241
247
  }));
242
248
  build.onLoad({ filter: /.*/, namespace: "dbx-tools-python" }, () => ({
249
+ contents: [bridgeSource, ...runtimeExports].join("\n"),
250
+ loader: "ts",
251
+ }));
252
+ build.onLoad({ filter: /.*/, namespace: runtimeBuiltinNamespace }, ({ path }) => ({
243
253
  contents: [
244
- `import ${JSON.stringify(resolve(shimRoot, "bootstrap.ts"))};`,
245
- bridgeSource,
246
- ...runtimeExports,
254
+ 'const runtime = globalThis[Symbol.for("@dbx-tools/node-runtime/runtime")];',
255
+ "if (!runtime) throw new Error('dbx-tools-node-runtime was not loaded before its generated bundle');",
256
+ `const builtin = runtime.modules[${JSON.stringify(`node:${path}`)}] ?? runtime.modules[${JSON.stringify(path)}];`,
257
+ `if (!builtin) throw new Error(${JSON.stringify(`dbx-tools-node-runtime does not provide ${path}`)});`,
258
+ "module.exports = builtin;",
247
259
  ].join("\n"),
248
- loader: "ts",
260
+ loader: "js",
249
261
  }));
250
262
  build.onLoad({ filter: /.*/, namespace: functionOverrideNamespace }, ({ path }) => {
251
263
  const overrides = overridesByModule.get(path);
@@ -272,8 +284,9 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
272
284
  return { path, namespace: functionOverrideNamespace };
273
285
  }
274
286
  const moduleName = path.replace(/^node:/, "");
275
- const shim = shimAliases.get(moduleName) ?? shimAliases.get(path);
276
- if (shim) return { path: shim };
287
+ if (runtimeBuiltins.has(moduleName)) {
288
+ return { path: moduleName, namespace: runtimeBuiltinNamespace };
289
+ }
277
290
  const standard = standardAliases[moduleName];
278
291
  return standard
279
292
  ? { path: Bun.resolveSync(standard, dirname(fileURLToPath(import.meta.url))) }
@@ -1172,125 +1185,27 @@ function pythonRuntimeLoader(source: string): string {
1172
1185
  "",
1173
1186
  "from __future__ import annotations",
1174
1187
  "",
1175
- "import asyncio",
1176
- "import inspect",
1177
- "from dataclasses import fields, is_dataclass",
1178
1188
  "from pathlib import Path",
1179
- "from threading import Lock",
1180
1189
  "from typing import Any",
1181
1190
  "",
1182
- "import pythonmonkey as pm",
1183
- "import pythonmonkey.require",
1191
+ `from ${PYTHON_NODE_RUNTIME_MODULE} import MISSING, RuntimeBundle, load_bundle`,
1184
1192
  "",
1185
- "_RUNTIME: Any | None = None",
1186
- "_LOCK = Lock()",
1187
- "MISSING = object()",
1193
+ "_RUNTIME: RuntimeBundle | None = None",
1188
1194
  "",
1189
1195
  "",
1190
- "def _load_runtime() -> Any:",
1191
- " try:",
1192
- " asyncio.get_running_loop()",
1193
- " except RuntimeError:",
1194
- " async def load() -> Any:",
1195
- ' return pm.require(str(Path(__file__).with_name("_runtime.js")))',
1196
- "",
1197
- " return asyncio.run(load())",
1198
- ' return pm.require(str(Path(__file__).with_name("_runtime.js")))',
1199
- "",
1200
- "",
1201
- "def get_runtime() -> Any:",
1196
+ "def get_runtime() -> RuntimeBundle:",
1202
1197
  " global _RUNTIME",
1203
1198
  " if _RUNTIME is None:",
1204
- " with _LOCK:",
1205
- " if _RUNTIME is None:",
1206
- " _RUNTIME = _load_runtime()",
1199
+ ' _RUNTIME = load_bundle(Path(__file__).with_name("_runtime.js"), bundle_id=__name__)',
1207
1200
  " return _RUNTIME",
1208
1201
  "",
1209
1202
  "",
1210
- "def _module(name: str) -> Any:",
1211
- ' return get_runtime()["__pythonModule"](name)',
1212
- "",
1213
- "",
1214
- "def _snake_to_camel(name: str) -> str:",
1215
- ' head, *tail = name.split("_")',
1216
- ' return head + "".join(part[:1].upper() + part[1:] for part in tail)',
1217
- "",
1218
- "",
1219
- "def _to_javascript(value: Any) -> Any:",
1220
- " if isinstance(value, _NodeObject):",
1221
- " return value._target",
1222
- " if is_dataclass(value) and not isinstance(value, type):",
1223
- " return {",
1224
- ' item.metadata.get("javascript_name", item.name): _to_javascript(field_value)',
1225
- " for item in fields(value)",
1226
- " if (field_value := getattr(value, item.name)) is not None",
1227
- " }",
1228
- " if isinstance(value, dict):",
1229
- " return {key: _to_javascript(item) for key, item in value.items()}",
1230
- " if isinstance(value, (list, tuple)):",
1231
- " return [_to_javascript(item) for item in value]",
1232
- " return value",
1233
- "",
1234
- "",
1235
- "def _from_javascript(value: Any) -> Any:",
1236
- " if isinstance(value, str):",
1237
- ' return value.encode("utf-8").decode("utf-8")',
1238
- " if value is None or isinstance(value, (int, float, bool)):",
1239
- " return value",
1240
- ' kind = get_runtime()["__pythonKind"](value)',
1241
- ' if kind == "instance":',
1242
- " return _NodeObject(value)",
1243
- ' if kind == "array":',
1244
- " return [_from_javascript(item) for item in value]",
1245
- ' if kind == "record":',
1246
- " return {str(key): _from_javascript(item) for key, item in value.items()}",
1247
- " return value",
1248
- "",
1249
- "",
1250
- "async def _resolve(value: Any) -> Any:",
1251
- " if inspect.isawaitable(value):",
1252
- " value = await value",
1253
- " return _from_javascript(value)",
1254
- "",
1255
- "",
1256
- "class _NodeObject:",
1257
- " def __init__(self, target: Any) -> None:",
1258
- " self._target = target",
1259
- "",
1260
- " def __getattr__(self, name: str) -> Any:",
1261
- " javascript_name = _snake_to_camel(name)",
1262
- ' value = get_runtime()["__pythonGet"](self._target, javascript_name)',
1263
- " if not callable(value):",
1264
- " return _from_javascript(value)",
1265
- "",
1266
- " async def invoke(*args: Any) -> Any:",
1267
- ' result = await get_runtime()["__pythonInvokeMethod"](',
1268
- " self._target,",
1269
- " javascript_name,",
1270
- " [_to_javascript(arg) for arg in args],",
1271
- " )",
1272
- ' if result["ok"]:',
1273
- ' return _from_javascript(result["value"])',
1274
- ' error = result["error"]',
1275
- " message = f\"{error['name']}: {error['message']}\"",
1276
- ' if error.get("stack"):',
1277
- " message = f\"{message}\\n{error['stack']}\"",
1278
- " raise RuntimeError(message)",
1279
- "",
1280
- " return invoke",
1281
- "",
1282
- "",
1283
1203
  "async def invoke_positioned(",
1284
1204
  " module: str,",
1285
1205
  " name: str,",
1286
1206
  " arguments: list[tuple[int, Any]],",
1287
1207
  ") -> Any:",
1288
- " return await _resolve(",
1289
- ' get_runtime()["__pythonInvokePositioned"](',
1290
- " _module(module)[name],",
1291
- " [[index, _to_javascript(value)] for index, value in arguments],",
1292
- " ),",
1293
- " )",
1208
+ " return await get_runtime().invoke_positioned(module, name, arguments)",
1294
1209
  "",
1295
1210
  "",
1296
1211
  "def invoke_positioned_sync(",
@@ -1298,12 +1213,7 @@ function pythonRuntimeLoader(source: string): string {
1298
1213
  " name: str,",
1299
1214
  " arguments: list[tuple[int, Any]],",
1300
1215
  ") -> Any:",
1301
- " return _from_javascript(",
1302
- ' get_runtime()["__pythonInvokePositioned"](',
1303
- " _module(module)[name],",
1304
- " [[index, _to_javascript(value)] for index, value in arguments],",
1305
- " ),",
1306
- " )",
1216
+ " return get_runtime().invoke_positioned_sync(module, name, arguments)",
1307
1217
  "",
1308
1218
  ].join("\n");
1309
1219
  }
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env -S bun
2
+ /** Keep the one package-owned PythonMonkey shim runtime current. */
3
+ import { existsSync } from "node:fs";
4
+ import { resolve } from "node:path";
5
+ import { parseArgs } from "node:util";
6
+ import { log } from "@dbx-tools/shared-core";
7
+ import { runTaskCommand } from "../src/_task-command.ts";
8
+ import { repoRoot } from "../src/packages.ts";
9
+ import { watchLoop } from "../src/watch.ts";
10
+ import { withWorkspaceMutationLock } from "../src/workspace-lock.ts";
11
+
12
+ const logger = log.logger("projen:python-node-runtime-watch");
13
+
14
+ export async function main(args: string[] = process.argv.slice(2)): Promise<void> {
15
+ const { values } = parseArgs({
16
+ args,
17
+ options: { project: { type: "string" } },
18
+ strict: true,
19
+ });
20
+ const project = values.project?.trim();
21
+ if (!project) throw new Error("--project is required");
22
+
23
+ const projectDirectory = resolve(repoRoot, project);
24
+ const buildScript = resolve(projectDirectory, "build-runtime.ts");
25
+ const shimDirectory = resolve(projectDirectory, "shims");
26
+ for (const path of [buildScript, shimDirectory]) {
27
+ if (!existsSync(path)) throw new Error(`Node runtime input does not exist: ${path}`);
28
+ }
29
+
30
+ const build = (): void => runTaskCommand(repoRoot, "bun", [buildScript]);
31
+ await withWorkspaceMutationLock(repoRoot, build);
32
+ logger.success("built shared Python Node runtime");
33
+
34
+ watchLoop("python-node-runtime", [buildScript, shimDirectory], () => {
35
+ build();
36
+ logger.success("rebuilt shared Python Node runtime");
37
+ });
38
+ }
39
+
40
+ if (import.meta.main) await main();