Skip to content

Commit e660b02

Browse files
ruromeroclaude
andauthored
fix(poetry): add ENOENT error handling for missing poetry binary (#538)
* fix(poetry): add ENOENT error handling for missing poetry binary Throw a clear "poetry is not accessible" error instead of the raw "spawnSync poetry ENOENT" when the poetry binary is not installed. Implements TC-4563 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(poetry): address sourcery-ai review suggestions - Thread resolved poetryBin through to _getPoetryShowTreeOutput and _getPoetryShowAllOutput to avoid duplicate resolution - Attach original error as cause in ENOENT path for debugging - Add happy-path test for _verifyPoetryAccessible Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(poetry): skip binary verification when env vars bypass invocation Guard _verifyPoetryAccessible with an env-var bypass check so poetry does not need to be installed when both TRUSTIFY_DA_POETRY_SHOW_TREE and TRUSTIFY_DA_POETRY_SHOW_ALL are set. Implements TC-4660 Assisted-by: Claude Code * fix(poetry): move _verifyPoetryAccessible above _getDependencyData JSDoc The method was inserted between the JSDoc block and _getDependencyData, causing the @param docs to appear as if they belonged to the wrong method. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6645ba5 commit e660b02

2 files changed

Lines changed: 126 additions & 8 deletions

File tree

src/providers/python_poetry.js

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,17 @@ export default class Python_poetry extends Base_pyproject {
4040
return 'poetry'
4141
}
4242

43+
_verifyPoetryAccessible(poetryBin) {
44+
try {
45+
invokeCommand(poetryBin, ['--version'])
46+
} catch (error) {
47+
if (error.code === 'ENOENT') {
48+
throw new Error(`poetry is not accessible at "${poetryBin}"`, { cause: error })
49+
}
50+
throw new Error('failed to check for poetry binary', { cause: error })
51+
}
52+
}
53+
4354
/**
4455
* @param {string} manifestDir
4556
* @param {string} _workspaceDir - unused (poetry has no workspace support)
@@ -49,9 +60,15 @@ export default class Python_poetry extends Base_pyproject {
4960
*/
5061
// eslint-disable-next-line no-unused-vars
5162
async _getDependencyData(manifestDir, _workspaceDir, parsed, opts) {
63+
let poetryBin = getCustomPath('poetry', opts)
64+
let envBypass = environmentVariableIsPopulated('TRUSTIFY_DA_POETRY_SHOW_TREE')
65+
&& environmentVariableIsPopulated('TRUSTIFY_DA_POETRY_SHOW_ALL')
66+
if (!envBypass) {
67+
this._verifyPoetryAccessible(poetryBin)
68+
}
5269
let hasDevGroup = !!(parsed.tool?.poetry?.group?.dev || parsed.tool?.poetry?.['dev-dependencies'])
53-
let treeOutput = this._getPoetryShowTreeOutput(manifestDir, hasDevGroup, opts)
54-
let showAllOutput = this._getPoetryShowAllOutput(manifestDir, opts)
70+
let treeOutput = this._getPoetryShowTreeOutput(manifestDir, hasDevGroup, poetryBin)
71+
let showAllOutput = this._getPoetryShowAllOutput(manifestDir, poetryBin)
5572
let versionMap = this._parsePoetryShowAll(showAllOutput)
5673
let lockDir = this._findLockFileDir(manifestDir, opts)
5774
let markerData = this._extractMarkerData(lockDir, parsed)
@@ -62,14 +79,13 @@ export default class Python_poetry extends Base_pyproject {
6279
* Get poetry show --tree output.
6380
* @param {string} manifestDir
6481
* @param {boolean} hasDevGroup
65-
* @param {Object} opts
82+
* @param {string} poetryBin
6683
* @returns {string}
6784
*/
68-
_getPoetryShowTreeOutput(manifestDir, hasDevGroup, opts) {
85+
_getPoetryShowTreeOutput(manifestDir, hasDevGroup, poetryBin) {
6986
if (environmentVariableIsPopulated('TRUSTIFY_DA_POETRY_SHOW_TREE')) {
7087
return Buffer.from(process.env['TRUSTIFY_DA_POETRY_SHOW_TREE'], 'base64').toString('utf-8')
7188
}
72-
let poetryBin = getCustomPath('poetry', opts)
7389
let args = ['show', '--tree', '--no-ansi']
7490
if (hasDevGroup) {
7591
args.push('--without', 'dev')
@@ -80,14 +96,13 @@ export default class Python_poetry extends Base_pyproject {
8096
/**
8197
* Get poetry show --all output (flat list with resolved versions).
8298
* @param {string} manifestDir
83-
* @param {Object} opts
99+
* @param {string} poetryBin
84100
* @returns {string}
85101
*/
86-
_getPoetryShowAllOutput(manifestDir, opts) {
102+
_getPoetryShowAllOutput(manifestDir, poetryBin) {
87103
if (environmentVariableIsPopulated('TRUSTIFY_DA_POETRY_SHOW_ALL')) {
88104
return Buffer.from(process.env['TRUSTIFY_DA_POETRY_SHOW_ALL'], 'base64').toString('utf-8')
89105
}
90-
let poetryBin = getCustomPath('poetry', opts)
91106
return invokeCommand(poetryBin, ['show', '--no-ansi', '--all'], { cwd: manifestDir }).toString()
92107
}
93108

test/providers/python_pyproject.test.js

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import fs from 'fs'
22
import path from 'path'
33

44
import { expect } from 'chai'
5+
import esmock from 'esmock'
56
import { useFakeTimers } from 'sinon'
67

78
import Python_pip_pyproject from '../../src/providers/python_pip_pyproject.js'
@@ -645,3 +646,105 @@ suite('testing the python-pyproject data provider', () => {
645646
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true })
646647
clock.restore()
647648
})
649+
650+
suite('testing python-poetry error handling', () => {
651+
/** Verifies that a missing poetry binary produces a clear error message. */
652+
test('verify error when poetry binary is not accessible', async () => {
653+
let provider = await esmock('../../src/providers/python_poetry.js', {
654+
'../../src/tools.js': {
655+
getCustomPath: () => '/nonexistent/poetry',
656+
invokeCommand: () => {
657+
let err = new Error('spawn /nonexistent/poetry ENOENT')
658+
err.code = 'ENOENT'
659+
throw err
660+
}
661+
}
662+
})
663+
664+
let instance = new provider.default()
665+
expect(() => instance._verifyPoetryAccessible('/nonexistent/poetry'))
666+
.to.throw('poetry is not accessible at "/nonexistent/poetry"')
667+
}).timeout(TIMEOUT)
668+
669+
/** Verifies that an accessible poetry binary does not throw. */
670+
test('verify no error when poetry binary is accessible', async () => {
671+
let provider = await esmock('../../src/providers/python_poetry.js', {
672+
'../../src/tools.js': {
673+
getCustomPath: () => 'poetry',
674+
invokeCommand: () => Buffer.from('Poetry (version 1.8.0)')
675+
}
676+
})
677+
678+
let instance = new provider.default()
679+
expect(() => instance._verifyPoetryAccessible('poetry')).to.not.throw()
680+
}).timeout(TIMEOUT)
681+
682+
/** Verifies that non-ENOENT errors are re-thrown with cause chain preserved. */
683+
test('verify non-ENOENT errors are re-thrown with cause', async () => {
684+
let originalError = new Error('permission denied')
685+
originalError.code = 'EACCES'
686+
687+
let provider = await esmock('../../src/providers/python_poetry.js', {
688+
'../../src/tools.js': {
689+
getCustomPath: () => 'poetry',
690+
invokeCommand: () => {
691+
throw originalError
692+
}
693+
}
694+
})
695+
696+
let instance = new provider.default()
697+
expect(() => instance._verifyPoetryAccessible('poetry'))
698+
.to.throw('failed to check for poetry binary')
699+
}).timeout(TIMEOUT)
700+
701+
/** Verifies that _getDependencyData skips binary verification when both env vars bypass poetry. */
702+
test('verify _getDependencyData skips binary check when both env vars are set', async () => {
703+
let provider = await esmock('../../src/providers/python_poetry.js', {
704+
'../../src/tools.js': {
705+
getCustomPath: () => '/nonexistent/poetry',
706+
environmentVariableIsPopulated: (name) => {
707+
return name === 'TRUSTIFY_DA_POETRY_SHOW_TREE' || name === 'TRUSTIFY_DA_POETRY_SHOW_ALL'
708+
},
709+
invokeCommand: () => {
710+
let err = new Error('spawn /nonexistent/poetry ENOENT')
711+
err.code = 'ENOENT'
712+
throw err
713+
}
714+
}
715+
})
716+
717+
let instance = new provider.default()
718+
instance._getPoetryShowTreeOutput = () => ''
719+
instance._getPoetryShowAllOutput = () => ''
720+
instance._parsePoetryShowAll = () => new Map()
721+
instance._findLockFileDir = () => '/tmp'
722+
instance._extractMarkerData = () => ({})
723+
instance._parsePoetryTree = () => ({ directDeps: [], graph: new Map() })
724+
725+
await instance._getDependencyData('/tmp', '/tmp', { tool: {} }, {})
726+
}).timeout(TIMEOUT)
727+
728+
/** Verifies that _getDependencyData runs binary verification when env vars are not set. */
729+
test('verify _getDependencyData runs binary check when env vars are not set', async () => {
730+
let provider = await esmock('../../src/providers/python_poetry.js', {
731+
'../../src/tools.js': {
732+
getCustomPath: () => '/nonexistent/poetry',
733+
environmentVariableIsPopulated: () => false,
734+
invokeCommand: () => {
735+
let err = new Error('spawn /nonexistent/poetry ENOENT')
736+
err.code = 'ENOENT'
737+
throw err
738+
}
739+
}
740+
})
741+
742+
let instance = new provider.default()
743+
try {
744+
await instance._getDependencyData('/tmp', '/tmp', { tool: {} }, {})
745+
expect.fail('Expected error to be thrown')
746+
} catch (e) {
747+
expect(e.message).to.include('poetry is not accessible')
748+
}
749+
}).timeout(TIMEOUT)
750+
})

0 commit comments

Comments
 (0)