From d79f345b49d085d7f35dbb0b510a5572667f017a Mon Sep 17 00:00:00 2001 From: Dmitry Dobrynin Date: Fri, 2 May 2025 16:29:00 +0200 Subject: [PATCH 1/5] =?UTF-8?q?=E2=9C=A8=20(presets):=20Add=20support=20fo?= =?UTF-8?q?r=20retrieving=20dynamic=20versions=20of=20Python=20packages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 +- packages/gitmoji-changelog-cli/.nvmrc | 1 + packages/gitmoji-changelog-cli/package.json | 3 +- packages/gitmoji-changelog-cli/src/cli.js | 4 +- packages/gitmoji-changelog-cli/src/index.js | 1 + .../src/presets/python.js | 102 +++++++++++------- yarn.lock | 5 + 7 files changed, 78 insertions(+), 41 deletions(-) create mode 100644 packages/gitmoji-changelog-cli/.nvmrc diff --git a/package.json b/package.json index 4cc4e8e..edb9e6a 100644 --- a/package.json +++ b/package.json @@ -28,5 +28,6 @@ }, "workspaces": [ "packages/*" - ] + ], + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/packages/gitmoji-changelog-cli/.nvmrc b/packages/gitmoji-changelog-cli/.nvmrc new file mode 100644 index 0000000..b009dfb --- /dev/null +++ b/packages/gitmoji-changelog-cli/.nvmrc @@ -0,0 +1 @@ +lts/* diff --git a/packages/gitmoji-changelog-cli/package.json b/packages/gitmoji-changelog-cli/package.json index e1daa5d..5cb2809 100644 --- a/packages/gitmoji-changelog-cli/package.json +++ b/packages/gitmoji-changelog-cli/package.json @@ -15,7 +15,7 @@ "description": "Gitmoji Changelog CLI", "main": "src/index.js", "engines": { - "node": ">=10" + "node": ">=18" }, "bin": { "gitmoji-changelog": "./src/index.js" @@ -45,6 +45,7 @@ "semver": "^5.6.0", "semver-compare": "^1.0.0", "simple-git": "^1.113.0", + "smol-toml": "^1.3.4", "toml": "^3.0.0", "yaml": "^1.10.2", "yargs": "^17.3.1" diff --git a/packages/gitmoji-changelog-cli/src/cli.js b/packages/gitmoji-changelog-cli/src/cli.js index 03936bd..9a5bbe5 100644 --- a/packages/gitmoji-changelog-cli/src/cli.js +++ b/packages/gitmoji-changelog-cli/src/cli.js @@ -52,7 +52,9 @@ async function main(options = {}) { // eslint-disable-next-line global-require const loadProjectInfo = require(`./presets/${options.preset}.js`) - projectInfo = await loadProjectInfo() + projectInfo = await loadProjectInfo({ + versionCommand: options.versionCommand, + }) if (!projectInfo) { throw Error(`Cannot retrieve configuration for preset ${options.preset}.`) diff --git a/packages/gitmoji-changelog-cli/src/index.js b/packages/gitmoji-changelog-cli/src/index.js index a51b5d8..7a810a6 100755 --- a/packages/gitmoji-changelog-cli/src/index.js +++ b/packages/gitmoji-changelog-cli/src/index.js @@ -52,6 +52,7 @@ yargs .option('group-similar-commits', { desc: '[⚗️ - beta] try to group similar commits', default: false }) .option('author', { default: false, desc: 'add the author in changelog lines' }) .option('interactive', { default: false, desc: 'select commits manually', alias: 'i' }) + .option('version-command', { desc: 'Command used to determine the package version (for Python packages with dynamic versioning)' }) .help('help') .epilog(`For more information visit: ${homepage}`) diff --git a/packages/gitmoji-changelog-cli/src/presets/python.js b/packages/gitmoji-changelog-cli/src/presets/python.js index 7aed44c..53b06db 100644 --- a/packages/gitmoji-changelog-cli/src/presets/python.js +++ b/packages/gitmoji-changelog-cli/src/presets/python.js @@ -1,29 +1,46 @@ -const toml = require('toml') const fs = require('fs') +const toml = require('smol-toml') +const ChildProcess = require('child_process') -module.exports = async () => { - try { - const pyprojectPromise = new Promise((resolve, reject) => { - try { - resolve(toml.parse(fs.readFileSync('pyproject.toml', 'utf-8'))) - } catch (err) { - reject(err) - } - }) - const projectFile = await pyprojectPromise - const name = recursiveKeySearch('name', projectFile)[0] - const version = recursiveKeySearch('version', projectFile)[0] - let description = recursiveKeySearch('description', projectFile)[0] +class DynamicVersionError extends Error { } + +module.exports = async (options = {}) => { + try { + const pyproject = toml.parse(fs.readFileSync('pyproject.toml', 'utf-8')) + const meta = pyproject.project || (pyproject.tool && pyproject.tool.poetry) + const dynamicFields = meta.dynamic || [] + + const name = meta.name if (!name) { throw new Error('Could not find name metadata in pyproject.toml') } + + let version = meta.version + const isDynamicVersion = dynamicFields.includes('version') + if (isDynamicVersion) { + if (options.versionCommand) { + version = getDynamicVersion(options.versionCommand) + } else { + throw new DynamicVersionError( + 'Dynamic version detected. Please supply a command to obtain it, e.g.: \'gitmoji-changelog --preset python --version-command "python setup.py --version\'"' + ) + } + } if (!version) { - throw new Error('Could not find version metadata in pyproject.toml') + throw new Error('Could not find version metadata (static or dynamic)') } - if (!description) { - description = '' + + let description = meta.description || '' + const isDynamicDescription = dynamicFields.includes('description') + if (isDynamicDescription) { + let readme = meta.readme + if (typeof readme === 'object') { + readme = readme.file + } + + description = getDescriptionFromReadme(readme) || '' } return { @@ -32,37 +49,46 @@ module.exports = async () => { description, } } catch (e) { + if (e instanceof DynamicVersionError) { + throw e + } + return null } } -function recursiveKeySearch(key, data) { - // https://codereview.stackexchange.com/a/143914 - if (data === null) { - return [] - } - - if (data !== Object(data)) { - return [] - } +function getDynamicVersion(command) { + try { + return ChildProcess.execSync(command, { encoding: 'utf-8' }).trim() + } catch (e) { + const { status, signal, stderr } = e + const exitCode = (status !== null && status !== undefined) ? status : 'unknown' - let results = [] + const details = [ + 'Failed to retrieve package version with external command', + ` cmd : ${command}`, + ` exit code: ${exitCode}${signal ? ` (signal: ${signal})` : ''}`, + ] - if (data.constructor === Array) { - for (let i = 0, len = data.length; i < len; i += 1) { - results = results.concat(recursiveKeySearch(key, data[i])) + if (stderr && String(stderr).trim()) { + details.push(` stderr : ${String(stderr).trim()}`) } - return results + + throw new DynamicVersionError(details.join('\n'), { cause: e }) } +} - for (let i = 0; i < Object.keys(data).length; i += 1) { - const dataKey = Object.keys(data)[i] - if (key === dataKey) { - results.push(data[key]) - } - results = results.concat(recursiveKeySearch(key, data[dataKey])) + +function getDescriptionFromReadme(readmePath = 'README.md') { + if (!fs.existsSync(readmePath)) { + return '' } - return results + const content = fs.readFileSync(readmePath, 'utf-8').trim() + + const paragraphs = content.split(/\r?\n\r?\n/) + const first = paragraphs.find(p => p.trim().length > 0) + + return first ? first.replace(/^#\s*/, '').trim() : null } diff --git a/yarn.lock b/yarn.lock index e11df11..bb54369 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8082,6 +8082,11 @@ smart-buffer@^4.1.0: resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae" integrity sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg== +smol-toml@^1.3.4: + version "1.3.4" + resolved "https://registry.yarnpkg.com/smol-toml/-/smol-toml-1.3.4.tgz#4ec76e0e709f586bc50ba30eb79024173c2b2221" + integrity sha512-UOPtVuYkzYGee0Bd2Szz8d2G3RfMfJ2t3qVdZUAozZyAk+a0Sxa+QKix0YCwjL/A1RR0ar44nCxaoN9FxdJGwA== + snapdragon-node@^2.0.1: version "2.1.1" resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b" From 7692d6189b37d9e81ca586c5952da55221feddf3 Mon Sep 17 00:00:00 2001 From: Dmitry Dobrynin Date: Sun, 4 May 2025 03:00:32 +0200 Subject: [PATCH 2/5] =?UTF-8?q?=F0=9F=A7=AA=20(presets):=20Add=20tests=20f?= =?UTF-8?q?or=20dynamic=20version=20and=20description=20in=20the=20python?= =?UTF-8?q?=20preset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/presets/python.spec.js | 201 +++++++++++++++++- 1 file changed, 200 insertions(+), 1 deletion(-) diff --git a/packages/gitmoji-changelog-cli/src/presets/python.spec.js b/packages/gitmoji-changelog-cli/src/presets/python.spec.js index 797aa17..ac65542 100644 --- a/packages/gitmoji-changelog-cli/src/presets/python.spec.js +++ b/packages/gitmoji-changelog-cli/src/presets/python.spec.js @@ -1,8 +1,15 @@ const fs = require('fs') +const ChildProcess = require('child_process') const loadProjectInfo = require('./python.js') -describe('getPackageInfo', () => { +describe('getPackageInfo | python', () => { + afterEach(() => { + jest.restoreAllMocks() + fs.readFileSync.mockReset() + fs.readFileSync.mockClear() + }) + it('should extract metadata from a pyproject.toml made by poetry', async () =>{ // Note the TOML section is distinct for poetry fs.readFileSync.mockReturnValue(` @@ -133,5 +140,197 @@ describe('getPackageInfo', () => { }) }) +describe('getPackageInfo | python | dynamic version', () => { + afterEach(() => { + jest.restoreAllMocks() + fs.readFileSync.mockReset() + fs.readFileSync.mockClear() + }) + + it('should extract version using the provided command', async () => { + fs.readFileSync.mockReturnValue(` + [project] + name = "dynamic_version" + description = "Description of the project with dynamic version" + dynamic = ["version"] + `) + jest.spyOn(ChildProcess, 'execSync').mockReturnValue('0.0.1\n') + + const result = await loadProjectInfo({ versionCommand: 'get_project_version' }) + + expect(result.version).toEqual('0.0.1') + }) + + it('should throw error when the external command fails', async () => { + fs.readFileSync.mockReturnValue(` + [project] + name = "fail_dynamic_version_command" + description = "Description of the project with dynamic version and failing command" + dynamic = ["version"] + `) + jest.spyOn(ChildProcess, 'execSync').mockImplementation(() => { + throw new Error('Command failed') + }) + + const result = loadProjectInfo({ versionCommand: 'invalid_command' }) + + await expect(result).rejects.toThrow(`Failed to retrieve package version with external command + cmd : invalid_command + exit code: unknown`) + }) + + it('should returns undefined version when the command outputs an empty string', async () => { + fs.readFileSync.mockReturnValue(` + [project] + name = "dynamic_version_command_return_empty" + description = "Description of the project with dynamic version and command returns empty" + dynamic = ["version"] + `) + jest.spyOn(ChildProcess, 'execSync').mockReturnValue('\n') + + const result = loadProjectInfo({ versionCommand: 'get_emptry_string' }) + + await expect(result.version).toEqual(undefined) + }) + + it('should throws error when no command is supplied for a dynamic version', async () => { + fs.readFileSync.mockReturnValue(` + [project] + name = "dynamic_version_missing_command" + description = "Description of the project with dynamic version but no command" + dynamic = ["version"] + `) + + await expect(loadProjectInfo()).rejects.toThrow( + 'Dynamic version detected. Please supply a command to obtain it, e.g.: \'gitmoji-changelog --preset python --version-command "python setup.py --version\'"' + ) + }) + + it('should does not execute an external command when the version is static', async () => { + fs.readFileSync.mockReturnValue(` + [project] + name = "static_version_project" + description = "Description of the project with static version" + version = "0.0.4" + `) + const execSpy = jest.spyOn(ChildProcess, 'execSync') + + const result = await loadProjectInfo({ versionCommand: 'get_project_version' }) + + expect(result.version).toEqual('0.0.4') + expect(execSpy).not.toHaveBeenCalled() + }) +}) + +describe('getPackageInfo | python | dynamic description', () => { + afterEach(() => { + jest.restoreAllMocks() + fs.readFileSync.mockReset() + fs.readFileSync.mockClear() + }) + + it('should extract description from README.md (string path)', async () => { + fs.readFileSync.mockImplementation((filePath) => { + if (filePath === 'pyproject.toml') { + return ` + [project] + name = "my-lib" + version = "1.0.0" + dynamic = ["description"] + readme = "README.md" + ` + } + + if (filePath === 'README.md') { + return ` +# My Awesome Library + +This is a longer description. + ` + } + + return '' + }) + fs.existsSync.mockReturnValue(true) + + const result = await loadProjectInfo() + expect(result.description).toBe('My Awesome Library') + }) + + it('should extract description from readme.file object path', async () => { + fs.readFileSync.mockImplementation((filePath) => { + if (filePath === 'pyproject.toml') { + return ` + [project] + name = "obj-readme" + version = "1.0.0" + dynamic = ["description"] + + [project.readme] + file = "README.rst" + ` + } + + if (filePath === 'README.rst') { + return ` +# Title from rst + +More details follow... + ` + } + + return '' + }) + fs.existsSync.mockReturnValue(true) + + const result = await loadProjectInfo() + expect(result.description).toBe('Title from rst') + }) + + it('should return empty description if README is empty', async () => { + fs.readFileSync.mockImplementation((filePath) => { + if (filePath === 'pyproject.toml') { + return ` + [project] + name = "empty-readme" + version = "1.0.0" + dynamic = ["description"] + readme = "README.md" + ` + } + + if (filePath === 'README.md') { + return '' + } + + return '' + }) + fs.existsSync.mockReturnValue(true) + + const result = await loadProjectInfo() + expect(result.description).toBe('') + }) + + it('should fallback to empty description if README file not found', async () => { + fs.readFileSync.mockImplementation((filePath) => { + if (filePath === 'pyproject.toml') { + return ` + [project] + name = "no-readme" + version = "1.0.0" + dynamic = ["description"] + readme = "README.md" + ` + } + + return '' + }) + fs.existsSync.mockReturnValue(false) + + const result = await loadProjectInfo() + expect(result.description).toBe('') + }) +}) + jest.mock('fs') From 783731669ac0dd34d5d2e931331129e52527b070 Mon Sep 17 00:00:00 2001 From: Dmitry Dobrynin Date: Sun, 4 May 2025 03:03:10 +0200 Subject: [PATCH 3/5] =?UTF-8?q?=F0=9F=94=A7=20Upgrade=20Node=20versions=20?= =?UTF-8?q?to=20>=3D18=20and=20LTS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/canary-release.yml | 2 +- .github/workflows/test-and-lint.yml | 2 +- Dockerfile | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/canary-release.yml b/.github/workflows/canary-release.yml index e30e8c3..ccd6ca6 100644 --- a/.github/workflows/canary-release.yml +++ b/.github/workflows/canary-release.yml @@ -11,7 +11,7 @@ jobs: strategy: matrix: - node-version: [12] + node-version: [18] steps: - name: Checkout project diff --git a/.github/workflows/test-and-lint.yml b/.github/workflows/test-and-lint.yml index 782904e..abed234 100644 --- a/.github/workflows/test-and-lint.yml +++ b/.github/workflows/test-and-lint.yml @@ -12,7 +12,7 @@ jobs: strategy: matrix: - node-version: [12] + node-version: [18, 20, 22] steps: - name: Checkout project diff --git a/Dockerfile b/Dockerfile index 3c88070..87602f4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:12.14.1-alpine3.11 +FROM node:lts-alpine3.20 ENV NODE_ENV=production # install dependencies From e16f4f6ce3df1aea941272022f46a720b4782a81 Mon Sep 17 00:00:00 2001 From: Dmitriy Dobrynin Date: Sun, 10 Aug 2025 23:53:53 +0200 Subject: [PATCH 4/5] =?UTF-8?q?=F0=9F=93=9D=20Add=20description=20of=20--v?= =?UTF-8?q?ersion-command=20option=20to=20Python=20section?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/gitmoji-changelog-documentation/README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/gitmoji-changelog-documentation/README.md b/packages/gitmoji-changelog-documentation/README.md index 39f035e..53e4cc9 100644 --- a/packages/gitmoji-changelog-documentation/README.md +++ b/packages/gitmoji-changelog-documentation/README.md @@ -62,6 +62,7 @@ The first command listed above is the idiomatic usage of `gitmoji-changelog` (re | --group-similar-commits | [⚗️,- beta] try to group similar commits | false | | --author | add the author in changelog lines | false | | --interactive -i | select commits manually | false | +| --version-command | command to retrieve the version for Python packages with dynamic versioning | | | --help | display help | | ### Example @@ -107,6 +108,7 @@ _This workflow is related to the `node` preset but can be adapted to your own te - maven - cargo - helm +- python Didn't see the preset you need in the list? Consider adding it. Presets are stored in a [presets](https://github.com/frinyvonnick/gitmoji-changelog/blob/master/packages/gitmoji-changelog-cli/src/presets) folder in the `cli` package. @@ -175,6 +177,12 @@ The python preset looks for 3 properties in your `pyproject.toml`: (The value taken is the first one found in your `pyproject.toml` that matches the expected key name given above.) +If your project uses dynamic versioning (i.e., `dynamic = ["version"]` is set in `pyproject.toml`), you can use the `--version-command` option to provide a command that returns the version, for example: + +```sh +gitmoji-changelog --preset python --version-command "hatch version" +``` + ### Add a preset A preset need to export a function. When called this function must return three mandatory information about the project in which the cli has been called. The name of the project, a short description of it and its current version. From 9b2041ddb2534b2994c9df2a6b90f7724115036a Mon Sep 17 00:00:00 2001 From: Dmitriy Dobrynin Date: Mon, 11 Aug 2025 00:18:56 +0200 Subject: [PATCH 5/5] =?UTF-8?q?=F0=9F=93=9D=20Added=20documentation=20for?= =?UTF-8?q?=20retrieving=20Python=20project=20description?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/gitmoji-changelog-documentation/README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/gitmoji-changelog-documentation/README.md b/packages/gitmoji-changelog-documentation/README.md index 53e4cc9..d6f32d1 100644 --- a/packages/gitmoji-changelog-documentation/README.md +++ b/packages/gitmoji-changelog-documentation/README.md @@ -183,6 +183,9 @@ If your project uses dynamic versioning (i.e., `dynamic = ["version"]` is set in gitmoji-changelog --preset python --version-command "hatch version" ``` +If your project uses a dynamic description (i.e., `dynamic = ["description"]` is set), the parser will take the first line from your README file as the description. + + ### Add a preset A preset need to export a function. When called this function must return three mandatory information about the project in which the cli has been called. The name of the project, a short description of it and its current version.