versionary 0.23.0 → 0.24.0

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
@@ -49,7 +49,8 @@ release/tag.
49
49
 
50
50
  Current implementation focuses on:
51
51
 
52
- - strategy-based version updates (`simple`, `node`, `rust`, `r`, `latex`)
52
+ - strategy-based version updates (`simple`, `node`, `rust`, `r`, `latex`,
53
+ `python`)
53
54
  - release planning and changelog generation
54
55
  - review-mode vs direct-mode release flow
55
56
  - a static internal SCM client (`github` provider today)
@@ -64,7 +65,7 @@ changing release orchestration. A new strategy should implement the
64
65
  `VersionStrategy` contract in `src/strategy/types.ts` and be wired in
65
66
  `src/strategy/resolve.ts`.
66
67
 
67
- Checklist for new strategies (for example `python`):
68
+ Checklist for new strategies:
68
69
 
69
70
  - define strategy `name`
70
71
  - define `getVersionFile(config)` defaults and config override behavior
@@ -94,6 +95,10 @@ Current ecosystem policy defaults:
94
95
  - Node strategy updates root `package-lock.json`/`npm-shrinkwrap.json` when
95
96
  present
96
97
  - Rust release PR prep refreshes all discovered `Cargo.lock` files
98
+ - Python strategy refreshes any `poetry.lock`/`uv.lock`/`pdm.lock` at the
99
+ package root by shelling out to the matching tool (`poetry lock
100
+ --no-update`, `uv lock`, `pdm lock --update-reuse`); the corresponding
101
+ binary must be on `PATH`
97
102
  - workspace/inheritance:
98
103
  - Rust supports `version.workspace = true` via `[workspace.package].version`
99
104
  - other strategies should document equivalent inheritance behavior explicitly
@@ -105,7 +110,7 @@ Current runtime code uses a flat `src/` layout with clear module boundaries:
105
110
  - `src/cli/`: command router (`run`, `verify`, `plan`, `changelog`, `pr`, `release`)
106
111
  - `src/release/`: release orchestration (plan/changelog/PR/release/state/recovery)
107
112
  - `src/strategy/`: strategy contracts, resolver, and built-in implementations
108
- (`simple`, `node`, `rust`, `r`, `latex`)
113
+ (`simple`, `node`, `rust`, `r`, `latex`, `python`)
109
114
  - `src/scm/`: SCM client contracts and provider implementation(s)
110
115
  - `src/config/`: config loading and schema validation
111
116
  - `src/git/`: git commit/range and repository URL helpers
@@ -134,6 +139,11 @@ For a quick trial, use:
134
139
  - `release-type: "latex"` uses `build.lua` as version source and updates LaTeX
135
140
  `\ProvidesPackage{...}[YYYY-MM-DD vX.Y.Z ...]` metadata in `src/**/*.dtx`
136
141
  using the release commit date
142
+ - `release-type: "python"` uses `pyproject.toml` (default) and updates
143
+ `[project].version` and/or `[tool.poetry].version`; point `version-file` at a
144
+ Python source file (e.g. `src/<pkg>/__init__.py`) to update a `__version__`
145
+ assignment instead. Refreshes `poetry.lock`/`uv.lock`/`pdm.lock` at the
146
+ package root if present
137
147
  - simple/default strategy keeps `version.txt` as source of truth and does not
138
148
  update `package.json`
139
149
  - stable release branch (`release-branch`, default: `versionary/release`) so
@@ -1,7 +1,7 @@
1
1
  import { type ParsedCommit } from "../git/commits.js";
2
2
  import type { VersionaryChangelogFormat } from "../types/config.js";
3
- import type { ReleasePlan, SimplePlan } from "./plan.js";
4
- export declare function renderSimpleReleaseNotes(input: {
3
+ import { type ReleasePlan, type SimplePlan } from "./plan.js";
4
+ export declare function renderReleaseNotesSection(input: {
5
5
  currentVersion: string;
6
6
  nextVersion: string;
7
7
  commits: ParsedCommit[];
@@ -11,9 +11,10 @@ export declare function renderSimpleReleaseNotes(input: {
11
11
  version: string;
12
12
  }>;
13
13
  highlights?: string;
14
+ tagPrefix?: string;
15
+ headerLabel?: string;
14
16
  }, options?: {
15
17
  includeFooter?: boolean;
16
- headerLabel?: string;
17
18
  }): string;
18
19
  export declare function renderReviewRequestFooter(): string;
19
20
  export declare function renderReleasePlanChangelog(plan: ReleasePlan, options?: {
@@ -24,13 +25,6 @@ export declare function renderReleasePlanChangelog(plan: ReleasePlan, options?:
24
25
  }): string;
25
26
  /** @deprecated Use renderReleasePlanChangelog. */
26
27
  export declare function renderSimpleChangelog(plan: SimplePlan): string;
27
- export declare function renderPackageChangelogSection(input: {
28
- currentVersion: string;
29
- nextVersion: string;
30
- commits: ParsedCommit[];
31
- tagPrefix: string;
32
- cwd?: string;
33
- }): string;
34
28
  export declare function prependChangelog(cwd: string, changelogFile: string, section: string, format?: VersionaryChangelogFormat): void;
35
29
  export declare function renderRNewsReleaseNotes(input: {
36
30
  packageName: string;
@@ -3,17 +3,17 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.renderSimpleReleaseNotes = renderSimpleReleaseNotes;
6
+ exports.renderReleaseNotesSection = renderReleaseNotesSection;
7
7
  exports.renderReviewRequestFooter = renderReviewRequestFooter;
8
8
  exports.renderReleasePlanChangelog = renderReleasePlanChangelog;
9
9
  exports.renderSimpleChangelog = renderSimpleChangelog;
10
- exports.renderPackageChangelogSection = renderPackageChangelogSection;
11
10
  exports.prependChangelog = prependChangelog;
12
11
  exports.renderRNewsReleaseNotes = renderRNewsReleaseNotes;
13
12
  const node_fs_1 = __importDefault(require("node:fs"));
14
13
  const node_path_1 = __importDefault(require("node:path"));
15
14
  const commits_js_1 = require("../git/commits.js");
16
15
  const repo_url_js_1 = require("../git/repo-url.js");
16
+ const plan_js_1 = require("./plan.js");
17
17
  const REVIEW_REQUEST_FOOTER = "---\n\nThis PR was generated by [Versionary](https://github.com/jolars/versionary).";
18
18
  function formatDate() {
19
19
  return new Date().toISOString().slice(0, 10);
@@ -140,11 +140,12 @@ function groupCommitLines(commits, repoUrl) {
140
140
  }
141
141
  return { breaking, features, fixes, performance, reverts };
142
142
  }
143
- function renderSimpleReleaseNotes(input, options = {}) {
143
+ function renderReleaseNotesSection(input, options = {}) {
144
144
  const repoUrl = (0, repo_url_js_1.resolveRepositoryWebBaseUrl)(input.cwd ?? process.cwd());
145
- const headerLabel = options.headerLabel ?? input.nextVersion;
145
+ const headerLabel = input.headerLabel ?? input.nextVersion;
146
+ const versionPrefix = input.tagPrefix ? `${input.tagPrefix}-v` : "v";
146
147
  const header = repoUrl
147
- ? `## [${headerLabel}](${repoUrl}/compare/v${input.currentVersion}...v${input.nextVersion}) (${formatDate()})`
148
+ ? `## [${headerLabel}](${repoUrl}/compare/${versionPrefix}${input.currentVersion}...${versionPrefix}${input.nextVersion}) (${formatDate()})`
148
149
  : `## ${headerLabel} (${formatDate()})`;
149
150
  const grouped = groupCommitLines(input.commits, repoUrl);
150
151
  const sections = [];
@@ -183,30 +184,6 @@ function renderReleasePlanChangelog(plan, options = {}) {
183
184
  if (!plan.nextVersion) {
184
185
  return "";
185
186
  }
186
- const propagatedRootPackage = plan.packages?.find((pkg) => pkg.path === ".");
187
- const isDirectBump = (pkg) => pkg.bumpReason === "direct" ||
188
- (pkg.bumpReason === undefined &&
189
- Boolean(pkg.nextVersion) &&
190
- pkg.commits.length > 0);
191
- const legacyDependencySources = propagatedRootPackage?.bumpReason === "dependency-propagation" &&
192
- plan.packages
193
- ? plan.packages
194
- .filter((pkg) => pkg.path !== "." && isDirectBump(pkg) && pkg.nextVersion)
195
- .map((pkg) => pkg.path)
196
- : [];
197
- const dependencySourcePaths = [
198
- ...(propagatedRootPackage?.dependencySourcePaths ??
199
- legacyDependencySources),
200
- ].sort((a, b) => a.localeCompare(b));
201
- const dependencies = plan.packages
202
- ? dependencySourcePaths
203
- .map((sourcePath) => plan.packages?.find((pkg) => pkg.path === sourcePath && pkg.nextVersion))
204
- .filter((sourcePackage) => Boolean(sourcePackage))
205
- .map((pkg) => ({
206
- name: pkg.path,
207
- version: pkg.nextVersion,
208
- }))
209
- : [];
210
187
  const dedupedCommits = [
211
188
  ...new Map(plan.commits.map((commit) => [commit.hash, commit])).values(),
212
189
  ];
@@ -219,46 +196,22 @@ function renderReleasePlanChangelog(plan, options = {}) {
219
196
  highlights: options.highlights,
220
197
  });
221
198
  }
222
- return renderSimpleReleaseNotes({
199
+ return renderReleaseNotesSection({
223
200
  currentVersion: plan.currentVersion,
224
201
  nextVersion: plan.nextVersion,
225
202
  commits: dedupedCommits,
226
203
  cwd: options.cwd ?? process.cwd(),
227
- dependencies,
204
+ dependencies: (0, plan_js_1.resolvePackageDependencies)(plan, "."),
228
205
  highlights: options.highlights,
206
+ headerLabel: options.headerLabel,
229
207
  }, {
230
208
  includeFooter: options.includeFooter,
231
- headerLabel: options.headerLabel,
232
209
  });
233
210
  }
234
211
  /** @deprecated Use renderReleasePlanChangelog. */
235
212
  function renderSimpleChangelog(plan) {
236
213
  return renderReleasePlanChangelog(plan);
237
214
  }
238
- function renderPackageChangelogSection(input) {
239
- const repoUrl = (0, repo_url_js_1.resolveRepositoryWebBaseUrl)(input.cwd ?? process.cwd());
240
- const header = repoUrl
241
- ? `## [${input.nextVersion}](${repoUrl}/compare/${input.tagPrefix}-v${input.currentVersion}...${input.tagPrefix}-v${input.nextVersion}) (${formatDate()})`
242
- : `## ${input.nextVersion} (${formatDate()})`;
243
- const grouped = groupCommitLines(input.commits, repoUrl);
244
- const sections = [];
245
- if (grouped.breaking.length > 0) {
246
- sections.push("### Breaking changes", ...grouped.breaking, "");
247
- }
248
- if (grouped.features.length > 0) {
249
- sections.push("### Features", ...grouped.features, "");
250
- }
251
- if (grouped.fixes.length > 0) {
252
- sections.push("### Bug Fixes", ...grouped.fixes, "");
253
- }
254
- if (grouped.performance.length > 0) {
255
- sections.push("### Performance Improvements", ...grouped.performance, "");
256
- }
257
- if (grouped.reverts.length > 0) {
258
- sections.push("### Reverts", ...grouped.reverts, "");
259
- }
260
- return [header, "", ...sections].join("\n");
261
- }
262
215
  function prependChangelog(cwd, changelogFile, section, format = "markdown-changelog") {
263
216
  const changelogPath = node_path_1.default.join(cwd, changelogFile);
264
217
  const existing = node_fs_1.default.existsSync(changelogPath)
@@ -37,3 +37,7 @@ export declare function getChangelogDefaults(config: {
37
37
  export declare function createReleasePlan(cwd?: string): ReleasePlan;
38
38
  /** @deprecated Use createReleasePlan. */
39
39
  export declare function createSimplePlan(cwd?: string): ReleasePlan;
40
+ export declare function resolvePackageDependencies(plan: ReleasePlan, packagePath: string): Array<{
41
+ name: string;
42
+ version: string;
43
+ }>;
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.getChangelogDefaults = getChangelogDefaults;
7
7
  exports.createReleasePlan = createReleasePlan;
8
8
  exports.createSimplePlan = createSimplePlan;
9
+ exports.resolvePackageDependencies = resolvePackageDependencies;
9
10
  const node_fs_1 = __importDefault(require("node:fs"));
10
11
  const node_path_1 = __importDefault(require("node:path"));
11
12
  const load_config_js_1 = require("../config/load-config.js");
@@ -311,3 +312,15 @@ function createReleasePlan(cwd = process.cwd()) {
311
312
  function createSimplePlan(cwd = process.cwd()) {
312
313
  return createReleasePlan(cwd);
313
314
  }
315
+ function resolvePackageDependencies(plan, packagePath) {
316
+ const target = plan.packages?.find((pkg) => pkg.path === packagePath);
317
+ if (!target) {
318
+ return [];
319
+ }
320
+ const sources = target.dependencySourcePaths ?? [];
321
+ return sources
322
+ .map((sourcePath) => plan.packages?.find((pkg) => pkg.path === sourcePath && pkg.nextVersion))
323
+ .filter((sourcePackage) => Boolean(sourcePackage))
324
+ .map((pkg) => ({ name: pkg.path, version: pkg.nextVersion }))
325
+ .sort((a, b) => a.name.localeCompare(b.name));
326
+ }
@@ -26,7 +26,7 @@ export declare function prepareReleasePr(cwd?: string, options?: {
26
26
  updated: boolean;
27
27
  highlights: string;
28
28
  };
29
- export declare function renderSimpleReviewRequestBody(version: string, previousVersion: string, commits: ParsedCommit[], plan?: SimplePlan | null, cwd?: string, highlights?: string): string;
29
+ export declare function renderSimpleReviewRequestBody(version: string, previousVersion: string, commits: ParsedCommit[], plan?: SimplePlan | null, cwd?: string, highlights?: string, loadedConfig?: VersionaryConfig): string;
30
30
  export declare function openOrUpdateReviewRequest(cwd: string, branch: string, title: string, version: string, previousVersion: string, commits: ParsedCommit[], plan?: SimplePlan | null, options?: {
31
31
  logger?: VersionaryPluginContext["logger"];
32
32
  highlights?: string;
@@ -315,12 +315,13 @@ function prepareReleasePr(cwd = process.cwd(), options = {}) {
315
315
  if (!packageMetadata) {
316
316
  continue;
317
317
  }
318
- const packageSection = (0, changelog_js_1.renderPackageChangelogSection)({
318
+ const packageSection = (0, changelog_js_1.renderReleaseNotesSection)({
319
319
  currentVersion: packagePlan.currentVersion,
320
320
  nextVersion: packagePlan.nextVersion,
321
321
  commits: packagePlan.commits,
322
322
  tagPrefix: packageMetadata.tagPrefix,
323
323
  cwd,
324
+ dependencies: (0, plan_js_1.resolvePackageDependencies)(plan, packagePlan.path),
324
325
  });
325
326
  (0, changelog_js_1.prependChangelog)(cwd, node_path_1.default.posix.join(packagePlan.path, packageChangelogFile), packageSection, "markdown-changelog");
326
327
  updatedChangelogFiles.push(node_path_1.default.posix.join(packagePlan.path, packageChangelogFile));
@@ -374,32 +375,20 @@ function prepareReleasePr(cwd = process.cwd(), options = {}) {
374
375
  highlights,
375
376
  };
376
377
  }
377
- function renderSimpleReviewRequestBody(version, previousVersion, commits, plan = null, cwd = process.cwd(), highlights = "") {
378
+ function renderSimpleReviewRequestBody(version, previousVersion, commits, plan = null, cwd = process.cwd(), highlights = "", loadedConfig) {
378
379
  const rootPackageLabel = node_path_1.default.basename(cwd);
379
380
  const formatPackageLabel = (packagePath) => packagePath === "." ? rootPackageLabel : packagePath;
380
- const isDirectBump = (pkg) => pkg.bumpReason === "direct" ||
381
- (pkg.bumpReason === undefined &&
382
- Boolean(pkg.nextVersion) &&
383
- pkg.commits.length > 0);
384
- const findPropagatedDependencies = (packagePath, packages) => {
385
- const target = packages.find((pkg) => pkg.path === packagePath);
386
- if (!target) {
387
- return [];
381
+ const resolveTagPrefix = (packagePath) => {
382
+ if (packagePath === ".") {
383
+ return undefined;
388
384
  }
389
- const sourcePaths = target.dependencySourcePaths && target.dependencySourcePaths.length > 0
390
- ? target.dependencySourcePaths
391
- : target.bumpReason === "dependency-propagation"
392
- ? packages.filter((pkg) => isDirectBump(pkg)).map((pkg) => pkg.path)
393
- : [];
394
- return sourcePaths
395
- .map((sourcePath) => packages.find((pkg) => pkg.path === sourcePath))
396
- .filter((sourcePackage) => Boolean(sourcePackage))
397
- .map((pkg) => ({
398
- name: formatPackageLabel(pkg.path),
399
- version: pkg.nextVersion ?? "",
400
- }))
401
- .filter((dependency) => dependency.version.length > 0)
402
- .sort((a, b) => a.name.localeCompare(b.name));
385
+ if (!loadedConfig) {
386
+ return normalizeReleaseNameForTag(packagePath);
387
+ }
388
+ const packageConfig = loadedConfig.packages?.[packagePath] ?? {};
389
+ const packageContext = (0, package_context_js_1.resolvePackageStrategyContext)(loadedConfig, packagePath, packageConfig);
390
+ const releaseName = resolveReleaseName(cwd, packagePath, packageConfig, packageContext.strategy, packageContext.config);
391
+ return normalizeReleaseNameForTag(releaseName);
403
392
  };
404
393
  if (plan?.packages && plan.packages.length > 1) {
405
394
  const sections = [];
@@ -414,21 +403,15 @@ function renderSimpleReviewRequestBody(version, previousVersion, commits, plan =
414
403
  }
415
404
  const packageSections = plan.packages
416
405
  .filter((pkg) => pkg.path !== "." && pkg.nextVersion)
417
- .map((pkg) => {
418
- const packageLabel = formatPackageLabel(pkg.path);
419
- const propagatedDependencies = findPropagatedDependencies(pkg.path, plan.packages ?? []);
420
- const notes = (0, changelog_js_1.renderSimpleReleaseNotes)({
421
- currentVersion: pkg.currentVersion,
422
- nextVersion: pkg.nextVersion ?? "",
423
- commits: pkg.commits,
424
- cwd,
425
- dependencies: propagatedDependencies,
426
- }, {
427
- includeFooter: false,
428
- headerLabel: `${packageLabel}: ${pkg.nextVersion ?? ""}`,
429
- });
430
- return notes;
431
- });
406
+ .map((pkg) => (0, changelog_js_1.renderReleaseNotesSection)({
407
+ currentVersion: pkg.currentVersion,
408
+ nextVersion: pkg.nextVersion ?? "",
409
+ commits: pkg.commits,
410
+ cwd,
411
+ dependencies: (0, plan_js_1.resolvePackageDependencies)(plan, pkg.path),
412
+ tagPrefix: resolveTagPrefix(pkg.path),
413
+ headerLabel: `${formatPackageLabel(pkg.path)}: ${pkg.nextVersion ?? ""}`,
414
+ }));
432
415
  sections.push(...packageSections);
433
416
  const bodySections = sections.join("\n\n");
434
417
  if (bodySections.length === 0) {
@@ -436,7 +419,7 @@ function renderSimpleReviewRequestBody(version, previousVersion, commits, plan =
436
419
  }
437
420
  return `${bodySections}\n\n${(0, changelog_js_1.renderReviewRequestFooter)()}`;
438
421
  }
439
- return (0, changelog_js_1.renderSimpleReleaseNotes)({
422
+ return (0, changelog_js_1.renderReleaseNotesSection)({
440
423
  currentVersion: previousVersion,
441
424
  nextVersion: version,
442
425
  commits,
@@ -455,7 +438,7 @@ async function openOrUpdateReviewRequest(cwd, branch, title, version, previousVe
455
438
  baseBranch: process.env.VERSIONARY_BASE_BRANCH ?? "main",
456
439
  headBranch: branch,
457
440
  title,
458
- body: renderSimpleReviewRequestBody(version, previousVersion, commits, plan, cwd, options.highlights ?? ""),
441
+ body: renderSimpleReviewRequestBody(version, previousVersion, commits, plan, cwd, options.highlights ?? "", loaded.config),
459
442
  labels: ["release"],
460
443
  }, {
461
444
  cwd,
@@ -0,0 +1,2 @@
1
+ import type { VersionStrategy } from "./types.js";
2
+ export declare const pythonVersionStrategy: VersionStrategy;
@@ -0,0 +1,245 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.pythonVersionStrategy = void 0;
7
+ const node_child_process_1 = require("node:child_process");
8
+ const node_fs_1 = __importDefault(require("node:fs"));
9
+ const node_path_1 = __importDefault(require("node:path"));
10
+ const toml_1 = __importDefault(require("@iarna/toml"));
11
+ const SOURCE_FILE_VERSION_PATTERN = /^(\s*__version__\s*=\s*)(["'])([^"']+)(\2)(\s*(?:#.*)?)?$/mu;
12
+ function isSourceFileMode(versionFile) {
13
+ return versionFile.toLowerCase().endsWith(".py");
14
+ }
15
+ function parsePyProject(content, versionFile) {
16
+ let parsed;
17
+ try {
18
+ parsed = toml_1.default.parse(content);
19
+ }
20
+ catch (error) {
21
+ const message = error instanceof Error ? error.message : String(error);
22
+ throw new Error(`Failed to parse ${versionFile}: ${message}`);
23
+ }
24
+ const root = parsed;
25
+ const project = root.project && typeof root.project === "object"
26
+ ? root.project
27
+ : null;
28
+ const toolPoetry = root.tool &&
29
+ typeof root.tool === "object" &&
30
+ "poetry" in root.tool &&
31
+ typeof root.tool.poetry === "object" &&
32
+ root.tool.poetry !== null
33
+ ? root.tool.poetry
34
+ : null;
35
+ return { project, toolPoetry };
36
+ }
37
+ function lookupPyProjectVersions(parsed) {
38
+ const projectVersionRaw = parsed.project?.version;
39
+ const poetryVersionRaw = parsed.toolPoetry?.version;
40
+ return {
41
+ projectVersion: typeof projectVersionRaw === "string" &&
42
+ projectVersionRaw.trim().length > 0
43
+ ? projectVersionRaw.trim()
44
+ : null,
45
+ poetryVersion: typeof poetryVersionRaw === "string" && poetryVersionRaw.trim().length > 0
46
+ ? poetryVersionRaw.trim()
47
+ : null,
48
+ };
49
+ }
50
+ function readPyProjectVersion(content, versionFile) {
51
+ const parsed = parsePyProject(content, versionFile);
52
+ const { projectVersion, poetryVersion } = lookupPyProjectVersions(parsed);
53
+ const version = projectVersion ?? poetryVersion;
54
+ if (!version) {
55
+ throw new Error(`${versionFile} is missing a valid "[project].version" or "[tool.poetry].version" field required by release-type "python".`);
56
+ }
57
+ return version;
58
+ }
59
+ function replaceVersionInTable(rawContent, targetTables, version) {
60
+ const lineEnding = rawContent.includes("\r\n") ? "\r\n" : "\n";
61
+ const hasFinalLineEnding = rawContent.endsWith("\n") || rawContent.endsWith("\r\n");
62
+ const lines = rawContent.split(/\r?\n/u);
63
+ const replaced = new Set();
64
+ let activeTable = null;
65
+ for (let index = 0; index < lines.length; index += 1) {
66
+ const line = lines[index] ?? "";
67
+ const sectionMatch = line.match(/^\s*\[([^\]]+)\]\s*(?:#.*)?$/u);
68
+ if (sectionMatch) {
69
+ activeTable = sectionMatch[1]?.trim() ?? null;
70
+ continue;
71
+ }
72
+ if (!activeTable || !targetTables.has(activeTable)) {
73
+ continue;
74
+ }
75
+ if (replaced.has(activeTable)) {
76
+ continue;
77
+ }
78
+ const versionMatch = line.match(/^(\s*version\s*=\s*)(["'])([^"']*)(\2)(\s*(?:#.*)?)?$/u);
79
+ if (!versionMatch) {
80
+ continue;
81
+ }
82
+ const [, prefix = "", quote = '"', , , suffix = ""] = versionMatch;
83
+ lines[index] = `${prefix}${quote}${version}${quote}${suffix}`;
84
+ replaced.add(activeTable);
85
+ }
86
+ let updated = lines.join(lineEnding);
87
+ if (hasFinalLineEnding && !updated.endsWith(lineEnding)) {
88
+ updated += lineEnding;
89
+ }
90
+ if (!hasFinalLineEnding && updated.endsWith(lineEnding)) {
91
+ updated = updated.slice(0, -lineEnding.length);
92
+ }
93
+ return { content: updated, replaced };
94
+ }
95
+ function writePyProjectVersion(rawContent, versionFile, version) {
96
+ const parsed = parsePyProject(rawContent, versionFile);
97
+ const { projectVersion, poetryVersion } = lookupPyProjectVersions(parsed);
98
+ if (!projectVersion && !poetryVersion) {
99
+ throw new Error(`${versionFile} is missing a valid "[project].version" or "[tool.poetry].version" field required by release-type "python".`);
100
+ }
101
+ const targets = new Set();
102
+ if (projectVersion) {
103
+ targets.add("project");
104
+ }
105
+ if (poetryVersion) {
106
+ targets.add("tool.poetry");
107
+ }
108
+ const { content, replaced } = replaceVersionInTable(rawContent, targets, version);
109
+ for (const target of targets) {
110
+ if (!replaced.has(target)) {
111
+ throw new Error(`Failed to update [${target}].version in ${versionFile}; ensure the version line uses the form \`version = "X.Y.Z"\`.`);
112
+ }
113
+ }
114
+ return content;
115
+ }
116
+ function readSourceFileVersion(content, versionFile) {
117
+ const match = content.match(SOURCE_FILE_VERSION_PATTERN);
118
+ if (!match?.[3]) {
119
+ throw new Error(`${versionFile} is missing a valid \`__version__ = "X.Y.Z"\` assignment required by release-type "python".`);
120
+ }
121
+ return match[3].trim();
122
+ }
123
+ function writeSourceFileVersion(content, versionFile, version) {
124
+ if (!SOURCE_FILE_VERSION_PATTERN.test(content)) {
125
+ throw new Error(`${versionFile} is missing a valid \`__version__ = "X.Y.Z"\` assignment required by release-type "python".`);
126
+ }
127
+ return content.replace(SOURCE_FILE_VERSION_PATTERN, (_full, prefix, quote, _value, _close, suffix = "") => `${prefix}${quote}${version}${quote}${suffix ?? ""}`);
128
+ }
129
+ const LOCKFILE_SPECS = [
130
+ {
131
+ lockfile: "poetry.lock",
132
+ command: "poetry",
133
+ args: ["lock", "--no-update"],
134
+ installHint: "install Poetry (https://python-poetry.org/)",
135
+ },
136
+ {
137
+ lockfile: "uv.lock",
138
+ command: "uv",
139
+ args: ["lock"],
140
+ installHint: "install uv (https://docs.astral.sh/uv/)",
141
+ },
142
+ {
143
+ lockfile: "pdm.lock",
144
+ command: "pdm",
145
+ args: ["lock", "--update-reuse"],
146
+ installHint: "install PDM (https://pdm-project.org/)",
147
+ },
148
+ ];
149
+ function refreshLockfile(cwd, spec) {
150
+ try {
151
+ (0, node_child_process_1.execFileSync)(spec.command, [...spec.args], {
152
+ cwd,
153
+ stdio: ["ignore", "pipe", "pipe"],
154
+ });
155
+ }
156
+ catch (error) {
157
+ const message = error instanceof Error ? error.message : String(error);
158
+ throw new Error(`Failed to refresh ${spec.lockfile} via "${spec.command} ${spec.args.join(" ")}". Ensure ${spec.command} is on PATH (${spec.installHint}) or remove ${spec.lockfile} from the working tree. Details: ${message}`);
159
+ }
160
+ }
161
+ function readPyProjectFromCwd(cwd) {
162
+ const target = node_path_1.default.join(cwd, "pyproject.toml");
163
+ if (!node_fs_1.default.existsSync(target)) {
164
+ return null;
165
+ }
166
+ const content = node_fs_1.default.readFileSync(target, "utf8");
167
+ return { parsed: parsePyProject(content, "pyproject.toml"), rawPath: target };
168
+ }
169
+ exports.pythonVersionStrategy = {
170
+ name: "python",
171
+ getVersionFile(config) {
172
+ return config["version-file"] ?? "pyproject.toml";
173
+ },
174
+ validateProject(cwd, config) {
175
+ const versionFile = this.getVersionFile(config);
176
+ const versionPath = node_path_1.default.join(cwd, versionFile);
177
+ if (!node_fs_1.default.existsSync(versionPath)) {
178
+ return null;
179
+ }
180
+ try {
181
+ const content = node_fs_1.default.readFileSync(versionPath, "utf8");
182
+ if (isSourceFileMode(versionFile)) {
183
+ readSourceFileVersion(content, versionFile);
184
+ }
185
+ else {
186
+ readPyProjectVersion(content, versionFile);
187
+ }
188
+ return null;
189
+ }
190
+ catch (error) {
191
+ return error instanceof Error ? error.message : String(error);
192
+ }
193
+ },
194
+ readVersion(cwd, config) {
195
+ const versionFile = this.getVersionFile(config);
196
+ const versionPath = node_path_1.default.join(cwd, versionFile);
197
+ if (!node_fs_1.default.existsSync(versionPath)) {
198
+ throw new Error(`Versionary requires ${versionFile} to exist.`);
199
+ }
200
+ const content = node_fs_1.default.readFileSync(versionPath, "utf8");
201
+ return isSourceFileMode(versionFile)
202
+ ? readSourceFileVersion(content, versionFile)
203
+ : readPyProjectVersion(content, versionFile);
204
+ },
205
+ writeVersion(cwd, config, version) {
206
+ const versionFile = this.getVersionFile(config);
207
+ const versionPath = node_path_1.default.join(cwd, versionFile);
208
+ if (!node_fs_1.default.existsSync(versionPath)) {
209
+ throw new Error(`Versionary requires ${versionFile} to exist.`);
210
+ }
211
+ const existing = node_fs_1.default.readFileSync(versionPath, "utf8");
212
+ const updated = isSourceFileMode(versionFile)
213
+ ? writeSourceFileVersion(existing, versionFile, version)
214
+ : writePyProjectVersion(existing, versionFile, version);
215
+ node_fs_1.default.writeFileSync(versionPath, updated, "utf8");
216
+ return [versionFile];
217
+ },
218
+ readPackageName(cwd, _config) {
219
+ const found = readPyProjectFromCwd(cwd);
220
+ if (!found) {
221
+ return null;
222
+ }
223
+ const projectName = found.parsed.project?.name;
224
+ if (typeof projectName === "string" && projectName.trim().length > 0) {
225
+ return projectName.trim();
226
+ }
227
+ const poetryName = found.parsed.toolPoetry?.name;
228
+ if (typeof poetryName === "string" && poetryName.trim().length > 0) {
229
+ return poetryName.trim();
230
+ }
231
+ return null;
232
+ },
233
+ finalizeVersionWrites(cwd, _writes, _context) {
234
+ const refreshed = [];
235
+ for (const spec of LOCKFILE_SPECS) {
236
+ const lockPath = node_path_1.default.join(cwd, spec.lockfile);
237
+ if (!node_fs_1.default.existsSync(lockPath)) {
238
+ continue;
239
+ }
240
+ refreshLockfile(cwd, spec);
241
+ refreshed.push(spec.lockfile);
242
+ }
243
+ return refreshed.sort((a, b) => a.localeCompare(b));
244
+ },
245
+ };
@@ -4,6 +4,7 @@ exports.listKnownReleaseTypes = listKnownReleaseTypes;
4
4
  exports.resolveVersionStrategy = resolveVersionStrategy;
5
5
  const latex_js_1 = require("./latex.js");
6
6
  const node_js_1 = require("./node.js");
7
+ const python_js_1 = require("./python.js");
7
8
  const r_js_1 = require("./r.js");
8
9
  const rust_js_1 = require("./rust.js");
9
10
  const simple_js_1 = require("./simple.js");
@@ -13,6 +14,7 @@ const strategyRegistry = {
13
14
  node: node_js_1.nodeVersionStrategy,
14
15
  rust: rust_js_1.rustVersionStrategy,
15
16
  r: r_js_1.rVersionStrategy,
17
+ python: python_js_1.pythonVersionStrategy,
16
18
  };
17
19
  function listKnownReleaseTypes() {
18
20
  return Object.keys(strategyRegistry).sort((a, b) => a.localeCompare(b));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "versionary",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "Automatic release framework based on conventional commits and semantic versioning",
5
5
  "keywords": [
6
6
  "releasing",