diff --git a/.github/workflows/canary-release.yml b/.github/workflows/canary-release.yml index e30e8c3..3154ffa 100644 --- a/.github/workflows/canary-release.yml +++ b/.github/workflows/canary-release.yml @@ -11,16 +11,18 @@ jobs: strategy: matrix: - node-version: [12] + node-version: [18] steps: - name: Checkout project uses: actions/checkout@v1 + - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v1 with: node-version: ${{ matrix.node-version }} registry-url: 'https://registry.npmjs.org' + - name: Install dependencies run: yarn install --frozen-lockfile - name: Publish Canary version 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/.nvmrc b/.nvmrc new file mode 100644 index 0000000..b009dfb --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +lts/* 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 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/package.json b/packages/gitmoji-changelog-cli/package.json index e1daa5d..ca78919 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.1", "toml": "^3.0.0", "yaml": "^1.10.2", "yargs": "^17.3.1" diff --git a/packages/gitmoji-changelog-cli/src/presets/python.js b/packages/gitmoji-changelog-cli/src/presets/python.js index 7aed44c..fe5d353 100644 --- a/packages/gitmoji-changelog-cli/src/presets/python.js +++ b/packages/gitmoji-changelog-cli/src/presets/python.js @@ -1,29 +1,60 @@ -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] + 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) { + const backend = pyproject['build-system'] && pyproject['build-system']['build-backend'] + if (!backend) { + throw new Error('Cannot resolve dynamic version: build-backend is not set') + } + + switch (backend) { + case 'hatchling.build': + version = getHatchVersion() + break + + case 'flit_core.buildapi': + version = getFlitVersion() + break + + case 'setuptools.build_meta': + version = getSetuptoolsScmVersion() + break + + case 'pdm.backend': + version = getPdmVersion() + break + + default: + throw new Error(`Unsupported build-backend: ${backend}`) + } + } 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 { @@ -37,32 +68,55 @@ module.exports = async () => { } -function recursiveKeySearch(key, data) { - // https://codereview.stackexchange.com/a/143914 - if (data === null) { - return [] +function getHatchVersion() { + try { + const version = ChildProcess.execSync('hatch version', { encoding: 'utf-8' }).trim() + return version + } catch (e) { + throw new Error('Failed to run `hatch version`: ' + e.message) } +} + - if (data !== Object(data)) { - return [] +function getFlitVersion() { + try { + const output = ChildProcess.execSync('flit info', { encoding: 'utf-8' }) + const match = output.match(/^Version:\s*(.+)$/m) + if (match) return match[1].trim() + throw new Error('Could not extract version from `flit info` output') + } catch (e) { + throw new Error('Failed to run `flit info`: ' + e.message) } +} - let results = [] - if (data.constructor === Array) { - for (let i = 0, len = data.length; i < len; i += 1) { - results = results.concat(recursiveKeySearch(key, data[i])) - } - return results +function getSetuptoolsScmVersion() { + try { + return ChildProcess.execSync('python -m setuptools_scm', { encoding: 'utf-8' }).trim() + } catch (e) { + throw new Error('Failed to run `setuptools_scm`: ' + e.message) } +} - 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 getPdmVersion() { + try { + return ChildProcess.execSync('pdm show --version', { encoding: 'utf-8' }).trim() + } catch (e) { + throw new Error('Failed to run `pdm show --version`: ' + e.message) + } +} + + +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/packages/gitmoji-changelog-cli/src/presets/python.spec.js b/packages/gitmoji-changelog-cli/src/presets/python.spec.js index 797aa17..815456a 100644 --- a/packages/gitmoji-changelog-cli/src/presets/python.spec.js +++ b/packages/gitmoji-changelog-cli/src/presets/python.spec.js @@ -1,8 +1,13 @@ const fs = require('fs') +const ChildProcess = require('child_process') const loadProjectInfo = require('./python.js') -describe('getPackageInfo', () => { +describe('getPackageInfo | python', () => { + afterEach(() => { + jest.restoreAllMocks() + }) + it('should extract metadata from a pyproject.toml made by poetry', async () =>{ // Note the TOML section is distinct for poetry fs.readFileSync.mockReturnValue(` @@ -43,32 +48,32 @@ describe('getPackageInfo', () => { "Development Status :: 4 - Beta", "Programming Language :: Python" ] - + dependencies = [ "httpx", "gidgethub[httpx]>4.0.0", "django>2.1; os_name != 'nt'", "django>2.0; os_name == 'nt'" ] - + [project.optional-dependencies] test = [ "pytest < 5.0.0", "pytest-cov[all]" ] - + [project.urls] homepage = "example.com" documentation = "readthedocs.org" repository = "github.com" changelog = "github.com/me/spam/blob/master/CHANGELOG.md" - + [project.scripts] spam-cli = "spam:main_cli" - + [project.gui-scripts] spam-gui = "spam:main_gui" - + [project.entry-points."spam.magical"] tomatoes = "spam:main_tomatoes" `) @@ -133,5 +138,185 @@ describe('getPackageInfo', () => { }) }) +describe('getPackageInfo | python | dynamic version', () => { + afterEach(() => { + jest.restoreAllMocks() + }) + + it('should extract version from dynamic field for hatch', async () => { + fs.readFileSync.mockReturnValue(` + [build-system] + build-backend = "hatchling.build" + + [project] + name = "hatch-package-name" + dynamic = ["version"] + + [tool.hatch.version] + path = "src/__init__.py" + `) + jest.spyOn(ChildProcess, 'execSync').mockReturnValue('0.0.1\n') + + const result = await loadProjectInfo() + + expect(result.version).toEqual('0.0.1') + }) + + it('should extract version from dynamic field for flit', async () => { + fs.readFileSync.mockReturnValue(` + [build-system] + build-backend = "flit_core.buildapi" + + [project] + name = "flit-package-name" + dynamic = ["version"] + + [tool.flit.module] + name = "flit_package_name" + `) + jest.spyOn(ChildProcess, 'execSync').mockReturnValue( + 'Module: flit_package_name\nVersion: 0.2.3' + ) + + const result = await loadProjectInfo() + + expect(result.version).toEqual('0.2.3') + }) + + it('should extract version using setuptools_scm', async () => { + fs.readFileSync.mockReturnValue(` + [build-system] + build-backend = "setuptools.build_meta" + + [project] + name = "setuptools-package" + dynamic = ["version"] + + [tool.setuptools_scm] + `) + jest.spyOn(ChildProcess, 'execSync').mockReturnValue('1.2.3\n') + + const result = await loadProjectInfo() + + expect(result.version).toEqual('1.2.3') + }) + + it('should extract version from dynamic field for pdm', async () => { + fs.readFileSync.mockReturnValue(` + [build-system] + build-backend = "pdm.backend" + + [project] + name = "pdm-package" + dynamic = ["version"] + + [tool.pdm.version] + source = "file" + `) + jest.spyOn(ChildProcess, 'execSync').mockReturnValue('3.4.5\n') + + const result = await loadProjectInfo() + + expect(result.version).toEqual('3.4.5') + }) +}) + +describe('getPackageInfo | python | dynamic description', () => { + afterEach(() => { + jest.restoreAllMocks() + }) + + 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" + ` + } + 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') diff --git a/yarn.lock b/yarn.lock index e11df11..55be497 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.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/smol-toml/-/smol-toml-1.3.1.tgz#d9084a9e212142e3cab27ef4e2b8e8ba620bfe15" + integrity sha512-tEYNll18pPKHroYSmLLrksq233j021G0giwW7P3D24jC54pQ5W5BXMsQ/Mvw1OJCmEYDgY+lrzT+3nNUtoNfXQ== + snapdragon-node@^2.0.1: version "2.1.1" resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b"