diff --git a/__snapshots__/cli.js b/__snapshots__/cli.js index a09c1ea58..75454f226 100644 --- a/__snapshots__/cli.js +++ b/__snapshots__/cli.js @@ -52,8 +52,9 @@ Options: [choices: "bazel", "dart", "dotnet-yoshi", "elixir", "expo", "go", "go-librarian", "go-yoshi", "helm", "java", "java-backport", "java-bom", "java-lts", "java-yoshi", "java-yoshi-mono-repo", "krm-blueprint", "maven", - "node", "node-librarian", "ocaml", "php", "php-yoshi", "python", "r", "ruby", - "ruby-yoshi", "rust", "salesforce", "sfdx", "simple", "terraform-module"] + "node", "node-librarian", "ocaml", "php", "php-yoshi", "python", + "python-librarian", "r", "ruby", "ruby-yoshi", "rust", "salesforce", "sfdx", + "simple", "terraform-module"] --config-file where can the config file be found in the project? [default: "release-please-config.json"] --manifest-file where can the manifest file be found in the @@ -279,8 +280,9 @@ Options: [choices: "bazel", "dart", "dotnet-yoshi", "elixir", "expo", "go", "go-librarian", "go-yoshi", "helm", "java", "java-backport", "java-bom", "java-lts", "java-yoshi", "java-yoshi-mono-repo", "krm-blueprint", "maven", - "node", "node-librarian", "ocaml", "php", "php-yoshi", "python", "r", "ruby", - "ruby-yoshi", "rust", "salesforce", "sfdx", "simple", "terraform-module"] + "node", "node-librarian", "ocaml", "php", "php-yoshi", "python", + "python-librarian", "r", "ruby", "ruby-yoshi", "rust", "salesforce", "sfdx", + "simple", "terraform-module"] --config-file where can the config file be found in the project? [default: "release-please-config.json"] diff --git a/__snapshots__/python-librarian.js b/__snapshots__/python-librarian.js new file mode 100644 index 000000000..c15624941 --- /dev/null +++ b/__snapshots__/python-librarian.js @@ -0,0 +1,37 @@ +exports['PythonLibrarian buildUpdates updates changelog.json if present 1'] = ` +{ + "entries": [ + { + "changes": [ + { + "type": "fix", + "sha": "845db1381b3d5d20151cad2588f85feb", + "message": "update dependency com.google.cloud:google-cloud-storage to v1.120.0", + "issues": [], + "scope": "deps" + }, + { + "type": "chore", + "sha": "b3f8966b023b8f21ce127142aa91841c", + "message": "update a very important dep", + "issues": [], + "breakingChangeNote": "update a very important dep" + }, + { + "type": "fix", + "sha": "08ca01180a91c0a1ba8992b491db9212", + "message": "update dependency com.google.cloud:google-cloud-spanner to v1.50.0", + "issues": [], + "scope": "deps" + } + ], + "version": "0.1.0", + "language": "PYTHON", + "artifactName": "google-cloud-automl", + "id": "abc-123-efd-qwerty", + "createTime": "2023-01-05T16:42:33.446Z" + } + ], + "updateTime": "2023-01-05T16:42:33.446Z" +} +` diff --git a/src/factory.ts b/src/factory.ts index bf39f3d7b..239afa582 100644 --- a/src/factory.ts +++ b/src/factory.ts @@ -38,6 +38,7 @@ import {OCaml} from './strategies/ocaml'; import {PHP} from './strategies/php'; import {PHPYoshi} from './strategies/php-yoshi'; import {Python} from './strategies/python'; +import {PythonLibrarian} from './strategies/python-librarian'; import {R} from './strategies/r'; import {Ruby} from './strategies/ruby'; import {RubyYoshi} from './strategies/ruby-yoshi'; @@ -103,6 +104,7 @@ const releasers: Record = { php: options => new PHP(options), 'php-yoshi': options => new PHPYoshi(options), python: options => new Python(options), + 'python-librarian': options => new PythonLibrarian(options), r: options => new R(options), ruby: options => new Ruby(options), 'ruby-yoshi': options => new RubyYoshi(options), diff --git a/src/strategies/python-librarian.ts b/src/strategies/python-librarian.ts new file mode 100644 index 000000000..b4e55bc34 --- /dev/null +++ b/src/strategies/python-librarian.ts @@ -0,0 +1,38 @@ +// 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 {Python} from './python'; +import {BuildUpdatesOptions} from './base'; +import {Update} from '../update'; +import {LibrarianYamlUpdater} from '../updaters/python/librarian-yaml'; + +export class PythonLibrarian extends Python { + protected async buildUpdates( + options: BuildUpdatesOptions + ): Promise { + const updates = await super.buildUpdates(options); + + // Update librarian.yaml if this package exists within it. + updates.push({ + path: 'librarian.yaml', + createIfMissing: false, + updater: new LibrarianYamlUpdater({ + version: options.newVersion, + packagePath: this.path, + }), + }); + + return updates; + } +} diff --git a/src/updaters/python/librarian-yaml.ts b/src/updaters/python/librarian-yaml.ts new file mode 100644 index 000000000..5e2ac3c24 --- /dev/null +++ b/src/updaters/python/librarian-yaml.ts @@ -0,0 +1,95 @@ +// 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 {DefaultUpdater, UpdateOptions} from '../default'; +import * as yaml from 'yaml'; +import {logger as defaultLogger, Logger} from '../../util/logger'; + +export interface LibrarianLibrary { + name: string; + output: string; + version: string; + [key: string]: any; +} + +export interface LibrarianYamlSchema { + libraries: LibrarianLibrary[]; + [key: string]: any; +} + +export interface LibrarianUpdateOptions extends UpdateOptions { + packagePath: string; +} + +/** + * Updates a librarian.yaml file. + */ +export class LibrarianYamlUpdater extends DefaultUpdater { + private readonly packagePath: string; + + constructor(options: LibrarianUpdateOptions) { + super(options); + this.packagePath = options.packagePath; + } + + /** + * 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 { + // Use yaml package to make sure librarian.yaml is not reformatted because + // we use different tool to format librarian.yaml. + const doc = yaml.parseDocument(content); + if (!doc || doc.errors.length > 0) { + logger.warn('Invalid yaml, cannot be parsed'); + return content; + } + + const libraries = doc.get('libraries'); + if (!libraries || !yaml.isSeq(libraries)) { + return content; + } + + let modified = false; + for (const library of libraries.items) { + if (!yaml.isMap(library)) continue; + + // The release-please version map key is the output directory (explicit or + // derived) in librarian.yaml. + const outputDirectory = this.deriveOutputDirectory( + library.toJSON() as LibrarianLibrary + ); + if (outputDirectory === this.packagePath) { + const newVersion = this.version.toString(); + if (library.get('version') !== newVersion) { + library.set('version', newVersion); + modified = true; + } + } + } + + if (modified) { + return doc.toString({lineWidth: 0}); + } + return content; + } + + deriveOutputDirectory(library: LibrarianLibrary): string { + if (library.output) { + return library.output; + } + return `packages/${library.name}`; + } +} diff --git a/test/strategies/python-librarian.ts b/test/strategies/python-librarian.ts new file mode 100644 index 000000000..a39f595cb --- /dev/null +++ b/test/strategies/python-librarian.ts @@ -0,0 +1,127 @@ +// 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 {describe, it, afterEach, beforeEach} from 'mocha'; +import {expect} from 'chai'; +import {GitHub} from '../../src/github'; +import {PythonLibrarian} from '../../src/strategies/python-librarian'; +import * as sinon from 'sinon'; +import {buildGitHubFileContent, assertHasUpdate} from '../helpers'; +import {buildMockConventionalCommit} from '../helpers'; +import {SetupPy} from '../../src/updaters/python/setup-py'; +import {LibrarianYamlUpdater} from '../../src/updaters/python/librarian-yaml'; +import {Version} from '../../src/version'; +import {TagName} from '../../src/util/tag-name'; + +const sandbox = sinon.createSandbox(); +const fixturesPath = './test/fixtures/strategies/python'; +const COMMITS = [ + ...buildMockConventionalCommit( + 'fix(deps): update dependency com.google.cloud:google-cloud-storage to v1.120.0' + ), +]; + +describe('PythonLibrarian', () => { + let github: GitHub; + beforeEach(async () => { + github = await GitHub.create({ + owner: 'googleapis', + repo: 'python-librarian-test-repo', + defaultBranch: 'main', + }); + }); + afterEach(() => { + sandbox.restore(); + }); + + describe('buildReleasePullRequest', () => { + it('returns release PR changes with defaultInitialVersion', async () => { + const expectedVersion = '0.1.0'; + const strategy = new PythonLibrarian({ + targetBranch: 'main', + github, + component: 'google-cloud-automl', + }); + sandbox + .stub(github, 'getFileContentsOnBranch') + .resolves(buildGitHubFileContent(fixturesPath, 'setup.py')); + sandbox.stub(github, 'findFilesByFilenameAndRef').resolves([]); + const latestRelease = undefined; + const release = await strategy.buildReleasePullRequest( + COMMITS, + latestRelease + ); + expect(release!.version?.toString()).to.eql(expectedVersion); + }); + + it('returns release PR changes with semver patch bump', async () => { + const expectedVersion = '0.123.5'; + const strategy = new PythonLibrarian({ + targetBranch: 'main', + github, + component: 'google-cloud-automl', + }); + sandbox + .stub(github, 'getFileContentsOnBranch') + .resolves(buildGitHubFileContent(fixturesPath, 'setup.py')); + sandbox.stub(github, 'findFilesByFilenameAndRef').resolves([]); + const latestRelease = { + tag: new TagName(Version.parse('0.123.4'), 'google-cloud-automl'), + sha: 'abc123', + notes: 'some notes', + }; + const release = await strategy.buildReleasePullRequest( + COMMITS, + latestRelease + ); + expect(release!.version?.toString()).to.eql(expectedVersion); + }); + }); + + describe('buildUpdates', () => { + it('builds common files and appends librarian.yaml correctly', async () => { + const strategy = new PythonLibrarian({ + targetBranch: 'main', + github, + component: 'google-cloud-automl', + }); + sandbox + .stub(github, 'getFileContentsOnBranch') + .resolves(buildGitHubFileContent(fixturesPath, 'setup.py')); + sandbox.stub(github, 'findFilesByFilenameAndRef').resolves([]); + const latestRelease = undefined; + const release = await strategy.buildReleasePullRequest( + COMMITS, + latestRelease + ); + const updates = release!.updates; + + // Verify that standard python updates are generated (inherited from Python strategy) + assertHasUpdate(updates, 'setup.py', SetupPy); + + // Verify that librarian.yaml is correctly registered as an update + const update = assertHasUpdate( + updates, + 'librarian.yaml', + LibrarianYamlUpdater + ); + expect(update.createIfMissing).to.be.false; + + // Verify updater is correctly configured + const updater = update.updater as LibrarianYamlUpdater; + expect(updater.version?.toString()).to.eql('0.1.0'); + expect((updater as any).packagePath).to.eql('.'); + }); + }); +}); diff --git a/test/updaters/python-librarian-yaml.ts b/test/updaters/python-librarian-yaml.ts new file mode 100644 index 000000000..77fcab93c --- /dev/null +++ b/test/updaters/python-librarian-yaml.ts @@ -0,0 +1,122 @@ +// 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 {describe, it} from 'mocha'; +import {expect} from 'chai'; +import {LibrarianYamlUpdater} from '../../src/updaters/python/librarian-yaml'; +import {Version} from '../../src/version'; + +const oldContent = `language: python +sources: + googleapis: + commit: cd090841ab172574e740c214c99df00aef9c0dee + sha256: 08e4b7744dc23b6e3320a3f1d05db9f40853aaf1089d06bfb8d79044b7a66f21 +default: + output: packages +libraries: + - name: google-cloud-storage + version: 1.42.0 + apis: + - path: google/storage/v2 + copyright_year: "2026" + - name: google-cloud-pubsub + version: 2.9.0 + apis: + - path: google/pubsub/v1 + copyright_year: "2026" + keep: + - .gitignore + - name: handwritten-lib + output: handwritten/handwritten-lib + version: 0.1.0 +`; + +const updatedStorageContent = `language: python +sources: + googleapis: + commit: cd090841ab172574e740c214c99df00aef9c0dee + sha256: 08e4b7744dc23b6e3320a3f1d05db9f40853aaf1089d06bfb8d79044b7a66f21 +default: + output: packages +libraries: + - name: google-cloud-storage + version: 1.43.0 + apis: + - path: google/storage/v2 + copyright_year: "2026" + - name: google-cloud-pubsub + version: 2.9.0 + apis: + - path: google/pubsub/v1 + copyright_year: "2026" + keep: + - .gitignore + - name: handwritten-lib + output: handwritten/handwritten-lib + version: 0.1.0 +`; + +const updatedHandwrittenContent = `language: python +sources: + googleapis: + commit: cd090841ab172574e740c214c99df00aef9c0dee + sha256: 08e4b7744dc23b6e3320a3f1d05db9f40853aaf1089d06bfb8d79044b7a66f21 +default: + output: packages +libraries: + - name: google-cloud-storage + version: 1.42.0 + apis: + - path: google/storage/v2 + copyright_year: "2026" + - name: google-cloud-pubsub + version: 2.9.0 + apis: + - path: google/pubsub/v1 + copyright_year: "2026" + keep: + - .gitignore + - name: handwritten-lib + output: handwritten/handwritten-lib + version: 0.2.0 +`; + +describe('PythonLibrarianYamlUpdater', () => { + it('updates librarian.yaml with implicit output directory', () => { + const updater = new LibrarianYamlUpdater({ + version: Version.parse('1.43.0'), + packagePath: 'packages/google-cloud-storage', + }); + const newContent = updater.updateContent(oldContent); + expect(newContent).to.eq(updatedStorageContent); + }); + + it('updates librarian.yaml with explicit output directory', () => { + const updater = new LibrarianYamlUpdater({ + version: Version.parse('0.2.0'), + packagePath: 'handwritten/handwritten-lib', + }); + const newContent = updater.updateContent(oldContent); + expect(newContent).to.eq(updatedHandwrittenContent); + }); + + it('returns original content if no libraries match the path', () => { + const updater = new LibrarianYamlUpdater({ + version: Version.parse('1.0.0'), + packagePath: 'packages/non-existent', + }); + const newContent = updater.updateContent(oldContent); + expect(newContent).to.equal(oldContent); + }); +});