@dbx-tools/projen 0.9.47 → 0.9.51

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.
package/README.md CHANGED
@@ -184,17 +184,56 @@ polyfills. Python-host-backed adapters for process execution, files, crypto,
184
184
  OS values, and process state are included automatically, so packages do not
185
185
  configure a shim directory.
186
186
 
187
- The workspace creates `<name>:python-runtime` and
188
- `<name>:python-runtime:check`. Full synthesis regenerates bindings and removes
189
- stale `_generated/node` trees by reconciling them with the current
190
- `pyproject.toml` files, including the resynthesis triggered by a `.projenrc.ts`
191
- change.
187
+ The workspace creates `<name>:python-runtime`, `<name>:python-runtime:check`,
188
+ and `<name>:python-runtime:watch`. The focused watcher regenerates a Python
189
+ project when its bound Node package, a transitive workspace dependency, its
190
+ binding configuration, a function override, or a shared shim changes.
191
+ `bun run sync --watch` supervises this watcher alongside Projen and barrel
192
+ generation. Full synthesis regenerates bindings and removes stale
193
+ `_generated/node` trees by reconciling them with the current `pyproject.toml`
194
+ files, including the resynthesis triggered by a `.projenrc.ts` change.
192
195
  Built-in shims can make ordinary Node imports work under PythonMonkey.
193
196
  `functionOverrides` replaces named exports only in the generated runtime, so the
194
197
  Node package does not gain Python callbacks or alternate source files. A
195
198
  replacement may use a different export name through `handlerExport`; every
196
199
  other export continues to come from the original module.
197
200
 
201
+ ### Synchronize Pinned Python Sources
202
+
203
+ Use a package's `sync` field when published Python code must include a reviewed
204
+ subset of a Git repository instead of exposing a direct URL dependency:
205
+
206
+ ```ts
207
+ {
208
+ directory: "service",
209
+ description: "Python service with synchronized upstream code",
210
+ sync: [
211
+ {
212
+ name: "upstream_driver",
213
+ source:
214
+ "driver @ git+https://github.com/example/project.git@0123456789abcdef0123456789abcdef01234567#subdirectory=src/driver",
215
+ include: ["driver.py", "operations/**/*.py"],
216
+ exclude: ["**/test_*.py"],
217
+ replace: {
218
+ "from driver.": "from example.service._generated.sync.upstream_driver.",
219
+ },
220
+ },
221
+ ],
222
+ }
223
+ ```
224
+
225
+ Each source is written beneath
226
+ `<python-module>/_generated/sync/<name-or-owner-repository-hash>`. The generated
227
+ package task parses pip-style Git sources, filters files with the configured
228
+ globs, verifies every replacement matched, records the options and resolved
229
+ commit in `.sync.json`, and recursively marks the result read-only.
230
+
231
+ Synchronization uses a check-lock-check sequence and an atomic directory
232
+ replacement. A current pinned commit performs no network request. Branches and
233
+ tags use `git ls-remote` to detect upstream movement. Run the generated
234
+ `<package>:python-sync:check` task in validation, or pass `--force` to refresh a
235
+ current source intentionally.
236
+
198
237
  ## Customize Packages With Mixins
199
238
 
200
239
  ```ts
package/index.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // Hand edits are overwritten on the next watch; this file is read-only.
4
4
 
5
5
  export const PACKAGE_IDENTIFIER = "@dbx-tools/projen";
6
- export const PACKAGE_VERSION = "0.9.47";
6
+ export const PACKAGE_VERSION = "0.9.51";
7
7
  export * as barrels from "./src/barrels.ts";
8
8
  export * as bunApp from "./src/bun-app.ts";
9
9
  export * as bunWorkflow from "./src/bun-workflow.ts";
@@ -50,7 +50,7 @@ export type { DBXToolsProjectLanguage, DBXToolsProjectOptions, DBXToolsProject,
50
50
  export { PackageIdentifier, LICENSE, DBXToolsNodeProject, ROOT_INSTALL_ONLY_MIXIN, DBXToolsTypeScriptProject } from "./src/project-js.ts";
51
51
  export type { DBXToolsJavaScriptProject, DBXToolsReleaseMode, DiscoveredTypeScriptPackageOptions, DBXToolsJavaScriptProjectOptions, DBXToolsTypeScriptProjectOptions } from "./src/project-js.ts";
52
52
  export { DBXToolsPythonProject, DBXToolsPythonWorkspace } from "./src/project-py.ts";
53
- export type { PythonRepositoryOptions, PythonPackageOptions, DBXToolsPythonProjectOptions, PythonReleaseOptions, DBXToolsPythonWorkspaceOptions } from "./src/project-py.ts";
53
+ export type { PythonRepositoryOptions, PythonPackageOptions, PythonSourceSyncOptions, DBXToolsPythonProjectOptions, PythonReleaseOptions, DBXToolsPythonWorkspaceOptions } from "./src/project-py.ts";
54
54
  export { PROJEN_VERSION } from "./src/projen-version.ts";
55
55
  export { COMPILED_DIR, COMPILED_COMPILER_OPTIONS } from "./src/publish.ts";
56
56
  export { PYTHON_NODE_SHIM_ROOT } from "./src/python-node-bindings.ts";
package/package.json CHANGED
@@ -30,9 +30,9 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@clack/prompts": "^1.7.0",
33
- "@dbx-tools/core": "^0.9.47",
34
- "@dbx-tools/path": "^0.9.47",
35
- "@dbx-tools/shared-core": "^0.9.47",
33
+ "@dbx-tools/core": "^0.9.51",
34
+ "@dbx-tools/path": "^0.9.51",
35
+ "@dbx-tools/shared-core": "^0.9.51",
36
36
  "commander": "^15.0.0",
37
37
  "concurrently": "^10.0.3",
38
38
  "constructs": "^10.6.0",
@@ -54,12 +54,12 @@
54
54
  },
55
55
  "main": "index.ts",
56
56
  "license": "Apache-2.0",
57
- "version": "0.9.47",
57
+ "version": "0.9.51",
58
58
  "types": "index.ts",
59
59
  "type": "module",
60
60
  "exports": {
61
61
  ".": "./index.ts",
62
- "./release-packaging": "./tasks/lib/publish-npm.ts",
62
+ "./release-packaging": "./tasks/publish-npm.ts",
63
63
  "./package.json": "./package.json"
64
64
  },
65
65
  "files": [
package/src/project-py.ts CHANGED
@@ -3,7 +3,7 @@ import * as projectUtils from "@dbx-tools/core/project-utils";
3
3
  import { stringUtils, type OneOrMany } from "@dbx-tools/shared-core";
4
4
  import { Component, License, TextFile, type Project, github, javascript, python } from "projen";
5
5
  import { JobPermission, type Job, type JobStep } from "projen/lib/github/workflows-model";
6
- import { LICENSE, projectRepositoryUrl } from "./project-js.ts";
6
+ import { LICENSE, projectRepositoryUrl, taskCommand } from "./project-js.ts";
7
7
  import { isDBXToolsJavaScriptProject } from "./project-predicate.ts";
8
8
  import type { DBXToolsProject, DBXToolsProjectOptions } from "./project.ts";
9
9
  import { PythonNodeBundle, type PythonNodeBindingsOptions } from "./python-node-bundle.ts";
@@ -33,6 +33,8 @@ export interface PythonPackageOptions extends DBXToolsProjectOptions {
33
33
  readonly moduleRoot?: string;
34
34
  readonly description: string;
35
35
  readonly dependencies?: readonly string[];
36
+ /** Development dependencies emitted only in this package's dependency group. */
37
+ readonly devDependencies?: readonly string[];
36
38
  /** Workspace package directories rendered as standalone Git dependencies. */
37
39
  readonly internalDependencies?: readonly string[];
38
40
  readonly scripts?: Readonly<Record<string, string>>;
@@ -40,6 +42,17 @@ export interface PythonPackageOptions extends DBXToolsProjectOptions {
40
42
  readonly releaseEnvironment?: string;
41
43
  /** One or more build-time Node packages embedded through PythonMonkey. */
42
44
  readonly nodeBindings?: OneOrMany<PythonNodeBindingsOptions>;
45
+ /** Pinned Git source subsets synchronized into this package's generated tree. */
46
+ readonly sync?: readonly PythonSourceSyncOptions[];
47
+ }
48
+
49
+ /** One pip-style Git source synchronized into a generated Python package tree. */
50
+ export interface PythonSourceSyncOptions {
51
+ readonly name?: string;
52
+ readonly source: string;
53
+ readonly include?: readonly string[];
54
+ readonly exclude?: readonly string[];
55
+ readonly replace?: Readonly<Record<string, string>>;
43
56
  }
44
57
 
45
58
  interface ResolvedPythonPackageOptions extends PythonPackageOptions {
@@ -245,7 +258,11 @@ export class DBXToolsPythonProject extends python.PythonProject implements DBXTo
245
258
  this.tasks.removeTask("publish");
246
259
  this.tasks.removeTask("publish:test");
247
260
  this.uv.file.addDeletionOverride("project.authors");
248
- this.uv.file.addDeletionOverride("dependency-groups");
261
+ if (pkg.devDependencies?.length) {
262
+ this.uv.file.addOverride("dependency-groups.dev", [...pkg.devDependencies]);
263
+ } else {
264
+ this.uv.file.addDeletionOverride("dependency-groups");
265
+ }
249
266
  if (nodeBindings.length > 0) {
250
267
  const rendered = nodeBindings.map(renderPythonNodeBindings);
251
268
  this.uv.file.addOverride(
@@ -253,6 +270,9 @@ export class DBXToolsPythonProject extends python.PythonProject implements DBXTo
253
270
  rendered.length === 1 ? rendered[0] : rendered,
254
271
  );
255
272
  }
273
+ if (pkg.sync?.length) {
274
+ this.uv.file.addOverride("tool.dbx_tools.sync", [...pkg.sync]);
275
+ }
256
276
  this.uv.file.readonly = true;
257
277
 
258
278
  for (const path of [".gitattributes", ".gitignore"]) {
@@ -345,6 +365,21 @@ export class DBXToolsPythonWorkspace extends Component {
345
365
  projectDirectory: pythonPackagePath(this.repository, pkg.packageOptions.directory),
346
366
  });
347
367
  }
368
+ for (const pkg of this.packages) {
369
+ if (!pkg.packageOptions.sync?.length) continue;
370
+ const directory = pythonPackagePath(this.repository, pkg.packageOptions.directory);
371
+ const command = taskCommand("python-sync.ts", "--project", directory);
372
+ const sync = project.addTask(`${pkg.packageOptions.directory}:python-sync`, {
373
+ description: `Synchronize generated Git sources for ${pkg.packageOptions.name}`,
374
+ execArgs: command,
375
+ });
376
+ const check = project.addTask(`${pkg.packageOptions.directory}:python-sync:check`, {
377
+ description: `Verify generated Git sources for ${pkg.packageOptions.name}`,
378
+ execArgs: [...command, "--check"],
379
+ });
380
+ project.preCompileTask.spawn(sync);
381
+ project.testTask.spawn(check);
382
+ }
348
383
  for (const pkg of this.packages) {
349
384
  const pyproject = `/${pythonPackagePath(this.repository, pkg.packageOptions.directory)}/pyproject.toml`;
350
385
  project.gitignore.include(pyproject);
@@ -1,6 +1,6 @@
1
1
  /** Shared pyproject-backed configuration for PythonMonkey Node bindings. */
2
2
  import { readFileSync, rmSync } from "node:fs";
3
- import { dirname, join, relative, resolve } from "node:path";
3
+ import { dirname, join, relative, resolve, sep } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { find } from "@dbx-tools/path";
6
6
  import { object, stringUtils } from "@dbx-tools/shared-core";
@@ -58,7 +58,8 @@ export function generatePythonNodeBindings(projectRoot: string = resolveRepoRoot
58
58
  }
59
59
  }
60
60
 
61
- function pythonNodeBindingProjects(projectRoot: string): string[] {
61
+ /** Repository-relative Python projects that configure generated Node bindings. */
62
+ export function pythonNodeBindingProjects(projectRoot: string): string[] {
62
63
  return [
63
64
  ...find
64
65
  .findFiles("**/pyproject.toml", { cwd: projectRoot })
@@ -169,6 +170,35 @@ export function pythonNodeBindingWatchInputs(config: ResolvedPythonNodeBindings)
169
170
  ];
170
171
  }
171
172
 
173
+ /**
174
+ * Python projects whose generated runtimes depend on at least one changed path.
175
+ *
176
+ * The project list is computed once per watch batch and reused by the lock preflight
177
+ * and generator callback. This keeps unrelated edits on the no-lock fast path while
178
+ * ensuring each affected Python project is regenerated only once.
179
+ */
180
+ export function affectedPythonNodeBindingProjects(
181
+ root: string,
182
+ configs: readonly ResolvedPythonNodeBindings[],
183
+ changed: readonly string[],
184
+ ): string[] {
185
+ const affected = new Set<string>();
186
+ for (const config of configs) {
187
+ const inputs = pythonNodeBindingWatchInputs(config);
188
+ if (changed.some((candidate) => inputs.some((input) => containsPath(root, input, candidate)))) {
189
+ affected.add(config.project);
190
+ }
191
+ }
192
+ return [...affected].sort();
193
+ }
194
+
195
+ /** Whether `candidate` is an input path or one of its descendants. */
196
+ function containsPath(root: string, input: string, candidate: string): boolean {
197
+ const inputPath = resolve(root, input);
198
+ const candidatePath = resolve(root, candidate);
199
+ return candidatePath === inputPath || candidatePath.startsWith(`${inputPath}${sep}`);
200
+ }
201
+
172
202
  function validateBindings(configs: readonly ResolvedPythonNodeBindings[]): void {
173
203
  const names = configs.map(({ bindingName }) => bindingName);
174
204
  if (new Set(names).size !== names.length) {
@@ -45,6 +45,7 @@ export interface PythonNodeBundleOptions {
45
45
  export class PythonNodeBundle extends Component {
46
46
  readonly buildTask: Task;
47
47
  readonly checkTask: Task;
48
+ readonly watchTask: Task;
48
49
 
49
50
  constructor(project: Project, options: PythonNodeBundleOptions) {
50
51
  super(project);
@@ -57,6 +58,10 @@ export class PythonNodeBundle extends Component {
57
58
  description: `Verify generated Node bindings for Python package ${options.name}`,
58
59
  execArgs: [...command, "--check"],
59
60
  });
61
+ this.watchTask = project.addTask(`${options.name}:python-runtime:watch`, {
62
+ description: `Watch and regenerate Node bindings for Python package ${options.name}`,
63
+ execArgs: taskCommand("python-node-bindings-watch.ts", "--project", options.projectDirectory),
64
+ });
60
65
  if (options.test ?? true) project.testTask.spawn(this.checkTask);
61
66
  }
62
67
  }
@@ -1,6 +1,145 @@
1
1
  #!/usr/bin/env -S bun
2
- import { main } from "./lib/compile-workspace.ts";
2
+ /** Batch package type-checks and run package-owned compile lifecycles. */
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { availableParallelism } from "node:os";
5
+ import { join, resolve } from "node:path";
6
+ import * as exec from "@dbx-tools/core/exec";
7
+ import { log } from "@dbx-tools/shared-core";
3
8
 
4
- export { compilePlan, main } from "./lib/compile-workspace.ts";
9
+ const logger = log.logger("projen:compile");
10
+
11
+ interface PackageManifest {
12
+ readonly name?: string;
13
+ readonly scripts?: Record<string, string>;
14
+ readonly workspaces?: string[];
15
+ }
16
+
17
+ interface TaskManifest {
18
+ readonly tasks?: {
19
+ readonly "pre-compile"?: {
20
+ readonly steps?: Array<{ readonly exec?: string; readonly execArgs?: string[] }>;
21
+ };
22
+ readonly compile?: {
23
+ readonly steps?: Array<{ readonly exec?: string; readonly execArgs?: string[] }>;
24
+ };
25
+ readonly "post-compile"?: {
26
+ readonly steps?: Array<{ readonly exec?: string; readonly execArgs?: string[] }>;
27
+ };
28
+ };
29
+ }
30
+
31
+ export interface CompilePlan {
32
+ readonly typescriptConfigs: string[];
33
+ readonly customPackages: Array<{ readonly name: string; readonly directory: string }>;
34
+ }
35
+
36
+ function readJson<T>(path: string): T {
37
+ return JSON.parse(readFileSync(path, "utf8")) as T;
38
+ }
39
+
40
+ function plainTypeScriptBuild(steps: unknown): boolean {
41
+ if (!Array.isArray(steps) || steps.length !== 1) return false;
42
+ const step = steps[0] as { execArgs?: string[] };
43
+ if (!step || typeof step !== "object") return false;
44
+ return (
45
+ Object.keys(step).length === 1 &&
46
+ step.execArgs?.length === 2 &&
47
+ step.execArgs[0] === "tsc" &&
48
+ step.execArgs[1] === "--build"
49
+ );
50
+ }
51
+
52
+ function hasSteps(steps: unknown): boolean {
53
+ return Array.isArray(steps) && steps.length > 0;
54
+ }
55
+
56
+ /** Read the root's concrete workspace paths on every run, including newly added members. */
57
+ export function compilePlan(root: string): CompilePlan {
58
+ const workspace = readJson<PackageManifest>(join(root, "package.json"));
59
+ const typescriptConfigs: string[] = [];
60
+ const customPackages: CompilePlan["customPackages"] = [];
61
+ for (const member of workspace.workspaces ?? []) {
62
+ const directory = resolve(root, member);
63
+ const manifest = readJson<PackageManifest>(join(directory, "package.json"));
64
+ const script = manifest.scripts?.compile;
65
+ if (!script) continue;
66
+ const taskPath = join(directory, ".projen", "tasks.json");
67
+ const tasks = existsSync(taskPath) ? readJson<TaskManifest>(taskPath).tasks : undefined;
68
+ const plainCompile = plainTypeScriptBuild(tasks?.compile?.steps);
69
+ if (
70
+ script === "projen compile" &&
71
+ plainCompile &&
72
+ !hasSteps(tasks?.["pre-compile"]?.steps) &&
73
+ !hasSteps(tasks?.["post-compile"]?.steps)
74
+ ) {
75
+ typescriptConfigs.push(join(directory, "tsconfig.json"));
76
+ } else {
77
+ customPackages.push({ name: manifest.name ?? member, directory });
78
+ }
79
+ }
80
+ return { typescriptConfigs, customPackages };
81
+ }
82
+
83
+ const emoji = /[\p{Extended_Pictographic}\uFE0F\u200D]/gu;
84
+
85
+ export async function main(): Promise<void> {
86
+ const root = process.cwd();
87
+ const plan = compilePlan(root);
88
+ const jobs: Array<{ name: string; result: Promise<exec.ExecResult> }> = [];
89
+ if (plan.typescriptConfigs.length > 0) {
90
+ const batchCount = Math.min(4, availableParallelism(), plan.typescriptConfigs.length);
91
+ const batches = Array.from({ length: batchCount }, () => [] as string[]);
92
+ plan.typescriptConfigs.forEach((config, index) => batches[index % batchCount]!.push(config));
93
+ logger.info(
94
+ `type-checking ${plan.typescriptConfigs.length} workspaces in ${batchCount} batches`,
95
+ );
96
+ for (const [index, batch] of batches.entries()) {
97
+ jobs.push({
98
+ name: `TypeScript batch ${index + 1}`,
99
+ result: exec.spawn("bunx", ["tsc", "--build", ...batch], {
100
+ cwd: root,
101
+ stdin: "ignore",
102
+ stdout: "inherit",
103
+ stderr: "inherit",
104
+ }),
105
+ });
106
+ }
107
+ }
108
+ for (const pkg of plan.customPackages) {
109
+ jobs.push({
110
+ name: pkg.name,
111
+ result: compileCustomPackage(pkg),
112
+ });
113
+ }
114
+ const results = await Promise.allSettled(jobs.map((job) => job.result));
115
+ let failed = false;
116
+ for (const [index, result] of results.entries()) {
117
+ if (result.status === "rejected") {
118
+ logger.error(`${jobs[index]!.name} compile failed:`, result.reason);
119
+ failed = true;
120
+ } else if (result.value.exitCode !== 0) {
121
+ logger.error(`${jobs[index]!.name} compile exited with ${result.value.exitCode}`);
122
+ failed = true;
123
+ }
124
+ }
125
+ if (failed) process.exitCode = 1;
126
+ }
127
+
128
+ async function compileCustomPackage(pkg: {
129
+ readonly name: string;
130
+ readonly directory: string;
131
+ }): Promise<exec.ExecResult> {
132
+ let result: exec.ExecResult | undefined;
133
+ for (const task of ["pre-compile", "compile", "post-compile"]) {
134
+ result = await exec.spawn("bun", ["run", task], {
135
+ cwd: pkg.directory,
136
+ stdin: "ignore",
137
+ stdout: (line) => logger.info(`${pkg.name}: ${line.replace(emoji, "")}`),
138
+ stderr: (line) => logger.error(`${pkg.name}: ${line.replace(emoji, "")}`),
139
+ });
140
+ if (result.exitCode !== 0) return result;
141
+ }
142
+ return result!;
143
+ }
5
144
 
6
145
  if (import.meta.main) await main();
@@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url";
4
4
  import * as projectUtils from "@dbx-tools/core/project-utils";
5
5
  import { log, net } from "@dbx-tools/shared-core";
6
6
  import { activePythonIndexes, resolveLocalPypi } from "./python-registry.ts";
7
- import { runTaskCommandAsync } from "../../src/_task-command.ts";
7
+ import { runTaskCommandAsync } from "../src/_task-command.ts";
8
8
 
9
9
  const logger = log.logger("projen:local-publish");
10
10
 
@@ -33,6 +33,11 @@ export interface LocalPublishResult {
33
33
  readonly python: boolean;
34
34
  }
35
35
 
36
+ /** Resolve a sibling release task after task implementations are flattened. */
37
+ export function localPublishTaskPath(task: "publish.ts" | "publish-python.ts"): string {
38
+ return fileURLToPath(new URL(task, import.meta.url));
39
+ }
40
+
36
41
  /** Build and publish the current workspace directly to local registries. */
37
42
  export async function publishLocalRelease(
38
43
  options: LocalPublishOptions,
@@ -54,7 +59,7 @@ export async function publishLocalRelease(
54
59
  }
55
60
 
56
61
  if (localRegistry) {
57
- const publishNpmScript = fileURLToPath(new URL("../publish.ts", import.meta.url));
62
+ const publishNpmScript = localPublishTaskPath("publish.ts");
58
63
  logger.info(`building and publishing npm workspace to ${localRegistry}`);
59
64
  publishes.push(
60
65
  runTaskCommandAsync(root, process.execPath, [
@@ -67,7 +72,7 @@ export async function publishLocalRelease(
67
72
  }
68
73
 
69
74
  if (localPypi) {
70
- const publishPythonScript = fileURLToPath(new URL("../publish-python.ts", import.meta.url));
75
+ const publishPythonScript = localPublishTaskPath("publish-python.ts");
71
76
  logger.info(`building and publishing Python workspace to ${localPypi.publishUrl}`);
72
77
  publishes.push(
73
78
  runTaskCommandAsync(root, process.execPath, [