diff --git a/__snapshots__/uv-lock.js b/__snapshots__/uv-lock.js new file mode 100644 index 000000000..3cf44a2ba --- /dev/null +++ b/__snapshots__/uv-lock.js @@ -0,0 +1,59 @@ +exports['UvLock updateContent updates the package version while preserving formatting 1'] = ` +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265 }, +] + +[[package]] +name = "aiohttp" +version = "3.13.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/42/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/4c/aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b", size = 746051 }, +] + +[[package]] +name = "my-cool-package" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } + +[[package]] +name = "virtual-workspace-member" +source = { virtual = "." } + +[[package]] +name = "socketry" +version = "0.3.0" +source = { editable = "." } +dependencies = [ + { name = "aiohttp" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiohttp", specifier = ">=3.9" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=9.0.2" }, +] + +` diff --git a/src/strategies/python.ts b/src/strategies/python.ts index bd9325165..85d2ff08e 100644 --- a/src/strategies/python.ts +++ b/src/strategies/python.ts @@ -25,6 +25,7 @@ import { PyProjectToml, } from '../updaters/python/pyproject-toml'; import {PythonFileWithVersion} from '../updaters/python/python-file-with-version'; +import {UvLock} from '../updaters/python/uv-lock'; import {FileNotFoundError} from '../errors'; import {filterCommits} from '../util/filter-commits'; @@ -106,6 +107,12 @@ export class Python extends BaseStrategy { if (!projectName) { this.logger.warn('No project/component found.'); } else { + updates.push({ + path: this.addPath('uv.lock'), + createIfMissing: false, + updater: new UvLock({packageName: projectName, version}), + }); + [projectName, projectName.replace(/-/g, '_')] .flatMap(packageName => [ `${packageName}/__init__.py`, diff --git a/src/updaters/python/uv-lock.ts b/src/updaters/python/uv-lock.ts new file mode 100644 index 000000000..850a4a83e --- /dev/null +++ b/src/updaters/python/uv-lock.ts @@ -0,0 +1,130 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as TOML from '@iarna/toml'; +import {replaceTomlValue} from '../../util/toml-edit'; +import {logger as defaultLogger, Logger} from '../../util/logger'; +import {Updater} from '../../update'; +import {Version} from '../../version'; + +interface UvLockfile { + // sic. the key is singular, but it's an array + package?: UvLockfilePackage[]; +} + +interface UvLockfilePackage { + name?: string; + version?: string; +} + +function parseUvLockfile(content: string): UvLockfile { + return TOML.parse(content) as UvLockfile; +} + +/** + * Normalizes a Python package name per PEP 503: lowercased, with runs of + * `-`, `_`, or `.` collapsed to a single `-`. + */ +function normalizePackageName(name: string): string { + return name.toLowerCase().replace(/[-_.]+/g, '-'); +} + +/** + * Updates `uv.lock` lockfiles, preserving formatting and comments. + * + * Note: the content is parsed twice — once here to find the matching package + * index, and again inside `replaceTomlValue` (which uses a position-tracking + * parser for byte-accurate string splicing). This is inherent to the + * `replaceTomlValue` API design and is consistent with how `CargoLock` works. + */ +export class UvLock implements Updater { + private packageName: string; + private version: Version; + + constructor(options: {packageName: string; version: Version}) { + this.packageName = options.packageName; + this.version = options.version; + } + + /** + * Given initial file contents, return updated contents. + * @param {string} content The initial content + * @returns {string} The updated content + */ + updateContent(content: string, logger: Logger = defaultLogger): string { + let payload = content; + + const parsed = parseUvLockfile(payload); + if (!parsed.package) { + // Unlike CargoLock (which throws here), we only warn. A uv.lock without + // [[package]] entries can exist for a freshly-initialized project before + // its first `uv lock` run; crashing the release would be unhelpful. + logger.warn('uv.lock has no [[package]] entries'); + return payload; + } + + const normalizedTarget = normalizePackageName(this.packageName); + + // n.b for `replaceTomlValue`, we need to keep track of the index + // (position) of the package we're considering. + let foundVersioned = false; + let foundVersionless = false; + for (let i = 0; i < parsed.package.length; i++) { + const pkg = parsed.package[i]; + if (!pkg.name) { + continue; + } + + if (normalizePackageName(pkg.name) !== normalizedTarget) { + continue; + } + + if (!pkg.version) { + // Virtual packages (workspace members without a version) have no + // `version` field; record that we found the package but cannot bump it. + foundVersionless = true; + continue; + } + + // note: in ECMAScript, using strings to index arrays is perfectly valid, + // which is lucky because `replaceTomlValue` expects "all strings" in its + // `path` argument. + const packageIndex = i.toString(); + + foundVersioned = true; + logger.info(`updating ${pkg.name} in uv.lock`); + // Note: replaceTomlValue may throw if the lockfile is structurally + // malformed (e.g., the `version` field is not a plain string). This is + // consistent with the CargoLock updater and is intentional — a corrupt + // lockfile should surface as an error rather than be silently skipped. + payload = replaceTomlValue( + payload, + ['package', packageIndex, 'version'], + this.version.toString() + ); + } + + if (!foundVersioned) { + if (foundVersionless) { + logger.warn( + `${this.packageName} is in uv.lock but has no version field (virtual package); skipping` + ); + } else { + logger.warn(`package ${this.packageName} not found in uv.lock`); + } + } + + return payload; + } +} diff --git a/test/strategies/python.ts b/test/strategies/python.ts index 1a95cc0f1..90e86cefa 100644 --- a/test/strategies/python.ts +++ b/test/strategies/python.ts @@ -29,6 +29,7 @@ import {Version} from '../../src/version'; import {PyProjectToml} from '../../src/updaters/python/pyproject-toml'; import {SetupCfg} from '../../src/updaters/python/setup-cfg'; import {SetupPy} from '../../src/updaters/python/setup-py'; +import {UvLock} from '../../src/updaters/python/uv-lock'; import {Changelog} from '../../src/updaters/changelog'; import {ChangelogJson} from '../../src/updaters/changelog-json'; import * as snapshot from 'snap-shot-it'; @@ -125,6 +126,7 @@ describe('Python', () => { assertHasUpdate(updates, 'CHANGELOG.md', Changelog); assertHasUpdate(updates, 'setup.cfg', SetupCfg); assertHasUpdate(updates, 'setup.py', SetupPy); + assertHasUpdate(updates, 'uv.lock', UvLock); assertHasUpdate( updates, 'google-cloud-automl/__init__.py', @@ -167,6 +169,7 @@ describe('Python', () => { assertNoHasUpdate(updates, 'CHANGELOG.md'); assertHasUpdate(updates, 'setup.cfg', SetupCfg); assertHasUpdate(updates, 'setup.py', SetupPy); + assertHasUpdate(updates, 'uv.lock', UvLock); assertHasUpdate( updates, 'google-cloud-automl/__init__.py', @@ -208,6 +211,7 @@ describe('Python', () => { ); const updates = release!.updates; assertHasUpdate(updates, 'pyproject.toml', PyProjectToml); + assertHasUpdate(updates, 'uv.lock', UvLock); }); it('finds and updates a version.py file', async () => { diff --git a/test/updaters/fixtures/uv.lock b/test/updaters/fixtures/uv.lock new file mode 100644 index 000000000..5b6f26daf --- /dev/null +++ b/test/updaters/fixtures/uv.lock @@ -0,0 +1,56 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265 }, +] + +[[package]] +name = "aiohttp" +version = "3.13.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/42/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/4c/aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b", size = 746051 }, +] + +[[package]] +name = "my-cool-package" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } + +[[package]] +name = "virtual-workspace-member" +source = { virtual = "." } + +[[package]] +name = "socketry" +version = "0.2.1" +source = { editable = "." } +dependencies = [ + { name = "aiohttp" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiohttp", specifier = ">=3.9" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=9.0.2" }, +] diff --git a/test/updaters/uv-lock.ts b/test/updaters/uv-lock.ts new file mode 100644 index 000000000..64277656f --- /dev/null +++ b/test/updaters/uv-lock.ts @@ -0,0 +1,152 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {readFileSync} from 'fs'; +import {resolve} from 'path'; +import * as snapshot from 'snap-shot-it'; +import {describe, it} from 'mocha'; +import {expect} from 'chai'; +import * as TOML from '@iarna/toml'; +import {UvLock} from '../../src/updaters/python/uv-lock'; +import {Version} from '../../src/version'; +import {Logger} from '../../src/util/logger'; + +const fixturesPath = './test/updaters/fixtures'; + +function loadFixture(): string { + return readFileSync(resolve(fixturesPath, 'uv.lock'), 'utf8').replace( + /\r\n/g, + '\n' + ); +} + +function parsedPackages(content: string): {name?: string; version?: string}[] { + const parsed = TOML.parse(content) as { + package?: {name?: string; version?: string}[]; + }; + return parsed.package ?? []; +} + +describe('UvLock', () => { + describe('updateContent', () => { + it('updates the package version while preserving formatting', () => { + const oldContent = loadFixture(); + const uvLock = new UvLock({ + packageName: 'socketry', + version: Version.parse('0.3.0'), + }); + const newContent = uvLock.updateContent(oldContent); + const pkg = parsedPackages(newContent).find(p => p.name === 'socketry'); + expect(pkg).to.deep.include({name: 'socketry', version: '0.3.0'}); + snapshot(newContent); + }); + + it('normalizes package name per PEP 508 when matching', () => { + const oldContent = loadFixture(); + // 'Socketry' should match 'socketry' (case-insensitive) + const uvLock = new UvLock({ + packageName: 'Socketry', + version: Version.parse('0.3.0'), + }); + const newContent = uvLock.updateContent(oldContent); + const pkg = parsedPackages(newContent).find(p => p.name === 'socketry'); + expect(pkg?.version).to.eql('0.3.0'); + }); + + it('warns and returns unchanged content for virtual packages without a version field', () => { + const oldContent = loadFixture(); + // 'virtual-workspace-member' has no version field in the fixture + const warnings: string[] = []; + const mockLogger: Logger = { + warn: (msg: string) => warnings.push(msg), + info: () => {}, + error: () => {}, + debug: () => {}, + trace: () => {}, + }; + const uvLock = new UvLock({ + packageName: 'virtual-workspace-member', + version: Version.parse('1.0.0'), + }); + const newContent = uvLock.updateContent(oldContent, mockLogger); + expect(newContent).to.eql(oldContent); + expect(warnings).to.deep.include( + 'virtual-workspace-member is in uv.lock but has no version field (virtual package); skipping' + ); + }); + + it('normalizes underscores in package name when matching', () => { + const oldContent = loadFixture(); + // 'my-cool-package' in the lockfile should match 'my_cool_package' + // after PEP 503 normalization (runs of [-_.] collapse to '-') + const uvLock = new UvLock({ + packageName: 'my_cool_package', + version: Version.parse('2.0.0'), + }); + const newContent = uvLock.updateContent(oldContent); + const pkg = parsedPackages(newContent).find( + p => p.name === 'my-cool-package' + ); + expect(pkg?.version).to.eql('2.0.0'); + }); + + it('normalizes dots in package name when matching', () => { + const oldContent = loadFixture(); + // 'my-cool-package' in the lockfile should match 'my.cool.package' + // after PEP 503 normalization + const uvLock = new UvLock({ + packageName: 'my.cool.package', + version: Version.parse('2.0.0'), + }); + const newContent = uvLock.updateContent(oldContent); + const pkg = parsedPackages(newContent).find( + p => p.name === 'my-cool-package' + ); + expect(pkg?.version).to.eql('2.0.0'); + }); + + it('does not update unrelated dependency entries', () => { + const oldContent = loadFixture(); + const uvLock = new UvLock({ + packageName: 'socketry', + version: Version.parse('0.3.0'), + }); + const newContent = uvLock.updateContent(oldContent); + const aiohttp = parsedPackages(newContent).find( + p => p.name === 'aiohttp' + ); + expect(aiohttp?.version).to.eql('3.13.3'); + }); + + it('returns content unchanged when uv.lock has no packages', () => { + const emptyLockfile = 'version = 1\nrequires-python = ">=3.11"\n'; + const uvLock = new UvLock({ + packageName: 'socketry', + version: Version.parse('0.3.0'), + }); + const newContent = uvLock.updateContent(emptyLockfile); + expect(newContent).to.eql(emptyLockfile); + }); + + it('returns content unchanged when target package is not found', () => { + const oldContent = loadFixture(); + const uvLock = new UvLock({ + packageName: 'nonexistent-package', + version: Version.parse('1.0.0'), + }); + const newContent = uvLock.updateContent(oldContent); + expect(newContent).to.eql(oldContent); + }); + }); +});