From 80a718f48e242bd4594500927fb9a115d80b4c74 Mon Sep 17 00:00:00 2001 From: Jesus Lopez Date: Mon, 2 Mar 2026 10:05:18 -0800 Subject: [PATCH 1/4] feat: support uv.lock files in the Python strategy Automatically update the project's entry in uv.lock when releasing a Python package, without requiring extra-files configuration. Closes #2561 --- __snapshots__/uv-lock.js | 54 ++++++++++++++++++ src/strategies/python.ts | 7 +++ src/updaters/python/uv-lock.ts | 100 +++++++++++++++++++++++++++++++++ test/strategies/python.ts | 2 + test/updaters/fixtures/uv.lock | 51 +++++++++++++++++ test/updaters/uv-lock.ts | 90 +++++++++++++++++++++++++++++ 6 files changed, 304 insertions(+) create mode 100644 __snapshots__/uv-lock.js create mode 100644 src/updaters/python/uv-lock.ts create mode 100644 test/updaters/fixtures/uv.lock create mode 100644 test/updaters/uv-lock.ts diff --git a/__snapshots__/uv-lock.js b/__snapshots__/uv-lock.js new file mode 100644 index 000000000..3696307be --- /dev/null +++ b/__snapshots__/uv-lock.js @@ -0,0 +1,54 @@ +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 = "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..9fbb9ad25 --- /dev/null +++ b/src/updaters/python/uv-lock.ts @@ -0,0 +1,100 @@ +// Copyright 2025 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 508: 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. + */ +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) { + 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. + for (let i = 0; i < parsed.package.length; i++) { + const pkg = parsed.package[i]; + if (!pkg.name || !pkg.version) { + // Virtual packages (workspace members without a version) have no + // `version` field; skip them silently. + continue; + } + + if (normalizePackageName(pkg.name) !== normalizedTarget) { + 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(); + + logger.info(`updating ${pkg.name} in uv.lock`); + payload = replaceTomlValue( + payload, + ['package', packageIndex, 'version'], + this.version.toString() + ); + } + + return payload; + } +} diff --git a/test/strategies/python.ts b/test/strategies/python.ts index 1a95cc0f1..812b27e2d 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'; @@ -208,6 +209,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..daeb18f35 --- /dev/null +++ b/test/updaters/fixtures/uv.lock @@ -0,0 +1,51 @@ +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 = "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..9e69af4e6 --- /dev/null +++ b/test/updaters/uv-lock.ts @@ -0,0 +1,90 @@ +// Copyright 2025 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'; + +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('silently skips packages without a version field', () => { + const oldContent = loadFixture(); + // 'virtual-workspace-member' has no version field in the fixture + const uvLock = new UvLock({ + packageName: 'virtual-workspace-member', + version: Version.parse('1.0.0'), + }); + const newContent = uvLock.updateContent(oldContent); + expect(newContent).to.eql(oldContent); + }); + + 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'); + }); + }); +}); From 297d4f3f1a48b808d5a0cc09ff9d48ee48283cfe Mon Sep 17 00:00:00 2001 From: Jesus Lopez Date: Mon, 2 Mar 2026 10:33:08 -0800 Subject: [PATCH 2/4] fix: address code review issues in uv.lock updater - Warn when target package is not found in uv.lock (silent no-op bug) - Add comment explaining warn-not-throw vs CargoLock for empty lockfiles - Add comment explaining double-parse is inherent to replaceTomlValue API - Add test for uv.lock with no [[package]] entries (100% coverage) - Add test for target package not found in lockfile - Add tests for PEP 503 underscore/dot normalization --- __snapshots__/uv-lock.js | 5 ++++ src/updaters/python/uv-lock.ts | 14 ++++++++++ test/updaters/fixtures/uv.lock | 5 ++++ test/updaters/uv-lock.ts | 50 ++++++++++++++++++++++++++++++++++ 4 files changed, 74 insertions(+) diff --git a/__snapshots__/uv-lock.js b/__snapshots__/uv-lock.js index 3696307be..3cf44a2ba 100644 --- a/__snapshots__/uv-lock.js +++ b/__snapshots__/uv-lock.js @@ -24,6 +24,11 @@ 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 = "." } diff --git a/src/updaters/python/uv-lock.ts b/src/updaters/python/uv-lock.ts index 9fbb9ad25..1a9ea61b4 100644 --- a/src/updaters/python/uv-lock.ts +++ b/src/updaters/python/uv-lock.ts @@ -42,6 +42,11 @@ function normalizePackageName(name: string): string { /** * 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; @@ -62,6 +67,9 @@ export class UvLock implements Updater { 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; } @@ -70,6 +78,7 @@ export class UvLock implements Updater { // n.b for `replaceTomlValue`, we need to keep track of the index // (position) of the package we're considering. + let found = false; for (let i = 0; i < parsed.package.length; i++) { const pkg = parsed.package[i]; if (!pkg.name || !pkg.version) { @@ -87,6 +96,7 @@ export class UvLock implements Updater { // `path` argument. const packageIndex = i.toString(); + found = true; logger.info(`updating ${pkg.name} in uv.lock`); payload = replaceTomlValue( payload, @@ -95,6 +105,10 @@ export class UvLock implements Updater { ); } + if (!found) { + logger.warn(`package ${this.packageName} not found in uv.lock`); + } + return payload; } } diff --git a/test/updaters/fixtures/uv.lock b/test/updaters/fixtures/uv.lock index daeb18f35..5b6f26daf 100644 --- a/test/updaters/fixtures/uv.lock +++ b/test/updaters/fixtures/uv.lock @@ -23,6 +23,11 @@ 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 = "." } diff --git a/test/updaters/uv-lock.ts b/test/updaters/uv-lock.ts index 9e69af4e6..d1be29dd9 100644 --- a/test/updaters/uv-lock.ts +++ b/test/updaters/uv-lock.ts @@ -74,6 +74,36 @@ describe('UvLock', () => { expect(newContent).to.eql(oldContent); }); + 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({ @@ -86,5 +116,25 @@ describe('UvLock', () => { ); 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); + }); }); }); From d2d28263e48b37fcafb9d34eaaa632503d9b4c4b Mon Sep 17 00:00:00 2001 From: Jesus Lopez Date: Mon, 2 Mar 2026 11:02:45 -0800 Subject: [PATCH 3/4] fix: improve uv.lock virtual package handling and test coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Distinguish virtual packages (present but version-less) from absent packages: emit a dedicated warning instead of the misleading "not found" message (fixes false debugging trail in uv mono-repos) - Rename test 'silently skips packages without a version field' to accurately reflect that a warning is now emitted; add logger spy to assert the warning text - Assert uv.lock is included in updates for 'builds common files' and 'omits changelog if skipChangelog=true' strategy tests, catching any regression that moves the uv.lock push inside the pyproject.toml branch - Correct PEP 508 → PEP 503 in normalizePackageName docstring (PEP 503 is the authoritative spec for canonical name normalisation) - Document that replaceTomlValue may throw on a malformed lockfile --- src/updaters/python/uv-lock.ts | 32 ++++++++++++++++++++++++-------- test/strategies/python.ts | 2 ++ test/updaters/uv-lock.ts | 16 ++++++++++++++-- 3 files changed, 40 insertions(+), 10 deletions(-) diff --git a/src/updaters/python/uv-lock.ts b/src/updaters/python/uv-lock.ts index 1a9ea61b4..c2d4e6f43 100644 --- a/src/updaters/python/uv-lock.ts +++ b/src/updaters/python/uv-lock.ts @@ -33,7 +33,7 @@ function parseUvLockfile(content: string): UvLockfile { } /** - * Normalizes a Python package name per PEP 508: lowercased, with runs of + * Normalizes a Python package name per PEP 503: lowercased, with runs of * `-`, `_`, or `.` collapsed to a single `-`. */ function normalizePackageName(name: string): string { @@ -78,12 +78,11 @@ export class UvLock implements Updater { // n.b for `replaceTomlValue`, we need to keep track of the index // (position) of the package we're considering. - let found = false; + let foundVersioned = false; + let foundVersionless = false; for (let i = 0; i < parsed.package.length; i++) { const pkg = parsed.package[i]; - if (!pkg.name || !pkg.version) { - // Virtual packages (workspace members without a version) have no - // `version` field; skip them silently. + if (!pkg.name) { continue; } @@ -91,13 +90,24 @@ export class UvLock implements Updater { 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(); - found = true; + 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'], @@ -105,8 +115,14 @@ export class UvLock implements Updater { ); } - if (!found) { - logger.warn(`package ${this.packageName} not found in uv.lock`); + 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 812b27e2d..90e86cefa 100644 --- a/test/strategies/python.ts +++ b/test/strategies/python.ts @@ -126,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', @@ -168,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', diff --git a/test/updaters/uv-lock.ts b/test/updaters/uv-lock.ts index d1be29dd9..a187f4de3 100644 --- a/test/updaters/uv-lock.ts +++ b/test/updaters/uv-lock.ts @@ -20,6 +20,7 @@ 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'; @@ -63,15 +64,26 @@ describe('UvLock', () => { expect(pkg?.version).to.eql('0.3.0'); }); - it('silently skips packages without a version field', () => { + 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); + 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', () => { From 9b22cfb3fdb8edb6c877cd1beaf65c20adefe567 Mon Sep 17 00:00:00 2001 From: Jesus Lopez Date: Mon, 2 Mar 2026 11:04:19 -0800 Subject: [PATCH 4/4] fix: copyright years --- src/updaters/python/uv-lock.ts | 2 +- test/updaters/uv-lock.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/updaters/python/uv-lock.ts b/src/updaters/python/uv-lock.ts index c2d4e6f43..850a4a83e 100644 --- a/src/updaters/python/uv-lock.ts +++ b/src/updaters/python/uv-lock.ts @@ -1,4 +1,4 @@ -// Copyright 2025 Google LLC +// 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. diff --git a/test/updaters/uv-lock.ts b/test/updaters/uv-lock.ts index a187f4de3..64277656f 100644 --- a/test/updaters/uv-lock.ts +++ b/test/updaters/uv-lock.ts @@ -1,4 +1,4 @@ -// Copyright 2025 Google LLC +// 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.