@dbx-tools/projen 0.9.19 → 0.9.28

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 (56) hide show
  1. package/README.md +83 -229
  2. package/index.ts +11 -18
  3. package/package.json +9 -5
  4. package/src/_task-command.ts +75 -0
  5. package/src/barrels.ts +1 -74
  6. package/src/module-exports.ts +175 -1
  7. package/src/openapi.ts +5 -72
  8. package/src/packages.ts +45 -0
  9. package/src/project-js.ts +5 -48
  10. package/src/project-predicate.ts +1 -2
  11. package/src/project-py.ts +87 -199
  12. package/src/project.ts +2 -2
  13. package/src/python-node-bindings.ts +138 -0
  14. package/src/python-node-bundle.ts +92 -0
  15. package/src/release-catalog.ts +40 -208
  16. package/src/{release-dispatch.ts → release-context.ts} +0 -11
  17. package/src/release.ts +74 -778
  18. package/src/scaffold.ts +2 -6
  19. package/src/workspace-version.ts +9 -121
  20. package/tasks/bump.ts +17 -34
  21. package/tasks/databricks-cli-assets.ts +129 -0
  22. package/tasks/local-publish.ts +61 -138
  23. package/tasks/publish-npm.ts +71 -30
  24. package/tasks/publish-python.ts +61 -148
  25. package/tasks/publish.ts +32 -182
  26. package/tasks/python-node-bindings-watch.ts +47 -0
  27. package/tasks/python-node-bindings.ts +1017 -0
  28. package/tasks/python-registry.ts +2 -9
  29. package/tasks/python-release.ts +74 -0
  30. package/tasks/release-version.ts +23 -2
  31. package/tasks/release.ts +99 -0
  32. package/tasks/sync.ts +13 -18
  33. package/tasks/version-check.ts +13 -72
  34. package/src/_rust-project.ts +0 -429
  35. package/src/_rust-release-workflow.ts +0 -698
  36. package/src/project-rs.ts +0 -1127
  37. package/src/release-plan.ts +0 -269
  38. package/src/release-please.ts +0 -242
  39. package/src/uniffi.ts +0 -65
  40. package/tasks/publish-uniffi-local.ts +0 -314
  41. package/tasks/release-assets.ts +0 -476
  42. package/tasks/release-bootstrap.ts +0 -59
  43. package/tasks/release-candidate.ts +0 -383
  44. package/tasks/release-manifest.ts +0 -273
  45. package/tasks/release-plan.ts +0 -89
  46. package/tasks/release-please.ts +0 -162
  47. package/tasks/release-pr.ts +0 -563
  48. package/tasks/release-request.ts +0 -166
  49. package/tasks/release-summary-units.ts +0 -75
  50. package/tasks/release-summary.ts +0 -381
  51. package/tasks/rust.ts +0 -178
  52. package/tasks/stamp-python.ts +0 -13
  53. package/tasks/uniffi-python.d.ts +0 -61
  54. package/tasks/uniffi-python.js +0 -191
  55. package/tasks/uniffi-release.mjs +0 -573
  56. package/tasks/uniffi.ts +0 -271
package/src/project-py.ts CHANGED
@@ -6,20 +6,16 @@ import { JobPermission } from "projen/lib/github/workflows-model";
6
6
  import { BUN_VERSION, bunCacheRestoreSteps, bunCacheSaveStep } from "./bun-workflow.ts";
7
7
  import { DBX_TOOLS_LICENSE, projectRepositoryUrl } from "./project-js.ts";
8
8
  import { isDBXToolsJavaScriptProject } from "./project-predicate.ts";
9
+ import { hasWorkspaceNodePackage } from "./python-node-bindings.ts";
10
+ import { PythonNodeBundle, type PythonNodeBindingsOptions } from "./python-node-bundle.ts";
9
11
  import type { DBXToolsProject, DBXToolsProjectOptions } from "./project.ts";
12
+ import { RELEASE_VERSION, releaseSourceSteps } from "./release-context.ts";
10
13
  import {
11
- RELEASE_SHA,
12
- RELEASE_TAG,
13
- RELEASE_VERSION,
14
- releaseSourceSteps,
15
- } from "./release-dispatch.ts";
16
- import {
17
- independentReleaseSetupSteps,
18
- registerIndependentPublicationJob,
14
+ refreshReleaseDocsDependencies,
19
15
  releaseTagPattern,
20
16
  releasePublishCondition,
21
- releaseStageCondition,
22
17
  tryReleaseWorkflow,
18
+ uvSetupStep,
23
19
  } from "./release.ts";
24
20
  import { readWorkspaceVersion } from "./workspace-version.ts";
25
21
 
@@ -35,13 +31,15 @@ export interface PythonPackageOptions extends DBXToolsProjectOptions {
35
31
  readonly directory: string;
36
32
  readonly name?: string;
37
33
  readonly module?: string;
34
+ /** Python import root. Defaults to `generated-src` for generated packages, otherwise `src`. */
35
+ readonly moduleRoot?: string;
38
36
  readonly description: string;
39
37
  readonly dependencies?: readonly string[];
40
38
  /** Workspace package directories rendered as standalone Git dependencies. */
41
39
  readonly internalDependencies?: readonly string[];
42
40
  readonly scripts?: Readonly<Record<string, string>>;
43
- /** Publish this package through the Rust UniFFI release flow instead of the Python workflow. */
44
- readonly uniffi?: boolean;
41
+ /** Build-time Node package embedded through PythonMonkey. */
42
+ readonly nodeBindings?: PythonNodeBindingsOptions;
45
43
  /** Generated source files excluded from strict static analysis. Package-relative. */
46
44
  readonly generatedSources?: readonly string[];
47
45
  /** Trusted publisher used outside the standard Python release workflow. */
@@ -114,8 +112,6 @@ const DEFAULT_DEV_DEPENDENCIES = [
114
112
  "ruff>=0.12,<1",
115
113
  ] as const;
116
114
 
117
- const quote = (value: string): string => JSON.stringify(value);
118
-
119
115
  /** Repository-relative path for a Python package directory. */
120
116
  export function pythonPackagePath(repository: PythonRepositoryOptions, directory: string): string {
121
117
  return `${repository.root ?? "packages/py"}/${directory}`;
@@ -197,7 +193,8 @@ export class DBXToolsPythonProject extends python.PythonProject implements DBXTo
197
193
  uv: {
198
194
  buildBackend: {
199
195
  moduleName: pkg.module,
200
- moduleRoot: "src",
196
+ moduleRoot:
197
+ pkg.moduleRoot ?? (pkg.nodeBindings?.layout === "package" ? "generated-src" : "src"),
201
198
  namespace: true,
202
199
  },
203
200
  },
@@ -214,8 +211,24 @@ export class DBXToolsPythonProject extends python.PythonProject implements DBXTo
214
211
  if (pkg.scripts) {
215
212
  this.uv.file.addOverride("project.scripts", pkg.scripts);
216
213
  }
217
- if (pkg.uniffi !== undefined) {
218
- this.uv.file.addOverride("tool.dbx_tools.config.uniffi", pkg.uniffi);
214
+ if (pkg.nodeBindings) {
215
+ this.uv.file.addOverride("tool.dbx_tools.node_bindings", {
216
+ package: pkg.nodeBindings.package,
217
+ ...(pkg.nodeBindings.entrypoint ? { entrypoint: pkg.nodeBindings.entrypoint } : {}),
218
+ ...(pkg.nodeBindings.layout ? { layout: pkg.nodeBindings.layout } : {}),
219
+ ...(pkg.nodeBindings.private ? { private: true } : {}),
220
+ ...(pkg.nodeBindings.shimRoot ? { shim_root: pkg.nodeBindings.shimRoot } : {}),
221
+ ...(pkg.nodeBindings.functionOverrides?.length
222
+ ? {
223
+ function_overrides: pkg.nodeBindings.functionOverrides.map((override) => ({
224
+ module: override.module,
225
+ export: override.export,
226
+ handler: override.handler,
227
+ ...(override.handlerExport ? { handler_export: override.handlerExport } : {}),
228
+ })),
229
+ }
230
+ : {}),
231
+ });
219
232
  }
220
233
  this.uv.file.readonly = true;
221
234
 
@@ -279,10 +292,7 @@ export class DBXToolsPythonWorkspace extends Component {
279
292
  }));
280
293
  const resolvedOptions = { ...options, packages };
281
294
  this.requiresPython = options.requiresPython ?? ">=3.10";
282
- this.version =
283
- isDBXToolsJavaScriptProject()(project) && project.releaseCatalog.mode === "independent"
284
- ? "0.0.0"
285
- : readWorkspaceVersion(project.outdir);
295
+ this.version = readWorkspaceVersion(project.outdir);
286
296
  this.file = this.emitWorkspace(project, resolvedOptions, scope);
287
297
  this.packages = packages.map(
288
298
  (pkg) =>
@@ -300,6 +310,14 @@ export class DBXToolsPythonWorkspace extends Component {
300
310
  : this.version,
301
311
  }),
302
312
  );
313
+ for (const pkg of this.packages) {
314
+ if (!pkg.packageOptions.nodeBindings) continue;
315
+ new PythonNodeBundle(project, {
316
+ name: pkg.packageOptions.directory,
317
+ projectDirectory: pythonPackagePath(this.repository, pkg.packageOptions.directory),
318
+ watch: hasWorkspaceNodePackage(project, pkg.packageOptions.nodeBindings.package),
319
+ });
320
+ }
303
321
  if (isDBXToolsJavaScriptProject()(project)) {
304
322
  for (const pkg of this.packages) {
305
323
  project.releaseCatalog.registerProject(pkg, {
@@ -321,7 +339,7 @@ export class DBXToolsPythonWorkspace extends Component {
321
339
  return {
322
340
  target: dependency.name,
323
341
  kind: "runtime" as const,
324
- requirement: pythonReleaseRequirement(version, dependency.uniffi === true),
342
+ requirement: pythonReleaseRequirement(version),
325
343
  propagation: "outside-range" as const,
326
344
  publishOrder: true,
327
345
  internal: true,
@@ -372,7 +390,9 @@ export class DBXToolsPythonWorkspace extends Component {
372
390
 
373
391
  private emitWorkspace(
374
392
  project: javascript.NodeProject,
375
- options: DBXToolsPythonWorkspaceOptions,
393
+ options: Omit<DBXToolsPythonWorkspaceOptions, "packages"> & {
394
+ readonly packages: readonly ResolvedPythonPackageOptions[];
395
+ },
376
396
  scope: string,
377
397
  ): python.PyprojectTomlFile {
378
398
  const testPaths = options.testPaths ?? [this.repository.root];
@@ -416,7 +436,7 @@ export class DBXToolsPythonWorkspace extends Component {
416
436
  const projectExcludes = [
417
437
  ...(options.pyreflyProjectExcludes ?? []),
418
438
  ...options.packages.flatMap((pkg) =>
419
- (pkg.generatedSources ?? []).map(
439
+ [...(pkg.generatedSources ?? []), ...pythonNodeGeneratedSources(pkg)].map(
420
440
  (source) => `${this.repository.root}/${pkg.directory}/${source}`,
421
441
  ),
422
442
  ),
@@ -459,154 +479,12 @@ export class DBXToolsPythonWorkspace extends Component {
459
479
  private addReleaseWorkflow(project: javascript.NodeProject, options: PythonReleaseOptions): void {
460
480
  if (!project.github || !isDBXToolsJavaScriptProject()(project)) return;
461
481
  const publications = this.publications(options);
462
- const uniffiPublications = this.uniffiPublications(options);
463
- const allPublications = [...publications, ...uniffiPublications];
482
+ const allPublications = publications;
464
483
  if (allPublications.length === 0) return;
465
- const usesRustArtifacts = uniffiPublications.length > 0;
466
484
  const workflow = tryReleaseWorkflow(project);
467
485
  if (!workflow) {
468
486
  throw new Error("Python release requires the root dbx-tools release mode");
469
487
  }
470
- if (project.releaseCatalog.mode === "independent") {
471
- workflow.addJob("build-python", {
472
- if: usesRustArtifacts
473
- ? "${{ always() && needs.release-plan.outputs.python == 'true' && needs.rust-build.result != 'failure' && needs.rust-build.result != 'cancelled' && (github.event_name == 'push' || inputs.stage == 'all' || inputs.stage == 'python') }}"
474
- : "${{ needs.release-plan.outputs.python == 'true' && (github.event_name == 'push' || inputs.stage == 'all' || inputs.stage == 'python') }}",
475
- needs: ["release-plan", ...(usesRustArtifacts ? ["rust-build"] : [])],
476
- runsOn: ["ubuntu-latest"],
477
- permissions: { actions: JobPermission.READ, contents: JobPermission.READ },
478
- timeoutMinutes: 20,
479
- env: { BUN_VERSION },
480
- steps: [
481
- ...independentReleaseSetupSteps(project),
482
- { name: "Setup uv", uses: "astral-sh/setup-uv@v7" },
483
- ...uniffiPublications.map((publication) => ({
484
- name: `Download ${publication.distribution} native wheels`,
485
- uses: "actions/download-artifact@v8",
486
- with: {
487
- pattern: `${publication.distribution}--*--python-wheel`,
488
- path: `dist/${publication.directory}`,
489
- "merge-multiple": true,
490
- },
491
- })),
492
- {
493
- name: "Build affected distributions",
494
- shell: "bash",
495
- run: publications
496
- .map(
497
- (publication) =>
498
- `if jq -e --arg package ${quote(publication.distribution)} '.pythonPackages[] | select(.identity == $package)' dist/release-plan.json >/dev/null; then uv build --package ${quote(publication.distribution)} --out-dir dist/${publication.directory}; fi`,
499
- )
500
- .join("\n"),
501
- },
502
- {
503
- name: "Validate affected distributions",
504
- run: "find dist -type f \\( -name '*.whl' -o -name '*.tar.gz' \\) -print0 | xargs -0 uvx twine check",
505
- },
506
- {
507
- name: "Upload distributions",
508
- uses: "actions/upload-artifact@v7",
509
- with: { name: "python-distributions", path: "dist", "retention-days": 7 },
510
- },
511
- ],
512
- });
513
- for (const publication of allPublications) {
514
- const dependencyJobs = (publication.dependencies ?? []).map(
515
- (dependency) => `publish-pypi-${dependency}`,
516
- );
517
- const dependencyCondition = dependencyJobs
518
- .map((job) => `needs.${job}.result != 'failure' && needs.${job}.result != 'cancelled'`)
519
- .join(" && ");
520
- workflow.addJob(`publish-pypi-${publication.directory}`, {
521
- if: `\${{ always() && needs.build-python.result == 'success' && contains(needs.release-plan.outputs.python_packages, '"identity":"${publication.distribution}"') && (github.event_name == 'push' || inputs.stage == 'all' || inputs.stage == 'python')${dependencyCondition ? ` && ${dependencyCondition}` : ""} }}`,
522
- needs: ["release-plan", "build-python", ...dependencyJobs],
523
- environment: {
524
- name: publication.environment,
525
- url:
526
- options.environmentUrl ??
527
- `https://pypi.org/project/${publication.distribution.replaceAll("_", "-")}/`,
528
- },
529
- runsOn: ["ubuntu-latest"],
530
- permissions: { idToken: JobPermission.WRITE },
531
- timeoutMinutes: 10,
532
- steps: [
533
- {
534
- name: "Download distributions",
535
- uses: "actions/download-artifact@v8",
536
- with: { name: "python-distributions", path: "dist" },
537
- },
538
- {
539
- name: `Publish ${publication.distribution} to PyPI`,
540
- uses: "pypa/gh-action-pypi-publish@release/v1",
541
- with: {
542
- "packages-dir": `dist/${publication.directory}`,
543
- "skip-existing": true,
544
- },
545
- },
546
- ],
547
- });
548
- registerIndependentPublicationJob(workflow, `publish-pypi-${publication.directory}`);
549
- }
550
- return;
551
- }
552
- workflow.addJob("build-python", {
553
- if: releaseStageCondition("python", ["needs.verify-context.result == 'success'"]),
554
- needs: ["verify-context"],
555
- runsOn: ["ubuntu-latest"],
556
- permissions: { contents: JobPermission.READ },
557
- timeoutMinutes: 20,
558
- env: { BUN_VERSION },
559
- steps: [
560
- ...releaseSourceSteps(),
561
- ...bunCacheRestoreSteps(project, {
562
- ignorePaths: project.workflowCacheIgnorePaths,
563
- }),
564
- { name: "Setup uv", uses: "astral-sh/setup-uv@v7" },
565
- { name: "Install release helpers", run: "bun install" },
566
- bunCacheSaveStep(),
567
- {
568
- name: "Download approved Python distributions",
569
- env: { GH_TOKEN: "${{ github.token }}", RELEASE_TAG },
570
- run: [
571
- "rm -rf dist/release-download",
572
- "mkdir -p dist/release-download",
573
- 'gh release download "$RELEASE_TAG" --pattern release-manifest.json --pattern SHA256SUMS --pattern "*.whl" --pattern "*.tar.gz" --dir dist/release-download',
574
- ].join("\n"),
575
- },
576
- ...allPublications.map((publication) => ({
577
- name: `Select ${publication.distribution} distributions`,
578
- env: {
579
- PACKAGE_NAME: publication.distribution.replaceAll("_", "-").toLowerCase(),
580
- RELEASE_SHA,
581
- RELEASE_TAG,
582
- RELEASE_VERSION,
583
- },
584
- run: [
585
- "bun node_modules/@dbx-tools/projen/tasks/release-manifest.ts verify \\",
586
- " --directory dist/release-download \\",
587
- ' --tag "$RELEASE_TAG" \\',
588
- ' --sha "$RELEASE_SHA" \\',
589
- ' --version "$RELEASE_VERSION" \\',
590
- " --kind pypi \\",
591
- ' --package "$PACKAGE_NAME" \\',
592
- ` --output dist/${publication.directory}`,
593
- ].join("\n"),
594
- })),
595
- {
596
- name: "Validate approved distributions",
597
- run: `find ${allPublications.map((publication) => `dist/${publication.directory}`).join(" ")} -type f \\( -name '*.whl' -o -name '*.tar.gz' \\) -print0 | xargs -0 uvx twine check`,
598
- },
599
- {
600
- name: "Upload distributions",
601
- uses: "actions/upload-artifact@v7",
602
- with: {
603
- name: "python-distributions",
604
- path: allPublications.map((publication) => `dist/${publication.directory}`).join("\n"),
605
- "retention-days": 7,
606
- },
607
- },
608
- ],
609
- });
610
488
  for (const publication of allPublications) {
611
489
  const dependencyJobs = (publication.dependencies ?? []).map(
612
490
  (dependency) => `publish-pypi-${dependency}`,
@@ -614,10 +492,9 @@ export class DBXToolsPythonWorkspace extends Component {
614
492
  workflow.addJob(`publish-pypi-${publication.directory}`, {
615
493
  if: releasePublishCondition("python", [
616
494
  "needs.verify-context.result == 'success'",
617
- "needs.build-python.result == 'success'",
618
495
  ...dependencyJobs.map((job) => `needs['${job}'].result == 'success'`),
619
496
  ]),
620
- needs: ["verify-context", "build-python", ...dependencyJobs],
497
+ needs: ["verify-context", ...dependencyJobs],
621
498
  environment: {
622
499
  name: publication.environment,
623
500
  url:
@@ -627,11 +504,24 @@ export class DBXToolsPythonWorkspace extends Component {
627
504
  runsOn: ["ubuntu-latest"],
628
505
  permissions: { idToken: JobPermission.WRITE },
629
506
  timeoutMinutes: 10,
507
+ env: { BUN_VERSION },
630
508
  steps: [
509
+ ...releaseSourceSteps(),
510
+ ...bunCacheRestoreSteps(project, {
511
+ ignorePaths: project.workflowCacheIgnorePaths,
512
+ }),
513
+ uvSetupStep(),
514
+ { name: "Install release helpers", run: "bun install --frozen-lockfile" },
515
+ bunCacheSaveStep(),
631
516
  {
632
- name: "Download distributions",
633
- uses: "actions/download-artifact@v8",
634
- with: { name: "python-distributions", path: "dist" },
517
+ name: `Build ${publication.distribution} distributions`,
518
+ env: { RELEASE_VERSION },
519
+ run: [
520
+ 'bun node_modules/@dbx-tools/projen/tasks/publish-python.ts "$RELEASE_VERSION"',
521
+ `--root ${JSON.stringify(this.repository.root)}`,
522
+ `--package ${JSON.stringify(publication.directory)}`,
523
+ `--output dist/${publication.directory}`,
524
+ ].join(" \\\n "),
635
525
  },
636
526
  {
637
527
  name: `Publish ${publication.distribution} to PyPI`,
@@ -644,32 +534,17 @@ export class DBXToolsPythonWorkspace extends Component {
644
534
  ],
645
535
  });
646
536
  }
537
+ refreshReleaseDocsDependencies(project);
647
538
  }
648
539
 
649
540
  private publications(options: PythonReleaseOptions): readonly PythonPublication[] {
650
- return this.packages
651
- .filter((pkg) => pkg.packageOptions.uniffi !== true)
652
- .map((pkg) => ({
653
- directory: pkg.packageOptions.directory,
654
- distribution: pkg.packageOptions.name,
655
- environment:
656
- options.environments?.[pkg.packageOptions.name] ?? `pypi-${pkg.packageOptions.name}`,
657
- }));
658
- }
659
-
660
- private uniffiPublications(options: PythonReleaseOptions): readonly PythonPublication[] {
661
- return this.packages
662
- .filter((pkg) => pkg.packageOptions.uniffi === true)
663
- .map((pkg) => ({
664
- directory: pkg.packageOptions.directory,
665
- distribution: pkg.packageOptions.name,
666
- environment:
667
- pkg.packageOptions.trustedPublisher?.environment ??
668
- options.environments?.[pkg.packageOptions.name] ??
669
- `pypi-${pkg.packageOptions.name}`,
670
- artifacts: pkg.packageOptions.trustedPublisher?.artifacts,
671
- dependencies: pkg.packageOptions.internalDependencies,
672
- }));
541
+ return this.packages.map((pkg) => ({
542
+ directory: pkg.packageOptions.directory,
543
+ distribution: pkg.packageOptions.name,
544
+ environment:
545
+ options.environments?.[pkg.packageOptions.name] ?? `pypi-${pkg.packageOptions.name}`,
546
+ dependencies: pkg.packageOptions.internalDependencies,
547
+ }));
673
548
  }
674
549
 
675
550
  private trustedPublisherPublications(
@@ -697,11 +572,10 @@ export class DBXToolsPythonWorkspace extends Component {
697
572
  ): void {
698
573
  const repository = this.githubRepository();
699
574
  const publications = this.trustedPublisherPublications(options);
700
- const releaseTag = isDBXToolsJavaScriptProject()(project)
701
- ? project.releaseCatalog.mode === "independent"
702
- ? "*-v*"
703
- : releaseTagPattern(project)
704
- : "v*";
575
+ const releaseTag =
576
+ isDBXToolsJavaScriptProject()(project) && tryReleaseWorkflow(project)
577
+ ? releaseTagPattern(project)
578
+ : "v*";
705
579
  const linesBeforeAuthentication = [
706
580
  "# PyPI Trusted Publisher Setup Instructions",
707
581
  "",
@@ -814,3 +688,17 @@ export class DBXToolsPythonWorkspace extends Component {
814
688
  return { owner: match[1], name: match[2] };
815
689
  }
816
690
  }
691
+
692
+ function pythonNodeGeneratedSources(pkg: ResolvedPythonPackageOptions): string[] {
693
+ if (!pkg.nodeBindings) return [];
694
+ const moduleRoot =
695
+ pkg.moduleRoot ?? (pkg.nodeBindings.layout === "package" ? "generated-src" : "src");
696
+ const moduleDirectory = `${moduleRoot}/${pkg.module.replaceAll(".", "/")}`;
697
+ const generatedDirectory =
698
+ pkg.nodeBindings.layout === "package" ? moduleDirectory : `${moduleDirectory}/_generated`;
699
+ return [
700
+ `${generatedDirectory}/_runtime.js`,
701
+ `${generatedDirectory}/node_bindings.py`,
702
+ `${generatedDirectory}/__init__.py`,
703
+ ];
704
+ }
package/src/project.ts CHANGED
@@ -12,10 +12,10 @@ import * as projectPredicate from "./project-predicate.ts";
12
12
 
13
13
  export * from "./project-js.ts";
14
14
  export * from "./project-py.ts";
15
- export * from "./project-rs.ts";
15
+ export * from "./python-node-bundle.ts";
16
16
 
17
17
  /** Runtime family implemented by a dbx-tools project. */
18
- export type DBXToolsProjectLanguage = "javascript" | "python" | "rust";
18
+ export type DBXToolsProjectLanguage = "javascript" | "python";
19
19
 
20
20
  /** Options shared by every dbx-tools project implementation. */
21
21
  export interface DBXToolsProjectOptions extends Partial<
@@ -0,0 +1,138 @@
1
+ /** Shared pyproject-backed configuration for PythonMonkey Node bindings. */
2
+ import { readFileSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import { object } from "@dbx-tools/shared-core";
5
+ import type { Project } from "projen";
6
+ import { parse } from "smol-toml";
7
+ import { workspaceDependencyDirectories } from "./packages.ts";
8
+
9
+ export interface ResolvedPythonNodeFunctionOverride {
10
+ readonly handlerExport: string;
11
+ readonly handlerFile: string;
12
+ readonly targetExport: string;
13
+ readonly targetModule: string;
14
+ }
15
+
16
+ export interface ResolvedPythonNodeBindings {
17
+ readonly bindingsPackageOutput: string;
18
+ readonly bindingsOutput: string;
19
+ readonly functionOverrides: readonly ResolvedPythonNodeFunctionOverride[];
20
+ readonly entrypoint: string;
21
+ readonly moduleDirectory: string;
22
+ readonly layout: "package" | "submodule";
23
+ readonly private: boolean;
24
+ readonly package: string;
25
+ readonly project: string;
26
+ readonly projectDirectory: string;
27
+ readonly pyproject: string;
28
+ readonly runtimeOutput: string;
29
+ readonly shimRoot?: string;
30
+ readonly workspaceDirectories: readonly string[];
31
+ }
32
+
33
+ /** Resolve one Python package's Node binding configuration and conventional outputs. */
34
+ export function resolvePythonNodeBindings(
35
+ root: string,
36
+ project: string,
37
+ ): ResolvedPythonNodeBindings {
38
+ const projectDirectory = resolve(root, project);
39
+ const pyproject = resolve(projectDirectory, "pyproject.toml");
40
+ const manifest = record(parse(readFileSync(pyproject, "utf8")), "pyproject.toml");
41
+ const tool = record(manifest.tool, "tool");
42
+ const dbxTools = record(tool.dbx_tools, "tool.dbx_tools");
43
+ const bindings = record(dbxTools.node_bindings, "tool.dbx_tools.node_bindings");
44
+ const uv = record(tool.uv, "tool.uv");
45
+ const backend = record(uv["build-backend"], "tool.uv.build-backend");
46
+ const packageName = requiredString(bindings.package, "tool.dbx_tools.node_bindings.package");
47
+ const entrypoint =
48
+ optionalString(bindings.entrypoint, "tool.dbx_tools.node_bindings.entrypoint") ?? packageName;
49
+ const layout = nodeBindingsLayout(bindings.layout);
50
+ const moduleName = requiredString(backend["module-name"], "tool.uv.build-backend.module-name");
51
+ const moduleRoot = requiredString(backend["module-root"], "tool.uv.build-backend.module-root");
52
+ const configuredOverrides = bindings.function_overrides ?? [];
53
+ if (!Array.isArray(configuredOverrides)) {
54
+ throw new Error("tool.dbx_tools.node_bindings.function_overrides must be an array");
55
+ }
56
+ const moduleDirectory = resolve(projectDirectory, moduleRoot, ...moduleName.split("."));
57
+ const generatedDirectory =
58
+ layout === "package" ? moduleDirectory : join(moduleDirectory, "_generated");
59
+ const shimRoot = optionalString(bindings.shim_root, "tool.dbx_tools.node_bindings.shim_root");
60
+ return {
61
+ project,
62
+ projectDirectory,
63
+ pyproject,
64
+ package: packageName,
65
+ entrypoint,
66
+ layout,
67
+ private: optionalBoolean(bindings.private, "tool.dbx_tools.node_bindings.private") ?? false,
68
+ moduleDirectory,
69
+ runtimeOutput: join(generatedDirectory, "_runtime.js"),
70
+ bindingsOutput: join(generatedDirectory, "node_bindings.py"),
71
+ bindingsPackageOutput: join(generatedDirectory, "__init__.py"),
72
+ ...(shimRoot ? { shimRoot: resolve(root, shimRoot) } : {}),
73
+ functionOverrides: configuredOverrides.map((candidate, index) => {
74
+ const path = `tool.dbx_tools.node_bindings.function_overrides[${index}]`;
75
+ const override = record(candidate, path);
76
+ const targetExport = requiredString(override.export, `${path}.export`);
77
+ return {
78
+ targetModule: requiredString(override.module, `${path}.module`),
79
+ targetExport,
80
+ handlerFile: resolve(root, requiredString(override.handler, `${path}.handler`)),
81
+ handlerExport:
82
+ optionalString(override.handler_export, `${path}.handler_export`) ?? targetExport,
83
+ };
84
+ }),
85
+ workspaceDirectories: workspaceDependencyDirectories(packageName, root),
86
+ };
87
+ }
88
+
89
+ function nodeBindingsLayout(value: unknown): "package" | "submodule" {
90
+ if (value === undefined) return "submodule";
91
+ if (value === "package" || value === "submodule") return value;
92
+ throw new Error("tool.dbx_tools.node_bindings.layout must be package or submodule");
93
+ }
94
+
95
+ /** All paths that can change a workspace-backed generated Node runtime. */
96
+ export function pythonNodeBindingWatchInputs(config: ResolvedPythonNodeBindings): string[] {
97
+ if (config.workspaceDirectories.length === 0) {
98
+ throw new Error(`${config.package} is not a workspace package and cannot be watched`);
99
+ }
100
+ return [
101
+ config.pyproject,
102
+ ...config.workspaceDirectories,
103
+ ...(config.shimRoot ? [config.shimRoot] : []),
104
+ ...config.functionOverrides.map(({ handlerFile }) => handlerFile),
105
+ ];
106
+ }
107
+
108
+ /** Whether a Projen tree already contains the configured Node package. */
109
+ export function hasWorkspaceNodePackage(project: Project, packageName: string): boolean {
110
+ return (
111
+ project.name === packageName || project.subprojects.some((child) => child.name === packageName)
112
+ );
113
+ }
114
+
115
+ function record(value: unknown, path: string): Record<string, unknown> {
116
+ if (!object.isRecord(value)) throw new Error(`${path} must be a table`);
117
+ return value;
118
+ }
119
+
120
+ function requiredString(value: unknown, path: string): string {
121
+ const parsed = optionalString(value, path);
122
+ if (!parsed) throw new Error(`${path} must be a non-empty string`);
123
+ return parsed;
124
+ }
125
+
126
+ function optionalString(value: unknown, path: string): string | undefined {
127
+ if (value === undefined) return undefined;
128
+ if (typeof value !== "string" || !value.trim()) {
129
+ throw new Error(`${path} must be a non-empty string`);
130
+ }
131
+ return value.trim();
132
+ }
133
+
134
+ function optionalBoolean(value: unknown, path: string): boolean | undefined {
135
+ if (value === undefined) return undefined;
136
+ if (typeof value !== "boolean") throw new Error(`${path} must be a boolean`);
137
+ return value;
138
+ }
@@ -0,0 +1,92 @@
1
+ import { Component, type Project, type Task } from "projen";
2
+ import { taskScript } from "./project-js.ts";
3
+ import { isDBXToolsJavaScriptProject } from "./project-predicate.ts";
4
+
5
+ /** One function replaced only in a generated PythonMonkey bundle. */
6
+ export interface PythonNodeFunctionOverride {
7
+ /** Module specifier imported by the ordinary Node source. */
8
+ readonly module: string;
9
+ /** Named export to replace. */
10
+ readonly export: string;
11
+ /** Repository-relative TypeScript file containing the replacement. */
12
+ readonly handler: string;
13
+ /** Named export in {@link handler}. Defaults to {@link export}. */
14
+ readonly handlerExport?: string;
15
+ }
16
+
17
+ /** Node package embedded into one Python distribution through PythonMonkey. */
18
+ export interface PythonNodeBindingsOptions {
19
+ /** Public Node package specifier, such as `@dbx-tools/auth`. */
20
+ readonly package: string;
21
+ /** Optional portable package subpath to bind. Defaults to {@link package}. */
22
+ readonly entrypoint?: string;
23
+ /** Generated bindings own the package or live under `_generated`. Defaults to `submodule`. */
24
+ readonly layout?: "package" | "submodule";
25
+ /** Keep generated symbols out of the generated package `__init__`. Defaults to false. */
26
+ readonly private?: boolean;
27
+ /** Repository-relative directory containing Node built-in shims. */
28
+ readonly shimRoot?: string;
29
+ /** Functions replaced only while generating the Python runtime. */
30
+ readonly functionOverrides?: readonly PythonNodeFunctionOverride[];
31
+ }
32
+
33
+ /** Options for pyproject-driven PythonMonkey generation tasks. */
34
+ export interface PythonNodeBundleOptions {
35
+ /** Stable task-name prefix, such as `auth`. */
36
+ readonly name: string;
37
+ /** Repository-relative Python project containing `pyproject.toml`. */
38
+ readonly projectDirectory: string;
39
+ /** Add the freshness check to the project's test task. Defaults to true. */
40
+ readonly test?: boolean;
41
+ /** Regenerate during the root workspace watch loop. Defaults to false. */
42
+ readonly watch?: boolean;
43
+ }
44
+
45
+ /** Owns paired generation and freshness-check tasks for one Python package. */
46
+ export class PythonNodeBundle extends Component {
47
+ readonly buildTask: Task;
48
+ readonly checkTask: Task;
49
+ readonly watchTask?: Task;
50
+
51
+ constructor(project: Project, options: PythonNodeBundleOptions) {
52
+ super(project);
53
+ const command = taskScript(
54
+ project,
55
+ "python-node-bindings.ts",
56
+ `--project ${shellQuote(options.projectDirectory)}`,
57
+ );
58
+ this.buildTask = project.addTask(`${options.name}:python-runtime`, {
59
+ description: `Generate Node bindings for Python package ${options.name}`,
60
+ exec: command,
61
+ });
62
+ this.checkTask = project.addTask(`${options.name}:python-runtime:check`, {
63
+ description: `Verify generated Node bindings for Python package ${options.name}`,
64
+ exec: `${command} --check`,
65
+ });
66
+ if (options.test ?? true) project.testTask.spawn(this.checkTask);
67
+ if (options.watch) {
68
+ this.watchTask = project.addTask(`${options.name}:python-runtime:watch`, {
69
+ description: `Regenerate Node bindings for Python package ${options.name} on changes`,
70
+ exec: taskScript(
71
+ project,
72
+ "python-node-bindings-watch.ts",
73
+ `--project ${shellQuote(options.projectDirectory)}`,
74
+ ),
75
+ });
76
+ if (isDBXToolsJavaScriptProject()(project)) {
77
+ const configured = project.dbxToolsConfig.pythonNodeBindings;
78
+ const projects = Array.isArray(configured)
79
+ ? configured.filter((value): value is string => typeof value === "string")
80
+ : [];
81
+ project.dbxToolsConfig.pythonNodeBindings = [
82
+ ...new Set([...projects, options.projectDirectory]),
83
+ ];
84
+ }
85
+ }
86
+ }
87
+ }
88
+
89
+ function shellQuote(value: string): string {
90
+ if (/^[A-Za-z0-9_./:@=+#-]+$/.test(value)) return value;
91
+ return `'${value.replaceAll("'", `'\\''`)}'`;
92
+ }