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.