@dbx-tools/projen 0.6.78 → 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/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.78",
30
- "@dbx-tools/path": "0.6.78",
31
- "@dbx-tools/shared-core": "0.6.78",
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.78",
50
+ "version": "0.6.79",
51
51
  "types": "index.ts",
52
52
  "type": "module",
53
53
  "exports": {
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
+ }