@dbx-tools/projen 0.6.77 → 0.6.79

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
@@ -260,6 +260,19 @@ a new package is covered without a re-synth. Work from the root:
260
260
  | `bun run barrels` | regenerate the read-only `index.ts` barrels |
261
261
  | `bun run bump` | version, tag, and publish |
262
262
 
263
+ `bump` also mirrors a release into local registries when the active clients are
264
+ pointed at loopback services. npm uses `npm config get registry` and publishes
265
+ to a local Verdaccio automatically. Python prefers uv's default index and only
266
+ treats a loopback `.../+simple/` URL as writable devpi; a read-only cache such as
267
+ proxpi (`.../index/`) is deliberately ignored. The task stamps every Python
268
+ member and its sibling dependencies to the release version, builds the workspace
269
+ with uv, then runs `devpi upload --from-dir` against the derived writable index.
270
+ Devpi client authentication remains in its normal `~/.devpi` state.
271
+
272
+ Use `--local-registry false` or `--local-pypi false` to disable either local
273
+ publish. An explicit `--local-pypi http://localhost:3141/user/index/` overrides
274
+ auto-detection; `--python-root` defaults to `packages/py`.
275
+
263
276
  Members intentionally keep only the tasks that something OTHER than a human
264
277
  invokes, so there is no second place to run the same thing:
265
278
 
package/index.ts CHANGED
@@ -36,11 +36,11 @@ export { repoRoot, DEFAULT_PACKAGE_ROOTS, DiscoveredPackage } from "./src/packag
36
36
  export type { RecordedPackage } from "./src/packages.ts";
37
37
  export { PnpmWorkspaceState } from "./src/pnpm-workspace.ts";
38
38
  export type { Catalog, AllowBuilds, DBXToolsPNPMWorkspaceOptions } from "./src/pnpm-workspace.ts";
39
- export type { ApplyToProjectsOptions } from "./src/project.ts";
39
+ export type { DBXToolsProjectLanguage, DBXToolsProjectOptions, DBXToolsProject, ApplyToProjectsOptions } from "./src/project.ts";
40
40
  export { PackageIdentifier, PROJEN_VERSION, DBXToolsNodeProject, ROOT_INSTALL_ONLY_MIXIN, DBXToolsTypeScriptProject } from "./src/project-js.ts";
41
- export type { DBXToolsProject, DBXToolsProjectOptions, DBXToolsTypeScriptProjectOptions } from "./src/project-js.ts";
42
- export { DBXToolsPythonWorkspace } from "./src/project-py.ts";
43
- export type { PythonRepositoryOptions, PythonPackageOptions, PythonReleaseOptions, DBXToolsPythonWorkspaceOptions } from "./src/project-py.ts";
41
+ export type { DBXToolsJavaScriptProject, DBXToolsJavaScriptProjectOptions, DBXToolsTypeScriptProjectOptions } from "./src/project-js.ts";
42
+ export { DBXToolsPythonProject, DBXToolsPythonWorkspace } from "./src/project-py.ts";
43
+ export type { PythonRepositoryOptions, PythonPackageOptions, DBXToolsPythonProjectOptions, PythonReleaseOptions, DBXToolsPythonWorkspaceOptions } from "./src/project-py.ts";
44
44
  export { COMPILED_DIR, COMPILED_COMPILER_OPTIONS } from "./src/publish.ts";
45
45
  export { DBXToolsRelease } from "./src/release.ts";
46
46
  export type { StandaloneRelease, DBXToolsReleaseOptions } from "./src/release.ts";
package/package.json CHANGED
@@ -26,9 +26,9 @@
26
26
  },
27
27
  "dependencies": {
28
28
  "@clack/prompts": "^1.7.0",
29
- "@dbx-tools/core": "0.6.77",
30
- "@dbx-tools/path": "0.6.77",
31
- "@dbx-tools/shared-core": "0.6.77",
29
+ "@dbx-tools/core": "0.6.79",
30
+ "@dbx-tools/path": "0.6.79",
31
+ "@dbx-tools/shared-core": "0.6.79",
32
32
  "commander": "^15.0.0",
33
33
  "concurrently": "^10.0.3",
34
34
  "constructs": "^10.6.0",
@@ -47,7 +47,7 @@
47
47
  },
48
48
  "main": "index.ts",
49
49
  "license": "Apache-2.0",
50
- "version": "0.6.77",
50
+ "version": "0.6.79",
51
51
  "types": "index.ts",
52
52
  "type": "module",
53
53
  "exports": {
@@ -250,6 +250,7 @@ export class PnpmWorkspaceState {
250
250
  */
251
251
  public resolveMembers(project: Project, extraMembers: readonly string[] = []): void {
252
252
  const members = project.subprojects
253
+ .filter((sub): sub is javascript.NodeProject => sub instanceof javascript.NodeProject)
253
254
  .map((sub) => toPosix(relative(project.outdir, sub.outdir)))
254
255
  .filter(Boolean);
255
256
  // `extraMembers` are workspace siblings NOT attached as subprojects (e.g. the
package/src/project-js.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * The dbx-tools project surface plus package tooling: the single
3
- * {@link DBXToolsProject} interface, the projen Node/TypeScript project classes,
3
+ * {@link DBXToolsJavaScriptProject} interface, the projen Node/TypeScript project classes,
4
4
  * naming, guards, manifest fields, and the shared root init.
5
5
  *
6
6
  * {@link DBXToolsNodeProject} (monorepo root) and {@link DBXToolsTypeScriptProject}
7
- * (a package, or a standalone compiling root) both implement {@link DBXToolsProject}.
7
+ * (a package, or a standalone compiling root) both implement {@link DBXToolsJavaScriptProject}.
8
8
  */
9
9
  import { existsSync, readdirSync } from "node:fs";
10
10
  import { dirname, join, relative, resolve } from "node:path";
@@ -41,13 +41,14 @@ import { DBXToolsRelease, type StandaloneRelease } from "./release.ts";
41
41
  import { AGNOSTIC_COMPILER_OPTIONS, PACKAGE_TAG_MIXINS, type PackageTag } from "./tags.ts";
42
42
  import { DBXToolsRootTsconfig } from "./tsconfig.ts";
43
43
  import { DBXToolsVsCode } from "./vscode.ts";
44
+ import type { DBXToolsProject, DBXToolsProjectOptions as CommonProjectOptions } from "./project.ts";
44
45
 
45
46
  /**
46
47
  * The dbx-tools project surface, backed by projen's Node toolchain. A single
47
48
  * interface for both the monorepo root and each package: it carries the
48
49
  * `dbxToolsConfig` component plus the npm-naming and root-only file components.
49
50
  */
50
- export interface DBXToolsProject extends javascript.NodeProject {
51
+ export interface DBXToolsJavaScriptProject extends DBXToolsProject, javascript.NodeProject {
51
52
  /** The package's `dbxToolsConfig` component (tags + `package.json` config). */
52
53
  readonly dbxToolsConfig: DBXToolsConfig;
53
54
  /** npm scope (the `@scope` in `@scope/pkg`), without the leading `@`. */
@@ -342,7 +343,9 @@ export const PROJEN_VERSION = "^0.101.16";
342
343
  * inherits the root's config rather than emitting its own. `name`/`defaultReleaseBranch`
343
344
  * are resolved/applied by the caller.
344
345
  */
345
- function defaultProjectOptions(options: DBXToolsProjectOptions): DBXToolsProjectOptions {
346
+ function defaultProjectOptions(
347
+ options: DBXToolsJavaScriptProjectOptions,
348
+ ): DBXToolsJavaScriptProjectOptions {
346
349
  const isRoot = options.parent === undefined;
347
350
  return {
348
351
  // Bun owns install/run/build/test locally and in CI. projen renders
@@ -420,7 +423,7 @@ function defaultProjectOptions(options: DBXToolsProjectOptions): DBXToolsProject
420
423
  * pristine array to seed a child's fresh one. Spread AFTER `...options`.
421
424
  */
422
425
  function copiedGitIgnoreOptions(
423
- options: DBXToolsProjectOptions,
426
+ options: DBXToolsJavaScriptProjectOptions,
424
427
  ): Pick<javascript.NodeProjectOptions, "gitIgnoreOptions"> {
425
428
  if (!options.gitIgnoreOptions?.ignorePatterns) return {};
426
429
  return {
@@ -460,8 +463,9 @@ function defaultTypeScriptProjectOptions(
460
463
  const DEV_DEPS_ROOT: string[] = ["typescript@^5.9.3", "@types/bun@^1.3.14"];
461
464
 
462
465
  /** Options for {@link DBXToolsNodeProject} (the monorepo root). */
463
- export interface DBXToolsProjectOptions
466
+ export interface DBXToolsJavaScriptProjectOptions
464
467
  extends
468
+ CommonProjectOptions,
465
469
  Partial<javascript.NodeProjectOptions>,
466
470
  DBXToolsConfigOptions,
467
471
  DBXToolsPNPMWorkspaceOptions {
@@ -529,7 +533,7 @@ export interface DBXToolsProjectOptions
529
533
 
530
534
  /** Options for {@link DBXToolsTypeScriptProject} (a package, or a compiling root). */
531
535
  export interface DBXToolsTypeScriptProjectOptions
532
- extends Partial<typescript.TypeScriptProjectOptions>, DBXToolsProjectOptions {
536
+ extends Partial<typescript.TypeScriptProjectOptions>, DBXToolsJavaScriptProjectOptions {
533
537
  /** Emit the projen-owned bun app scaffolding (`bunfig.toml`/`dev.ts`/`build.ts`). */
534
538
  readonly bunApp?: boolean;
535
539
  }
@@ -539,7 +543,11 @@ export interface DBXToolsTypeScriptProjectOptions
539
543
  * {@link DBXToolsTypeScriptProject} per `src`-bearing folder, then emits the
540
544
  * shared config, tasks, `pnpm-workspace.yaml`, and barrels-on-synth.
541
545
  */
542
- export class DBXToolsNodeProject extends javascript.NodeProject implements DBXToolsProject {
546
+ export class DBXToolsNodeProject
547
+ extends javascript.NodeProject
548
+ implements DBXToolsJavaScriptProject
549
+ {
550
+ readonly language = "javascript" as const;
543
551
  readonly scope: string;
544
552
  readonly dbxToolsConfig: DBXToolsConfig;
545
553
  pnpmWorkspace?: PnpmWorkspaceState;
@@ -548,7 +556,7 @@ export class DBXToolsNodeProject extends javascript.NodeProject implements DBXTo
548
556
  private readonly extraWorkspaceMembers: readonly string[];
549
557
  private readonly rootInstallOnly: boolean;
550
558
 
551
- constructor(options: DBXToolsProjectOptions = {}) {
559
+ constructor(options: DBXToolsJavaScriptProjectOptions = {}) {
552
560
  const { name, scope } = resolveIdentity(options);
553
561
  const releaseDefaults =
554
562
  options.release && options.releaseTrigger === undefined
@@ -622,8 +630,9 @@ export const ROOT_INSTALL_ONLY_MIXIN = mixin.create(
622
630
  */
623
631
  export class DBXToolsTypeScriptProject
624
632
  extends typescript.TypeScriptProject
625
- implements DBXToolsProject
633
+ implements DBXToolsJavaScriptProject
626
634
  {
635
+ readonly language = "javascript" as const;
627
636
  readonly scope: string;
628
637
  readonly dbxToolsConfig: DBXToolsConfig;
629
638
  pnpmWorkspace?: PnpmWorkspaceState;
@@ -816,7 +825,7 @@ class PrettierIgnoreGenerated extends Component {
816
825
  /** Default leading path segment stripped from a package's name (not its tag). */
817
826
  const DEFAULT_OMIT_RELATIVE_PREFIX = ["node"];
818
827
 
819
- /** Normalize the {@link DBXToolsProjectOptions.omitRelativePrefix} option to a slug list. */
828
+ /** Normalize the {@link DBXToolsJavaScriptProjectOptions.omitRelativePrefix} option to a slug list. */
820
829
  function resolveOmitRelativePrefix(option: OneOrMany<string> | undefined): string[] {
821
830
  const raw = option === undefined ? DEFAULT_OMIT_RELATIVE_PREFIX : option;
822
831
  const list = Array.isArray(raw) ? raw : [raw];
@@ -943,7 +952,7 @@ export function taskScript(_project: javascript.NodeProject, script: string, arg
943
952
  */
944
953
  function initProject(
945
954
  project: DBXToolsNodeProject | DBXToolsTypeScriptProject,
946
- options: DBXToolsProjectOptions,
955
+ options: DBXToolsJavaScriptProjectOptions,
947
956
  ): void {
948
957
  // projen's GithubProject seeds a `# replace this` SampleReadme on every
949
958
  // project. READMEs are hand-written and owned outside projen, so drop the
@@ -1101,7 +1110,7 @@ function initProject(
1101
1110
 
1102
1111
  // Already-attached subprojects, keyed by repo-relative member path.
1103
1112
  const rootAbs = resolve(project.outdir);
1104
- const existing = new Map<string, DBXToolsProject>();
1113
+ const existing = new Map<string, DBXToolsJavaScriptProject>();
1105
1114
  for (const sub of project.subprojects) {
1106
1115
  if (sub instanceof DBXToolsNodeProject || sub instanceof DBXToolsTypeScriptProject) {
1107
1116
  existing.set(toPosix(relative(rootAbs, sub.outdir)), sub);
@@ -1182,7 +1191,7 @@ class ChildGitignore extends IgnoreFile {
1182
1191
  */
1183
1192
  function swapChildGitignore(
1184
1193
  project: javascript.NodeProject,
1185
- options: DBXToolsProjectOptions,
1194
+ options: DBXToolsJavaScriptProjectOptions,
1186
1195
  ): void {
1187
1196
  project.tryRemoveFile(".gitignore");
1188
1197
  const fresh = new ChildGitignore(project, ".gitignore", {
@@ -11,7 +11,8 @@ import { IConstruct } from "constructs";
11
11
  import { Project } from "projen";
12
12
  import { project } from "..";
13
13
  import { toPosix } from "./packages.ts";
14
- import { DBXToolsProject, DBXToolsNodeProject, DBXToolsTypeScriptProject } from "./project-js.ts";
14
+ import type { DBXToolsProject } from "./project.ts";
15
+ import type { DBXToolsJavaScriptProject } from "./project-js.ts";
15
16
 
16
17
  /**
17
18
  * Guard: the construct is a projen {@link Project} - the base every builder here
@@ -27,11 +28,19 @@ export function isProject(): Predicate<IConstruct, Project> {
27
28
  return predicate.create((c: IConstruct): c is Project => Project.isProject(c));
28
29
  }
29
30
 
30
- /** Guard: the construct is a {@link DBXToolsProject} (a DBXTools Node or TypeScript project). */
31
+ /** Guard: the construct implements the language-agnostic {@link DBXToolsProject} contract. */
31
32
  export function isDBXToolsProject(): Predicate<IConstruct, DBXToolsProject> {
32
33
  return isProject().and(
33
34
  (project): project is DBXToolsProject =>
34
- project instanceof DBXToolsNodeProject || project instanceof DBXToolsTypeScriptProject,
35
+ (project as Partial<DBXToolsProject>).language === "javascript" ||
36
+ (project as Partial<DBXToolsProject>).language === "python",
37
+ );
38
+ }
39
+
40
+ /** Guard: the construct is a dbx-tools JavaScript/TypeScript project. */
41
+ export function isDBXToolsJavaScriptProject(): Predicate<IConstruct, DBXToolsJavaScriptProject> {
42
+ return isDBXToolsProject().and(
43
+ (project): project is DBXToolsJavaScriptProject => project.language === "javascript",
35
44
  );
36
45
  }
37
46
 
@@ -97,14 +106,16 @@ export function hasIdentifierScope(
97
106
 
98
107
  /**
99
108
  * Matches DBXTools packages carrying every listed tag (`dbxToolsConfig.tags`), narrowing
100
- * {@link Project} to {@link DBXToolsProject} (tags live only on DBXTools packages). Also the
109
+ * {@link Project} to {@link DBXToolsJavaScriptProject} (tags live only on JavaScript packages). Also the
101
110
  * guard backing each built-in {@link PACKAGE_TAG_MIXINS} entry. Keep it in the SAME `.and(...)`
102
111
  * as any name/path filter (or last when chaining) - a later non-tag `.and` re-widens to
103
112
  * {@link Project} and drops the narrowing.
104
113
  */
105
- export function hasTag(...tags: OneOrMany<PathMatchInput>): Predicate<IConstruct, DBXToolsProject> {
114
+ export function hasTag(
115
+ ...tags: OneOrMany<PathMatchInput>
116
+ ): Predicate<IConstruct, DBXToolsJavaScriptProject> {
106
117
  const matchers = projectMatchers(...tags);
107
- return isDBXToolsProject().and((project) =>
118
+ return isDBXToolsJavaScriptProject().and((project) =>
108
119
  matchers.every((matcher) => project.dbxToolsConfig.tags.some((tag) => matcher(tag))),
109
120
  );
110
121
  }
package/src/project-py.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  /** Reusable uv workspace generation for Python packages hosted in a projen tree. */
2
2
  import { string } from "@dbx-tools/shared-core";
3
- import { Component, TextFile, type Project, javascript, vscode } from "projen";
3
+ import { Component, type Project, javascript, python, vscode } from "projen";
4
4
  import { GithubWorkflow } from "projen/lib/github";
5
5
  import { JobPermission } from "projen/lib/github/workflows-model";
6
+ import type { DBXToolsProject, DBXToolsProjectOptions } from "./project.ts";
6
7
 
7
8
  /** Git location used by direct `#subdirectory=` package dependencies. */
8
9
  export interface PythonRepositoryOptions {
@@ -12,7 +13,7 @@ export interface PythonRepositoryOptions {
12
13
  }
13
14
 
14
15
  /** One independently installable Python package in the uv workspace. */
15
- export interface PythonPackageOptions {
16
+ export interface PythonPackageOptions extends DBXToolsProjectOptions {
16
17
  readonly directory: string;
17
18
  readonly name: string;
18
19
  readonly module: string;
@@ -20,10 +21,19 @@ export interface PythonPackageOptions {
20
21
  readonly dependencies?: readonly string[];
21
22
  }
22
23
 
24
+ /** Options for one projen-native Python workspace member. */
25
+ export interface DBXToolsPythonProjectOptions extends DBXToolsProjectOptions {
26
+ readonly parent: Project;
27
+ readonly package: PythonPackageOptions;
28
+ readonly repository: Required<PythonRepositoryOptions>;
29
+ readonly requiresPython: string;
30
+ }
31
+
23
32
  /** Python release workflow configuration. */
24
33
  export interface PythonReleaseOptions {
25
34
  readonly workflowName?: string;
26
- readonly environment?: string;
35
+ /** GitHub environment by Python distribution name. Defaults to `pypi-<name>`. */
36
+ readonly environments?: Readonly<Record<string, string>>;
27
37
  readonly environmentUrl?: string;
28
38
  }
29
39
 
@@ -69,27 +79,112 @@ function projectVscode(project: Project): vscode.VsCode | undefined {
69
79
  return (project as Project & { readonly vscode?: vscode.VsCode }).vscode;
70
80
  }
71
81
 
82
+ /** A Python package implemented with projen's `PythonProject` and uv backend. */
83
+ export class DBXToolsPythonProject extends python.PythonProject implements DBXToolsProject {
84
+ readonly language = "python" as const;
85
+ readonly packageOptions: PythonPackageOptions;
86
+ readonly uv: python.Uv;
87
+
88
+ constructor(options: DBXToolsPythonProjectOptions) {
89
+ const pkg = options.package;
90
+ super({
91
+ parent: options.parent,
92
+ outdir: pythonPackagePath(options.repository, pkg.directory),
93
+ name: pkg.name,
94
+ moduleName: pkg.module,
95
+ authorName: "",
96
+ authorEmail: "",
97
+ version: "0.0.0",
98
+ description: pkg.description,
99
+ github: false,
100
+ sample: false,
101
+ pytest: false,
102
+ projenrcPython: false,
103
+ projenrcJs: false,
104
+ projenrcTs: false,
105
+ pip: false,
106
+ venv: false,
107
+ setuptools: false,
108
+ poetry: false,
109
+ uv: true,
110
+ projenCommand: options.parent.projenCommand,
111
+ uvOptions: {
112
+ project: {
113
+ name: pkg.name,
114
+ version: "0.0.0",
115
+ description: pkg.description,
116
+ readme: "README.md",
117
+ requiresPython: options.requiresPython,
118
+ dependencies: [...(pkg.dependencies ?? [])],
119
+ urls: {
120
+ Source: `${options.repository.url.replace(/\.git$/, "")}/tree/${options.repository.ref}/${pythonPackagePath(options.repository, pkg.directory)}`,
121
+ },
122
+ },
123
+ buildSystem: {
124
+ requires: ["uv_build>=0.11.28,<0.12.0"],
125
+ buildBackend: "uv_build",
126
+ },
127
+ uv: {
128
+ buildBackend: {
129
+ moduleName: pkg.module,
130
+ moduleRoot: "src",
131
+ namespace: true,
132
+ },
133
+ },
134
+ },
135
+ });
136
+ this.packageOptions = pkg;
137
+ if (!(this.packagingManager instanceof python.Uv)) {
138
+ throw new Error(`Expected uv packaging for ${pkg.name}`);
139
+ }
140
+ this.uv = this.packagingManager;
141
+ this.uv.file.addDeletionOverride("project.authors");
142
+ this.uv.file.addDeletionOverride("dependency-groups");
143
+ this.uv.file.readonly = true;
144
+
145
+ for (const path of [".gitattributes", ".gitignore"]) {
146
+ this.tryRemoveFile(path);
147
+ }
148
+ }
149
+
150
+ /** The root workspace owns dependency installation for every member. */
151
+ public override postSynthesize(): void {}
152
+ }
153
+
72
154
  /**
73
- * Generates a root uv workspace, member package metadata, Python tasks, editor
74
- * interpreter selection, and an optional trusted-publishing workflow.
155
+ * Generates a root uv workspace, projen-native Python member projects, Python
156
+ * tasks, editor interpreter selection, and an optional publishing workflow.
75
157
  */
76
158
  export class DBXToolsPythonWorkspace extends Component {
77
- readonly packages: readonly PythonPackageOptions[];
159
+ readonly packages: readonly DBXToolsPythonProject[];
78
160
  readonly repository: Required<PythonRepositoryOptions>;
79
161
  readonly requiresPython: string;
162
+ readonly file: python.PyprojectTomlFile;
80
163
 
81
164
  constructor(project: javascript.NodeProject, options: DBXToolsPythonWorkspaceOptions) {
82
165
  super(project);
83
- this.packages = options.packages;
84
166
  this.repository = {
85
167
  url: options.repository.url,
86
168
  ref: options.repository.ref ?? "main",
87
169
  root: options.repository.root ?? "packages/py",
88
170
  };
89
171
  this.requiresPython = options.requiresPython ?? ">=3.10";
90
-
91
- this.emitWorkspace(project, options);
92
- this.emitPackages(project);
172
+ this.file = this.emitWorkspace(project, options);
173
+ this.packages = options.packages.map(
174
+ (pkg) =>
175
+ new DBXToolsPythonProject({
176
+ parent: project,
177
+ package: pkg,
178
+ repository: this.repository,
179
+ requiresPython: this.requiresPython,
180
+ }),
181
+ );
182
+ for (const pkg of this.packages) {
183
+ const pyproject = `/${pythonPackagePath(this.repository, pkg.packageOptions.directory)}/pyproject.toml`;
184
+ project.gitignore.include(pyproject);
185
+ project.gitattributes.addAttributes(pyproject, "linguist-generated");
186
+ project.prettier?.addIgnorePattern(pyproject.slice(1));
187
+ }
93
188
  this.addTasks(project, options);
94
189
 
95
190
  const interpreterPath = options.interpreterPath ?? "${workspaceFolder}/.venv/bin/python";
@@ -115,81 +210,45 @@ export class DBXToolsPythonWorkspace extends Component {
115
210
  private emitWorkspace(
116
211
  project: javascript.NodeProject,
117
212
  options: DBXToolsPythonWorkspaceOptions,
118
- ): void {
119
- const devDependencies = options.devDependencies ?? DEFAULT_DEV_DEPENDENCIES;
213
+ ): python.PyprojectTomlFile {
120
214
  const testPaths = options.testPaths ?? [this.repository.root];
121
- const ruffTarget = options.ruffTarget ?? "py310";
122
215
  const perFileIgnores = options.ruffPerFileIgnores ?? {};
123
- new TextFile(project, "pyproject.toml", {
124
- marker: false,
125
- readonly: true,
126
- lines: [
127
- '# ~~ Generated by projen. To modify, edit .projenrc.ts and run "bunx projen".',
128
- "[project]",
129
- `name = ${quote(options.workspaceName ?? `${string.toSlug(project.name)}-python-workspace`)}`,
130
- 'version = "0.0.0"',
131
- `requires-python = ${quote(this.requiresPython)}`,
132
- "dependencies = []",
133
- "",
134
- "[dependency-groups]",
135
- `dev = [${devDependencies.map(quote).join(", ")}]`,
136
- "",
137
- "[tool.uv]",
138
- "package = false",
139
- "",
140
- "[tool.uv.workspace]",
141
- `members = [${quote(`${this.repository.root}/*`)}]`,
142
- "",
143
- "[tool.uv.sources]",
144
- ...this.packages.map((pkg) => `${pkg.name} = { workspace = true }`),
145
- "",
146
- "[tool.pytest.ini_options]",
147
- 'asyncio_mode = "auto"',
148
- `testpaths = [${testPaths.map(quote).join(", ")}]`,
149
- "",
150
- "[tool.ruff]",
151
- `target-version = ${quote(ruffTarget)}`,
152
- "line-length = 100",
153
- "",
154
- "[tool.ruff.lint.per-file-ignores]",
155
- ...Object.entries(perFileIgnores).map(
156
- ([path, rules]) => `${quote(path)} = [${rules.map(quote).join(", ")}]`,
157
- ),
158
- "",
159
- ],
216
+ const file = new python.PyprojectTomlFile(project, {
217
+ project: {
218
+ name: options.workspaceName ?? `${string.toSlug(project.name)}-python-workspace`,
219
+ version: "0.0.0",
220
+ requiresPython: this.requiresPython,
221
+ dependencies: [],
222
+ },
223
+ dependencyGroups: {
224
+ dev: [...(options.devDependencies ?? DEFAULT_DEV_DEPENDENCIES)],
225
+ },
226
+ tool: {
227
+ uv: python.uvConfig.toJson_UvConfiguration({
228
+ package: false,
229
+ workspace: { members: [`${this.repository.root}/*`] },
230
+ }),
231
+ pytest: {
232
+ ini_options: {
233
+ asyncio_mode: "auto",
234
+ testpaths: testPaths,
235
+ },
236
+ },
237
+ ruff: {
238
+ "target-version": options.ruffTarget ?? "py310",
239
+ "line-length": 100,
240
+ lint: {
241
+ "per-file-ignores": perFileIgnores,
242
+ },
243
+ },
244
+ },
160
245
  });
161
- }
162
-
163
- private emitPackages(project: javascript.NodeProject): void {
164
- for (const pkg of this.packages) {
165
- new TextFile(project, `${this.packagePath(pkg.directory)}/pyproject.toml`, {
166
- marker: false,
167
- readonly: true,
168
- lines: [
169
- '# ~~ Generated by projen. To modify, edit .projenrc.ts and run "bunx projen".',
170
- "[project]",
171
- `name = ${quote(pkg.name)}`,
172
- 'version = "0.0.0"',
173
- `description = ${quote(pkg.description)}`,
174
- 'readme = "README.md"',
175
- `requires-python = ${quote(this.requiresPython)}`,
176
- `dependencies = [${(pkg.dependencies ?? []).map(quote).join(", ")}]`,
177
- "",
178
- "[project.urls]",
179
- `Source = ${quote(`${this.repository.url.replace(/\.git$/, "")}/tree/${this.repository.ref}/${this.packagePath(pkg.directory)}`)}`,
180
- "",
181
- "[build-system]",
182
- 'requires = ["uv_build>=0.11.28,<0.12.0"]',
183
- 'build-backend = "uv_build"',
184
- "",
185
- "[tool.uv.build-backend]",
186
- `module-name = ${quote(pkg.module)}`,
187
- 'module-root = "src"',
188
- "namespace = true",
189
- "",
190
- ],
191
- });
192
- }
246
+ file.addOverride(
247
+ "tool.uv.sources",
248
+ Object.fromEntries(options.packages.map((pkg) => [pkg.name, { workspace: true }])),
249
+ );
250
+ file.readonly = true;
251
+ return file;
193
252
  }
194
253
 
195
254
  private addTasks(project: javascript.NodeProject, options: DBXToolsPythonWorkspaceOptions): void {
@@ -247,12 +306,20 @@ export class DBXToolsPythonWorkspace extends Component {
247
306
  env: { VERSION: "${{ inputs.version }}" },
248
307
  run: this.renderVersionStampScript(),
249
308
  },
250
- { name: "Build distributions", run: "uv build --all-packages" },
309
+ {
310
+ name: "Build distributions",
311
+ run: this.packages
312
+ .map(
313
+ (pkg) =>
314
+ `uv build --package ${pkg.packageOptions.name} --out-dir dist/${pkg.packageOptions.directory}`,
315
+ )
316
+ .join("\n"),
317
+ },
251
318
  {
252
319
  name: "Validate distributions",
253
320
  run: [
254
- `test "$(find dist -maxdepth 1 -type f | wc -l | tr -d ' ')" -eq ${this.packages.length * 2}`,
255
- "uvx twine check dist/*",
321
+ `test "$(find dist -type f \\( -name '*.whl' -o -name '*.tar.gz' \\) | wc -l | tr -d ' ')" -eq ${this.packages.length * 2}`,
322
+ "uvx twine check dist/*/*.whl dist/*/*.tar.gz",
256
323
  ].join("\n"),
257
324
  },
258
325
  {
@@ -262,29 +329,34 @@ export class DBXToolsPythonWorkspace extends Component {
262
329
  },
263
330
  ],
264
331
  });
265
- workflow.addJob("publish", {
266
- if: "${{ inputs.publish }}",
267
- needs: ["build"],
268
- environment: {
269
- name: options.environment ?? "pypi",
270
- url: options.environmentUrl ?? "https://pypi.org/",
271
- },
272
- runsOn: ["ubuntu-latest"],
273
- permissions: { idToken: JobPermission.WRITE },
274
- timeoutMinutes: 10,
275
- steps: [
276
- {
277
- name: "Download distributions",
278
- uses: "actions/download-artifact@v8",
279
- with: { name: "python-distributions", path: "dist" },
280
- },
281
- {
282
- name: "Publish to PyPI",
283
- uses: "pypa/gh-action-pypi-publish@release/v1",
284
- with: { "packages-dir": "dist" },
332
+ for (const pkg of this.packages) {
333
+ workflow.addJob(`publish-${pkg.packageOptions.directory}`, {
334
+ if: "${{ inputs.publish }}",
335
+ needs: ["build"],
336
+ environment: {
337
+ name:
338
+ options.environments?.[pkg.packageOptions.name] ?? `pypi-${pkg.packageOptions.name}`,
339
+ url:
340
+ options.environmentUrl ??
341
+ `https://pypi.org/project/${pkg.packageOptions.name.replaceAll("_", "-")}/`,
285
342
  },
286
- ],
287
- });
343
+ runsOn: ["ubuntu-latest"],
344
+ permissions: { idToken: JobPermission.WRITE },
345
+ timeoutMinutes: 10,
346
+ steps: [
347
+ {
348
+ name: "Download distributions",
349
+ uses: "actions/download-artifact@v8",
350
+ with: { name: "python-distributions", path: "dist" },
351
+ },
352
+ {
353
+ name: `Publish ${pkg.packageOptions.name} to PyPI`,
354
+ uses: "pypa/gh-action-pypi-publish@release/v1",
355
+ with: { "packages-dir": `dist/${pkg.packageOptions.directory}` },
356
+ },
357
+ ],
358
+ });
359
+ }
288
360
  }
289
361
 
290
362
  private renderVersionStampScript(): string {
package/src/project.ts CHANGED
@@ -5,14 +5,27 @@
5
5
  import { type PathMatchInput } from "@dbx-tools/path";
6
6
  import { object, type OneOrMany } from "@dbx-tools/shared-core";
7
7
  import { type IConstruct } from "constructs";
8
- import { Project } from "projen";
8
+ import { Project, type ProjectOptions } from "projen";
9
9
  import * as mixin from "./mixin.ts";
10
10
  import * as projectPredicate from "./project-predicate.ts";
11
- import type { DBXToolsProject } from "./project-js.ts";
11
+ import type { DBXToolsJavaScriptProject } from "./project-js.ts";
12
12
 
13
13
  export * from "./project-js.ts";
14
14
  export * from "./project-py.ts";
15
15
 
16
+ /** Runtime family implemented by a dbx-tools project. */
17
+ export type DBXToolsProjectLanguage = "javascript" | "python";
18
+
19
+ /** Options shared by every dbx-tools project implementation. */
20
+ export interface DBXToolsProjectOptions extends Partial<
21
+ Pick<ProjectOptions, "name" | "parent" | "outdir">
22
+ > {}
23
+
24
+ /** Minimal language-agnostic project contract. */
25
+ export interface DBXToolsProject extends Project {
26
+ readonly language: DBXToolsProjectLanguage;
27
+ }
28
+
16
29
  /** Filters selecting which projects an {@link applyToProjects} call runs against. */
17
30
  export interface ApplyToProjectsOptions {
18
31
  /** Include plain projen projects. Defaults to DBXTools projects only. */
@@ -45,8 +58,8 @@ type ApplyToAllProjectsOptions = Omit<ApplyToProjectsOptions, "includeNonDBXTool
45
58
  export function applyToProjects(
46
59
  construct: IConstruct,
47
60
  ...args:
48
- | [ApplyToDBXToolsProjectsOptions, ...OneOrMany<(project: DBXToolsProject) => void>]
49
- | OneOrMany<(project: DBXToolsProject) => void>
61
+ | [ApplyToDBXToolsProjectsOptions, ...OneOrMany<(project: DBXToolsJavaScriptProject) => void>]
62
+ | OneOrMany<(project: DBXToolsJavaScriptProject) => void>
50
63
  ): void;
51
64
 
52
65
  export function applyToProjects(
@@ -71,7 +84,7 @@ export function applyToProjects<P extends Project>(
71
84
  const callbacks = (hasOptions ? rest : args) as OneOrMany<(project: Project) => void>;
72
85
  let predicate = projectPredicate.isProject();
73
86
  if (!options?.includeNonDBXToolsProjects) {
74
- predicate = predicate.and(projectPredicate.isDBXToolsProject());
87
+ predicate = predicate.and(projectPredicate.isDBXToolsJavaScriptProject());
75
88
  }
76
89
  if (!options?.includeRoots) predicate = predicate.and((project) => project.parent != null);
77
90
  if (options?.identifierPackageName) {
package/src/publish.ts CHANGED
@@ -29,7 +29,7 @@
29
29
  */
30
30
  import type { javascript } from "projen";
31
31
  import { typescript } from "projen";
32
- import { isDBXToolsProject } from "./project-predicate.ts";
32
+ import { isDBXToolsJavaScriptProject } from "./project-predicate.ts";
33
33
  import { addPackageFiles, applyCompilerOptions, applyIncludes } from "./project.ts";
34
34
 
35
35
  /** Directory `tsc` emits into, and the root of every published entry point. */
@@ -63,7 +63,7 @@ export const COMPILED_COMPILER_OPTIONS: javascript.TypeScriptCompilerOptions = {
63
63
  */
64
64
  export function publishesCompiled(pkg: javascript.NodeProject): boolean {
65
65
  if (!(pkg instanceof typescript.TypeScriptProject) || !pkg.parent) return false;
66
- return isDBXToolsProject()(pkg) && !pkg.dbxToolsConfig.tags.includes("ui");
66
+ return isDBXToolsJavaScriptProject()(pkg) && !pkg.dbxToolsConfig.tags.includes("ui");
67
67
  }
68
68
 
69
69
  /** The `./lib/...` stem of a source path, or `undefined` if it is not TypeScript. */
package/tasks/bump.ts CHANGED
@@ -30,12 +30,11 @@
30
30
  * `--publish` / `--no-publish` is an alias for `--push` (pushing the tag is
31
31
  * what publishes). The tag prefix comes from `--prefix` (default `v`).
32
32
  *
33
- * `--local-registry <value>` publishes the just-tagged version to a LOCAL
34
- * registry (e.g. a verdaccio) right after the git tag is pushed - so a local
35
- * `bun run bump` both fires the GitHub release (public npm) and populates your
36
- * local registry. Values:
33
+ * `--local-registry <value>` publishes npm packages to a LOCAL registry (e.g.
34
+ * verdaccio) right after the tag push. `--local-pypi <value>` does the same for
35
+ * Python packages through a writable devpi index. Values for both:
37
36
  * - `auto` (default): publish only when `npm config get registry` is a
38
- * loopback host (`localhost` / `127.0.0.0/8` / `::1`); otherwise skip.
37
+ * loopback host, or uv's default index is a loopback devpi `+simple` URL.
39
38
  * - `false`: never publish locally.
40
39
  * - a URL: always publish to that registry.
41
40
  */
@@ -45,6 +44,7 @@ import { fileURLToPath } from "node:url";
45
44
  import { exec, project } from "@dbx-tools/core";
46
45
  import { log, net } from "@dbx-tools/shared-core";
47
46
  import { Command, Option } from "commander";
47
+ import { activePythonIndex, resolveLocalPypi } from "./python-registry.ts";
48
48
 
49
49
  const logger = log.logger("projen:bump");
50
50
  const LEVELS = ["patch", "minor", "major"] as const;
@@ -188,6 +188,12 @@ program
188
188
  "publish locally after the tag push: 'auto' (only a loopback npm registry), 'false', or a registry URL",
189
189
  "auto",
190
190
  )
191
+ .option(
192
+ "--local-pypi <value>",
193
+ "publish Python packages locally: 'auto' (only a loopback devpi +simple index), 'false', or a devpi URL",
194
+ "auto",
195
+ )
196
+ .option("--python-root <path>", "Python workspace package root", "packages/py")
191
197
  .action(
192
198
  (opts: {
193
199
  level: Level;
@@ -199,7 +205,9 @@ program
199
205
  tag: boolean;
200
206
  push: boolean;
201
207
  publish: boolean;
208
+ localPypi: string;
202
209
  localRegistry: string;
210
+ pythonRoot: string;
203
211
  }) => {
204
212
  const pkgPath = resolve(process.cwd(), "package.json");
205
213
  if (!existsSync(pkgPath)) throw new Error(`no package.json in ${process.cwd()}`);
@@ -314,6 +322,45 @@ program
314
322
  logger.success(`published ${version} to ${localRegistry}`);
315
323
  }
316
324
 
325
+ const activeIndex = activePythonIndex();
326
+ const localPypi = resolveLocalPypi(opts.localPypi, activeIndex);
327
+ const pythonRoot = resolve(opts.pythonRoot);
328
+ if (
329
+ opts.localPypi.toLowerCase() === "auto" &&
330
+ activeIndex &&
331
+ net.isLoopbackHost(new URL(activeIndex)) &&
332
+ !localPypi
333
+ ) {
334
+ logger.info(`skipped local Python publish: ${activeIndex} is not a devpi +simple index`);
335
+ }
336
+ if (opts.version === false && localPypi) {
337
+ logger.info("skipped local Python publish (--no-version left packages unstamped)");
338
+ } else if (opts.version && localPypi && existsSync(pythonRoot)) {
339
+ logger.info(`publishing Python ${version} to local devpi ${localPypi.publishUrl}`);
340
+ const publishPythonScript = fileURLToPath(new URL("./publish-python.ts", import.meta.url));
341
+ exec.spawnSync(
342
+ "bun",
343
+ [
344
+ publishPythonScript,
345
+ version,
346
+ "--root",
347
+ pythonRoot,
348
+ "--index-url",
349
+ localPypi.indexUrl,
350
+ "--publish-url",
351
+ localPypi.publishUrl,
352
+ ],
353
+ {
354
+ cwd: process.cwd(),
355
+ stdout: "inherit",
356
+ stderr: "inherit",
357
+ stdin: "ignore",
358
+ check: true,
359
+ },
360
+ );
361
+ logger.success(`published Python ${version} to ${localPypi.publishUrl}`);
362
+ }
363
+
317
364
  // Publishing can run package lifecycle hooks, including a standalone
318
365
  // project's own projen synth, which rewrites its generated manifest back
319
366
  // to 0.0.0. Re-assert the release version last so root and every sibling
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env -S bun
2
+ import {
3
+ chmodSync,
4
+ existsSync,
5
+ mkdtempSync,
6
+ readFileSync,
7
+ readdirSync,
8
+ rmSync,
9
+ statSync,
10
+ writeFileSync,
11
+ } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { basename, join, resolve } from "node:path";
14
+ import { exec } from "@dbx-tools/core";
15
+ import { Command } from "commander";
16
+
17
+ interface PythonProjectFile {
18
+ readonly directory: string;
19
+ readonly mode: number;
20
+ readonly name: string;
21
+ readonly path: string;
22
+ readonly source: string;
23
+ }
24
+
25
+ function escapeRegExp(value: string): string {
26
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
27
+ }
28
+
29
+ export function stampPythonProjects(root: string, version: string): () => void {
30
+ const packageFiles = readdirSync(root, { withFileTypes: true })
31
+ .filter((entry) => entry.isDirectory())
32
+ .map((entry) => resolve(root, entry.name, "pyproject.toml"))
33
+ .filter(existsSync)
34
+ .sort();
35
+ const projects: PythonProjectFile[] = packageFiles.map((path) => {
36
+ const source = readFileSync(path, "utf8");
37
+ const name = /^name = "([^"]+)"$/m.exec(source)?.[1];
38
+ if (!name) throw new Error(`Missing project name in ${path}`);
39
+ return {
40
+ directory: basename(resolve(path, "..")),
41
+ mode: statSync(path).mode,
42
+ name,
43
+ path,
44
+ source,
45
+ };
46
+ });
47
+ if (projects.length === 0) throw new Error(`No Python packages found under ${root}`);
48
+
49
+ try {
50
+ for (const project of projects) {
51
+ let stamped = project.source.replace(/^version = "[^"]+"$/m, `version = "${version}"`);
52
+ if (stamped === project.source) {
53
+ throw new Error(`Expected one project version in ${project.path}`);
54
+ }
55
+ for (const sibling of projects) {
56
+ stamped = stamped.replace(
57
+ new RegExp(
58
+ `${escapeRegExp(sibling.name)} @ git\\+[^" ]+#subdirectory=[^" ]+/${escapeRegExp(sibling.directory)}`,
59
+ "g",
60
+ ),
61
+ `${sibling.name}==${version}`,
62
+ );
63
+ }
64
+ chmodSync(project.path, project.mode | 0o200);
65
+ writeFileSync(project.path, stamped);
66
+ }
67
+ } catch (error) {
68
+ for (const project of projects) {
69
+ chmodSync(project.path, project.mode | 0o200);
70
+ writeFileSync(project.path, project.source);
71
+ chmodSync(project.path, project.mode);
72
+ }
73
+ throw error;
74
+ }
75
+
76
+ return () => {
77
+ for (const project of projects) {
78
+ chmodSync(project.path, project.mode | 0o200);
79
+ writeFileSync(project.path, project.source);
80
+ chmodSync(project.path, project.mode);
81
+ }
82
+ };
83
+ }
84
+
85
+ export function publishPythonProjects(options: {
86
+ readonly dryRun?: boolean;
87
+ readonly indexUrl: string;
88
+ readonly publishUrl: string;
89
+ readonly root: string;
90
+ readonly version: string;
91
+ }): void {
92
+ const root = resolve(options.root);
93
+ const output = mkdtempSync(join(tmpdir(), "dbx-tools-python-publish-"));
94
+ const restore = stampPythonProjects(root, options.version);
95
+ try {
96
+ exec.spawnSync("uv", ["build", "--all-packages", "--out-dir", output], {
97
+ cwd: process.cwd(),
98
+ stdout: "inherit",
99
+ stderr: "inherit",
100
+ stdin: "ignore",
101
+ check: true,
102
+ });
103
+ exec.spawnSync(
104
+ "uvx",
105
+ [
106
+ "--from",
107
+ "devpi-client",
108
+ "devpi",
109
+ "upload",
110
+ "--index",
111
+ options.publishUrl,
112
+ "--from-dir",
113
+ ...(options.dryRun ? ["--dry-run"] : []),
114
+ output,
115
+ ],
116
+ {
117
+ cwd: process.cwd(),
118
+ env: { ...process.env, UV_DEFAULT_INDEX: options.indexUrl },
119
+ stdout: "inherit",
120
+ stderr: "inherit",
121
+ stdin: "ignore",
122
+ check: true,
123
+ },
124
+ );
125
+ } finally {
126
+ restore();
127
+ rmSync(output, { recursive: true, force: true });
128
+ }
129
+ }
130
+
131
+ if (import.meta.main) {
132
+ const program = new Command();
133
+ program
134
+ .argument("<version>", "Python package version")
135
+ .requiredOption("--index-url <url>", "devpi Simple API URL")
136
+ .requiredOption("--publish-url <url>", "devpi writable index URL")
137
+ .option("--root <path>", "Python workspace package root", "packages/py")
138
+ .option("--dry-run", "build and inspect distributions without uploading")
139
+ .action(
140
+ (
141
+ version: string,
142
+ options: { dryRun?: boolean; indexUrl: string; publishUrl: string; root: string },
143
+ ) => publishPythonProjects({ ...options, version }),
144
+ );
145
+ await program.parseAsync();
146
+ }
@@ -0,0 +1,87 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { resolve } from "node:path";
4
+ import { exec } from "@dbx-tools/core";
5
+ import { net } from "@dbx-tools/shared-core";
6
+
7
+ export interface LocalPythonRegistry {
8
+ readonly indexUrl: string;
9
+ readonly publishUrl: string;
10
+ }
11
+
12
+ /** Read the default index URL from uv's TOML configuration. */
13
+ export function parseUvDefaultIndex(source: string): string | undefined {
14
+ const blocks = source.split(/(?=^\[\[index\]\]\s*$)/m);
15
+ for (const block of blocks) {
16
+ if (!/^\[\[index\]\]\s*$/m.test(block) || !/^\s*default\s*=\s*true\s*$/m.test(block)) {
17
+ continue;
18
+ }
19
+ const url = /^\s*url\s*=\s*["']([^"']+)["']\s*$/m.exec(block)?.[1];
20
+ if (url) return url;
21
+ }
22
+ return undefined;
23
+ }
24
+
25
+ /** Convert a devpi Simple API URL into its writable index URL. */
26
+ export function devpiRegistry(index: string): LocalPythonRegistry | undefined {
27
+ let url: URL;
28
+ try {
29
+ url = new URL(index);
30
+ } catch {
31
+ return undefined;
32
+ }
33
+ if (!net.isLoopbackHost(url)) return undefined;
34
+
35
+ const path = url.pathname.replace(/\/+$/, "");
36
+ if (!path.endsWith("/+simple")) return undefined;
37
+ url.pathname = `${path.slice(0, -"/+simple".length)}/`;
38
+ url.search = "";
39
+ url.hash = "";
40
+ return {
41
+ indexUrl: new URL("+simple/", url).href,
42
+ publishUrl: url.href,
43
+ };
44
+ }
45
+
46
+ /** The active Python package index, preferring uv because Python builds use uv. */
47
+ export function activePythonIndex(): string | undefined {
48
+ for (const value of [process.env.UV_DEFAULT_INDEX, process.env.UV_INDEX_URL]) {
49
+ if (value?.trim()) return value.trim();
50
+ }
51
+
52
+ const uvConfig = process.env.UV_CONFIG_FILE ?? resolve(homedir(), ".config/uv/uv.toml");
53
+ if (existsSync(uvConfig)) {
54
+ const index = parseUvDefaultIndex(readFileSync(uvConfig, "utf8"));
55
+ if (index) return index;
56
+ }
57
+
58
+ if (process.env.PIP_INDEX_URL?.trim()) return process.env.PIP_INDEX_URL.trim();
59
+ const pip = exec.spawnSync("python", ["-m", "pip", "config", "get", "global.index-url"], {
60
+ cwd: process.cwd(),
61
+ stdout: "capture",
62
+ stderr: "ignore",
63
+ stdin: "ignore",
64
+ check: false,
65
+ });
66
+ return pip.stdout?.trim() || undefined;
67
+ }
68
+
69
+ /** Resolve `auto`, `false`, or an explicit devpi index/publish URL. */
70
+ export function resolveLocalPypi(
71
+ value: string,
72
+ activeIndex: string | undefined = activePythonIndex(),
73
+ ): LocalPythonRegistry | undefined {
74
+ const trimmed = value.trim();
75
+ if (!trimmed || trimmed.toLowerCase() === "false") return undefined;
76
+ if (trimmed.toLowerCase() === "auto") {
77
+ return activeIndex ? devpiRegistry(activeIndex) : undefined;
78
+ }
79
+
80
+ const derived = devpiRegistry(trimmed);
81
+ if (derived) return derived;
82
+ const publishUrl = trimmed.endsWith("/") ? trimmed : `${trimmed}/`;
83
+ return {
84
+ publishUrl,
85
+ indexUrl: new URL("+simple/", publishUrl).href,
86
+ };
87
+ }