@dbx-tools/projen 0.6.153 → 0.6.160

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/src/project-py.ts CHANGED
@@ -1,11 +1,15 @@
1
1
  /** Reusable uv workspace generation for Python packages hosted in a projen tree. */
2
+ import { project as coreProject } from "@dbx-tools/core";
2
3
  import { string } from "@dbx-tools/shared-core";
3
4
  import { Component, TextFile, type Project, javascript, python, vscode } from "projen";
4
5
  import type { IResolver } from "projen/lib/file";
5
6
  import { GithubWorkflow } from "projen/lib/github";
6
7
  import { JobPermission } from "projen/lib/github/workflows-model";
7
8
  import { parse, stringify } from "smol-toml";
9
+ import { projectReleaseBranch, projectRepositoryUrl } from "./project-js.ts";
8
10
  import type { DBXToolsProject, DBXToolsProjectOptions } from "./project.ts";
11
+ import { isDBXToolsJavaScriptProject } from "./project-predicate.ts";
12
+ import { DOWNSTREAM_RELEASE_EVENT, RELEASE_TAG, releaseSourceSteps } from "./release-dispatch.ts";
9
13
  import { readWorkspaceVersion } from "./workspace-version.ts";
10
14
 
11
15
  /** Git location used by direct `#subdirectory=` package dependencies. */
@@ -18,17 +22,26 @@ export interface PythonRepositoryOptions {
18
22
  /** One independently installable Python package in the uv workspace. */
19
23
  export interface PythonPackageOptions extends DBXToolsProjectOptions {
20
24
  readonly directory: string;
21
- readonly name: string;
22
- readonly module: string;
25
+ readonly name?: string;
26
+ readonly module?: string;
23
27
  readonly description: string;
24
28
  readonly dependencies?: readonly string[];
29
+ /** Workspace package directories rendered as standalone Git dependencies. */
30
+ readonly internalDependencies?: readonly string[];
25
31
  readonly scripts?: Readonly<Record<string, string>>;
26
32
  /** Keep this package unpublished and out of public docs and releases. */
27
33
  readonly private?: boolean;
34
+ /** Generated source files excluded from strict static analysis. Package-relative. */
35
+ readonly generatedSources?: readonly string[];
28
36
  /** Trusted publisher used outside the standard Python release workflow. */
29
37
  readonly trustedPublisher?: PythonTrustedPublisherOptions;
30
38
  }
31
39
 
40
+ interface ResolvedPythonPackageOptions extends PythonPackageOptions {
41
+ readonly name: string;
42
+ readonly module: string;
43
+ }
44
+
32
45
  /** GitHub Actions publisher for a Python package released by another workflow. */
33
46
  export interface PythonTrustedPublisherOptions {
34
47
  readonly workflowName: string;
@@ -39,7 +52,7 @@ export interface PythonTrustedPublisherOptions {
39
52
  /** Options for one projen-native Python workspace member. */
40
53
  export interface DBXToolsPythonProjectOptions extends DBXToolsProjectOptions {
41
54
  readonly parent: Project;
42
- readonly package: PythonPackageOptions;
55
+ readonly package: ResolvedPythonPackageOptions;
43
56
  readonly repository: Required<PythonRepositoryOptions>;
44
57
  readonly requiresPython: string;
45
58
  /** Workspace version copied onto this package's `pyproject.toml`. */
@@ -67,7 +80,9 @@ interface PythonPublication {
67
80
  /** Options for {@link DBXToolsPythonWorkspace}. */
68
81
  export interface DBXToolsPythonWorkspaceOptions {
69
82
  readonly packages: readonly PythonPackageOptions[];
70
- readonly repository: PythonRepositoryOptions;
83
+ readonly repository?: PythonRepositoryOptions;
84
+ /** Repository-relative Python package root. Defaults to `packages/py`. */
85
+ readonly root?: string;
71
86
  /** Workspace packages exposed as commands from the repository root. */
72
87
  readonly dependencies?: readonly string[];
73
88
  readonly requiresPython?: string;
@@ -126,6 +141,11 @@ export function pythonGitDependency(
126
141
  return `${name} @ git+${repository.url}@${repository.ref ?? "main"}#subdirectory=${pythonPackagePath(repository, directory)}`;
127
142
  }
128
143
 
144
+ /** Derive a dotted Python module from an npm-style scope and package directory. */
145
+ export function pythonModuleName(scope: string, directory: string): string {
146
+ return [scope, ...directory.split("/")].map((part) => part.replaceAll("-", "_")).join(".");
147
+ }
148
+
129
149
  function projectVscode(project: Project): vscode.VsCode | undefined {
130
150
  return (project as Project & { readonly vscode?: vscode.VsCode }).vscode;
131
151
  }
@@ -133,7 +153,7 @@ function projectVscode(project: Project): vscode.VsCode | undefined {
133
153
  /** A Python package implemented with projen's `PythonProject` and uv backend. */
134
154
  export class DBXToolsPythonProject extends python.PythonProject implements DBXToolsProject {
135
155
  readonly language = "python" as const;
136
- readonly packageOptions: PythonPackageOptions;
156
+ readonly packageOptions: ResolvedPythonPackageOptions;
137
157
  readonly uv: python.Uv;
138
158
 
139
159
  constructor(options: DBXToolsPythonProjectOptions) {
@@ -222,17 +242,49 @@ export class DBXToolsPythonWorkspace extends Component {
222
242
 
223
243
  constructor(project: javascript.NodeProject, options: DBXToolsPythonWorkspaceOptions) {
224
244
  super(project);
245
+ const scope = isDBXToolsJavaScriptProject()(project)
246
+ ? string.toSlug(project.scope)
247
+ : string.toSlug(project.name).replace(/-root$/, "");
248
+ const repositoryUrl =
249
+ options.repository?.url ??
250
+ projectRepositoryUrl(project) ??
251
+ coreProject.repositoryUrl(project.outdir);
252
+ if (!repositoryUrl) {
253
+ throw new Error("Python workspace repository URL was not configured or detected");
254
+ }
225
255
  this.repository = {
226
- url: options.repository.url,
227
- ref: options.repository.ref ?? "main",
228
- root: options.repository.root ?? "packages/py",
256
+ url: repositoryUrl.endsWith(".git") ? repositoryUrl : `${repositoryUrl}.git`,
257
+ ref: options.repository?.ref ?? "main",
258
+ root: options.root ?? options.repository?.root ?? "packages/py",
229
259
  };
260
+ const packageIdentities: ResolvedPythonPackageOptions[] = options.packages.map((pkg) => ({
261
+ ...pkg,
262
+ name: pkg.name ?? `${scope}-${string.toSlug(pkg.directory)}`,
263
+ module: pkg.module ?? pythonModuleName(scope, pkg.directory),
264
+ }));
265
+ const packagesByDirectory = new Map(packageIdentities.map((pkg) => [pkg.directory, pkg]));
266
+ const packages = packageIdentities.map((pkg) => ({
267
+ ...pkg,
268
+ dependencies: [
269
+ ...(pkg.dependencies ?? []),
270
+ ...(pkg.internalDependencies ?? []).map((directory) => {
271
+ const dependency = packagesByDirectory.get(directory);
272
+ if (!dependency) {
273
+ throw new Error(
274
+ `Python package ${pkg.directory} references unknown internal package ${directory}`,
275
+ );
276
+ }
277
+ return pythonGitDependency(this.repository, dependency.name, dependency.directory);
278
+ }),
279
+ ],
280
+ }));
281
+ const resolvedOptions = { ...options, packages };
230
282
  this.requiresPython = options.requiresPython ?? ">=3.10";
231
283
  // The single workspace version, copied from the root `VERSION` file so Python
232
284
  // members carry the same number as their JS siblings.
233
285
  this.version = readWorkspaceVersion(project.outdir);
234
- this.file = this.emitWorkspace(project, options);
235
- this.packages = options.packages.map(
286
+ this.file = this.emitWorkspace(project, resolvedOptions, scope);
287
+ this.packages = packages.map(
236
288
  (pkg) =>
237
289
  new DBXToolsPythonProject({
238
290
  parent: project,
@@ -248,9 +300,18 @@ export class DBXToolsPythonWorkspace extends Component {
248
300
  project.gitattributes.addAttributes(pyproject, "linguist-generated");
249
301
  project.prettier?.addIgnorePattern(pyproject.slice(1));
250
302
  }
251
- this.addTasks(project, options);
303
+ project.gitignore.addPatterns(
304
+ ".venv/",
305
+ ".pytest_cache/",
306
+ ".ruff_cache/",
307
+ "**/__pycache__/",
308
+ "**/*.py[cod]",
309
+ `${this.repository.root}/**/dist/`,
310
+ );
311
+ this.addTasks(project, resolvedOptions);
252
312
 
253
- const releaseOptions = options.release === true ? {} : options.release || {};
313
+ const configuredReleaseOptions = options.release === true ? {} : options.release || {};
314
+ const releaseOptions = { ...configuredReleaseOptions };
254
315
  this.addTrustedPublisherInstructionsTask(project, releaseOptions);
255
316
 
256
317
  const interpreterPath = options.interpreterPath ?? "${workspaceFolder}/.venv/bin/python";
@@ -258,7 +319,11 @@ export class DBXToolsPythonWorkspace extends Component {
258
319
  projectVscode(project)?.settings.addSetting("python.defaultInterpreterPath", interpreterPath);
259
320
  }
260
321
 
261
- if (options.release) {
322
+ if (options.release && this.packages.some((pkg) => !pkg.packageOptions.private)) {
323
+ if (isDBXToolsJavaScriptProject()(project)) {
324
+ project.dbxToolsConfig.pythonReleaseWorkflow =
325
+ releaseOptions.workflowName ?? "python-release";
326
+ }
262
327
  this.addReleaseWorkflow(project, releaseOptions);
263
328
  }
264
329
  }
@@ -276,12 +341,13 @@ export class DBXToolsPythonWorkspace extends Component {
276
341
  private emitWorkspace(
277
342
  project: javascript.NodeProject,
278
343
  options: DBXToolsPythonWorkspaceOptions,
344
+ scope: string,
279
345
  ): python.PyprojectTomlFile {
280
346
  const testPaths = options.testPaths ?? [this.repository.root];
281
347
  const perFileIgnores = options.ruffPerFileIgnores ?? {};
282
348
  const file = new python.PyprojectTomlFile(project, {
283
349
  project: {
284
- name: options.workspaceName ?? `${string.toSlug(project.name)}-python-workspace`,
350
+ name: options.workspaceName ?? `${scope}-python-workspace`,
285
351
  version: this.version,
286
352
  requiresPython: this.requiresPython,
287
353
  dependencies: [...(options.dependencies ?? [])],
@@ -315,8 +381,16 @@ export class DBXToolsPythonWorkspace extends Component {
315
381
  file.addOverride("tool.uv.index-strategy", options.indexStrategy);
316
382
  }
317
383
  file.addOverride("tool.pyrefly.ignore-errors-in-generated-code", true);
318
- if (options.pyreflyProjectExcludes?.length) {
319
- file.addOverride("tool.pyrefly.project-excludes", [...options.pyreflyProjectExcludes]);
384
+ const projectExcludes = [
385
+ ...(options.pyreflyProjectExcludes ?? []),
386
+ ...options.packages.flatMap((pkg) =>
387
+ (pkg.generatedSources ?? []).map(
388
+ (source) => `${this.repository.root}/${pkg.directory}/${source}`,
389
+ ),
390
+ ),
391
+ ];
392
+ if (projectExcludes.length) {
393
+ file.addOverride("tool.pyrefly.project-excludes", [...new Set(projectExcludes)]);
320
394
  }
321
395
  file.addOverride(
322
396
  "tool.uv.sources",
@@ -356,63 +430,153 @@ export class DBXToolsPythonWorkspace extends Component {
356
430
  const publications = this.publications(options);
357
431
  if (publications.length === 0) return;
358
432
  const workflow = new GithubWorkflow(project.github, options.workflowName ?? "python-release");
359
- workflow.file?.addOverride("permissions", { contents: "read" });
433
+ workflow.file?.addOverride("permissions", {
434
+ contents: "read",
435
+ ...(options.upstreamWorkflow ? { actions: "read" } : {}),
436
+ });
360
437
  const upstreamWorkflow = options.upstreamWorkflow;
438
+ const branchDispatch = upstreamWorkflow === undefined && isDBXToolsJavaScriptProject()(project);
361
439
  if (upstreamWorkflow) {
362
440
  workflow.file?.addOverride("on.workflow_run", {
363
441
  workflows: [upstreamWorkflow],
364
442
  types: ["completed"],
365
443
  });
366
444
  workflow.on({ workflowDispatch: {} });
445
+ } else if (branchDispatch) {
446
+ workflow.file?.addOverride("on.repository_dispatch", {
447
+ types: [DOWNSTREAM_RELEASE_EVENT],
448
+ });
449
+ workflow.on({ workflowDispatch: {} });
367
450
  } else {
368
451
  workflow.on({ push: { tags: ["v*"] }, workflowDispatch: {} });
369
452
  }
370
453
  workflow.file?.addOverride("on.workflow_dispatch", {
371
454
  inputs: {
372
- version: {
373
- description: "Python package version to build",
374
- type: "string",
375
- required: true,
376
- },
455
+ ...(branchDispatch
456
+ ? {
457
+ release_tag: {
458
+ description: "Release tag to package during a dry run",
459
+ type: "string",
460
+ required: true,
461
+ },
462
+ expected_sha: {
463
+ description: "Commit the release tag must reference",
464
+ type: "string",
465
+ required: true,
466
+ },
467
+ }
468
+ : {
469
+ version: {
470
+ description: "Python package version to build",
471
+ type: "string",
472
+ required: true,
473
+ },
474
+ }),
377
475
  },
378
476
  });
379
477
  workflow.addJob("build", {
380
478
  ...(upstreamWorkflow
381
479
  ? {
382
- if: "${{ github.event_name != 'workflow_run' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push') }}",
480
+ if: "${{ github.event_name != 'workflow_run' || (github.event.workflow_run.conclusion == 'success' && (github.event.workflow_run.event == 'push' || github.event.workflow_run.event == 'repository_dispatch')) }}",
383
481
  }
384
482
  : {}),
385
483
  runsOn: ["ubuntu-latest"],
386
- permissions: { contents: JobPermission.READ },
484
+ permissions: {
485
+ contents: JobPermission.READ,
486
+ ...(upstreamWorkflow ? { actions: JobPermission.READ } : {}),
487
+ },
387
488
  timeoutMinutes: 20,
388
489
  steps: [
389
- {
390
- name: "Checkout",
391
- uses: "actions/checkout@v6",
392
- with: {
393
- "fetch-depth": 0,
394
- ...(upstreamWorkflow
395
- ? {
396
- ref: "${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }}",
397
- }
398
- : {}),
399
- },
400
- },
490
+ ...(upstreamWorkflow
491
+ ? [
492
+ {
493
+ name: "Download release metadata",
494
+ if: "${{ github.event_name == 'workflow_run' && github.event.workflow_run.event == 'repository_dispatch' }}",
495
+ uses: "actions/download-artifact@v8",
496
+ with: {
497
+ name: "release-metadata",
498
+ path: ".release",
499
+ "run-id": "${{ github.event.workflow_run.id }}",
500
+ "github-token": "${{ github.token }}",
501
+ },
502
+ },
503
+ {
504
+ name: "Read release metadata",
505
+ id: "release_metadata",
506
+ if: "${{ github.event_name == 'workflow_run' && github.event.workflow_run.event == 'repository_dispatch' }}",
507
+ shell: "bash",
508
+ run: [
509
+ 'RELEASE_TAG="$(cat .release/tag)"',
510
+ 'EXPECTED_SHA="$(cat .release/sha)"',
511
+ 'test -n "$RELEASE_TAG"',
512
+ 'test -n "$EXPECTED_SHA"',
513
+ 'echo "release_tag=$RELEASE_TAG" >> "$GITHUB_OUTPUT"',
514
+ 'echo "expected_sha=$EXPECTED_SHA" >> "$GITHUB_OUTPUT"',
515
+ ].join("\n"),
516
+ },
517
+ ]
518
+ : []),
519
+ ...(branchDispatch
520
+ ? releaseSourceSteps()
521
+ : [
522
+ {
523
+ name: "Checkout",
524
+ uses: "actions/checkout@v6",
525
+ with: {
526
+ "fetch-depth": 0,
527
+ ...(upstreamWorkflow
528
+ ? {
529
+ ref: "${{ github.event_name == 'workflow_run' && github.event.workflow_run.event == 'repository_dispatch' && steps.release_metadata.outputs.expected_sha || github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }}",
530
+ }
531
+ : {}),
532
+ },
533
+ },
534
+ ]),
535
+ ...(upstreamWorkflow
536
+ ? [
537
+ {
538
+ name: "Verify release source",
539
+ if: "${{ github.event_name == 'workflow_run' && github.event.workflow_run.event == 'repository_dispatch' }}",
540
+ shell: "bash",
541
+ env: {
542
+ RELEASE_TAG: "${{ steps.release_metadata.outputs.release_tag }}",
543
+ EXPECTED_SHA: "${{ steps.release_metadata.outputs.expected_sha }}",
544
+ },
545
+ run: [
546
+ 'git fetch --force origin "+refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG"',
547
+ 'test "$(git rev-parse "$RELEASE_TAG^{commit}")" = "$EXPECTED_SHA"',
548
+ 'test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"',
549
+ ].join("\n"),
550
+ },
551
+ ]
552
+ : []),
401
553
  { name: "Setup uv", uses: "astral-sh/setup-uv@v7" },
402
554
  {
403
555
  name: "Stamp workspace versions",
404
556
  env: {
405
- VERSION: "${{ github.event_name == 'push' && github.ref_name || inputs.version }}",
557
+ VERSION: branchDispatch
558
+ ? RELEASE_TAG
559
+ : "${{ github.event_name == 'push' && github.ref_name || github.event_name == 'workflow_run' && github.event.workflow_run.event == 'repository_dispatch' && steps.release_metadata.outputs.release_tag || inputs.version }}",
406
560
  },
407
561
  run: upstreamWorkflow
408
562
  ? [
409
563
  'if [ "$GITHUB_EVENT_NAME" = "workflow_run" ]; then',
410
- ' VERSION="$(git tag --points-at HEAD --list "v*" | sort -V | tail -1)"',
564
+ ' if [ "${{ github.event.workflow_run.event }}" != "repository_dispatch" ]; then',
565
+ ' VERSION="$(git tag --points-at HEAD --list "v*" | sort -V | tail -1)"',
566
+ " fi",
411
567
  ' test -n "$VERSION"',
412
568
  "fi",
413
569
  this.renderVersionStampScript(),
570
+ "mkdir -p .release",
571
+ 'printf "%s\\n" "$VERSION" > .release/tag',
572
+ "git rev-parse HEAD > .release/sha",
414
573
  ].join("\n")
415
- : this.renderVersionStampScript(),
574
+ : [
575
+ this.renderVersionStampScript(),
576
+ "mkdir -p .release",
577
+ 'printf "%s\\n" "$VERSION" > .release/tag',
578
+ "git rev-parse HEAD > .release/sha",
579
+ ].join("\n"),
416
580
  },
417
581
  {
418
582
  name: "Build distributions",
@@ -435,11 +599,18 @@ export class DBXToolsPythonWorkspace extends Component {
435
599
  uses: "actions/upload-artifact@v7",
436
600
  with: { name: "python-distributions", path: "dist" },
437
601
  },
602
+ {
603
+ name: "Upload release metadata",
604
+ uses: "actions/upload-artifact@v7",
605
+ with: { name: "release-metadata", path: ".release" },
606
+ },
438
607
  ],
439
608
  });
440
609
  for (const publication of publications) {
441
610
  workflow.addJob(`publish-${publication.directory}`, {
442
- if: "${{ github.event_name == 'push' || github.event_name == 'workflow_run' }}",
611
+ if: branchDispatch
612
+ ? "${{ github.event_name == 'repository_dispatch' }}"
613
+ : "${{ github.event_name == 'push' || github.event_name == 'workflow_run' }}",
443
614
  needs: ["build"],
444
615
  environment: {
445
616
  name: publication.environment,
@@ -504,6 +675,7 @@ export class DBXToolsPythonWorkspace extends Component {
504
675
  ): void {
505
676
  const repository = this.githubRepository();
506
677
  const publications = this.trustedPublisherPublications(options);
678
+ const releaseBranch = projectReleaseBranch(project);
507
679
  const linesBeforeAuthentication = [
508
680
  "# PyPI Trusted Publisher Setup Instructions",
509
681
  "",
@@ -513,7 +685,7 @@ export class DBXToolsPythonWorkspace extends Component {
513
685
  "",
514
686
  "Before making any changes, complete a read-only audit:",
515
687
  "",
516
- `- Confirm that the active PyPI account is ${repository.owner}.`,
688
+ "- Confirm that the active PyPI account can administer the listed projects and pending publishers.",
517
689
  "- Start at https://pypi.org/manage/projects/ and determine which listed projects exist.",
518
690
  "- Inspect every existing GitHub publisher for each project and compare its owner, repository name, workflow name, and environment name with the desired values below.",
519
691
  "- Identify duplicate and mismatched trusted publishers that must be replaced by removing the publisher entry and adding the correct one.",
@@ -522,11 +694,17 @@ export class DBXToolsPythonWorkspace extends Component {
522
694
  "- Ask the user to confirm the complete proposed plan before submitting any change.",
523
695
  "- After confirmation, perform the authorized plan without asking for additional confirmation unless authentication or a CAPTCHA requires user action.",
524
696
  "",
697
+ "## GitHub environment policy",
698
+ "",
699
+ `- Configure every environment listed below to permit deployments from the ${releaseBranch} branch.`,
700
+ "- Branch-scoped release workflows use the environment names below; tag-only deployment policies reject their trusted-publishing jobs.",
701
+ "- Keep the GitHub environment name synchronized with the PyPI trusted publisher environment.",
702
+ "",
525
703
  "## Authentication",
526
704
  "",
527
705
  ];
528
706
  const linesAfterAuthentication = [
529
- `- If the active account is not ${repository.owner}, pause and ask the user to sign in to the correct account.`,
707
+ "- If the active account cannot administer the listed projects or pending publishers, pause and ask the user to sign in to an authorized account.",
530
708
  "- Pause and ask the user to complete every CAPTCHA. Do not attempt to solve or bypass a CAPTCHA.",
531
709
  "- After the user completes an authentication or CAPTCHA step, continue from the current browser session.",
532
710
  "",
@@ -560,6 +738,7 @@ export class DBXToolsPythonWorkspace extends Component {
560
738
  `- Repository name: ${repository.name}`,
561
739
  `- Workflow name: ${workflowName}`,
562
740
  `- Environment name: ${publication.environment}`,
741
+ `- GitHub environment branch: ${releaseBranch}`,
563
742
  ...(publication.artifacts ? [`- Artifacts: ${publication.artifacts}`] : []),
564
743
  "",
565
744
  ];