-
Notifications
You must be signed in to change notification settings - Fork 578
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #2029 from snyk/feat/poetry
Feat: Poetry support for @snyk/fix
- Loading branch information
Showing
26 changed files
with
1,874 additions
and
9 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
42 changes: 42 additions & 0 deletions
42
packages/snyk-fix/src/plugins/python/handlers/poetry/index.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
import * as debugLib from 'debug'; | ||
import * as ora from 'ora'; | ||
|
||
import { EntityToFix, FixOptions } from '../../../../types'; | ||
import { checkPackageToolSupported } from '../../../package-tool-supported'; | ||
import { PluginFixResponse } from '../../../types'; | ||
import { updateDependencies } from './update-dependencies'; | ||
|
||
const debug = debugLib('snyk-fix:python:Poetry'); | ||
|
||
export async function poetry( | ||
fixable: EntityToFix[], | ||
options: FixOptions, | ||
): Promise<PluginFixResponse> { | ||
debug(`Preparing to fix ${fixable.length} Python Poetry projects`); | ||
const handlerResult: PluginFixResponse = { | ||
succeeded: [], | ||
failed: [], | ||
skipped: [], | ||
}; | ||
|
||
await checkPackageToolSupported('poetry', options); | ||
for (const [index, entity] of fixable.entries()) { | ||
const spinner = ora({ isSilent: options.quiet, stream: process.stdout }); | ||
const spinnerMessage = `Fixing pyproject.toml ${index + 1}/${ | ||
fixable.length | ||
}`; | ||
spinner.text = spinnerMessage; | ||
spinner.start(); | ||
|
||
const { failed, succeeded, skipped } = await updateDependencies( | ||
entity, | ||
options, | ||
); | ||
handlerResult.succeeded.push(...succeeded); | ||
handlerResult.failed.push(...failed); | ||
handlerResult.skipped.push(...skipped); | ||
spinner.stop(); | ||
} | ||
|
||
return handlerResult; | ||
} |
175 changes: 175 additions & 0 deletions
175
packages/snyk-fix/src/plugins/python/handlers/poetry/update-dependencies/index.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,175 @@ | ||
import * as pathLib from 'path'; | ||
import * as toml from 'toml'; | ||
|
||
import * as debugLib from 'debug'; | ||
import * as poetryFix from '@snyk/fix-poetry'; | ||
|
||
import { PluginFixResponse } from '../../../../types'; | ||
import { | ||
DependencyPins, | ||
EntityToFix, | ||
FixChangesSummary, | ||
FixOptions, | ||
} from '../../../../../types'; | ||
import { NoFixesCouldBeAppliedError } from '../../../../../lib/errors/no-fixes-applied'; | ||
import { CommandFailedError } from '../../../../../lib/errors/command-failed-to-run-error'; | ||
import { validateRequiredData } from '../../validate-required-data'; | ||
import { standardizePackageName } from '../../../standardize-package-name'; | ||
|
||
const debug = debugLib('snyk-fix:python:Poetry'); | ||
|
||
interface PyProjectToml { | ||
tool: { | ||
poetry: { | ||
name: string; | ||
version: string; | ||
description: string; | ||
authors: string[]; | ||
dependencies?: object; | ||
'dev-dependencies'?: object; | ||
}; | ||
}; | ||
} | ||
export async function updateDependencies( | ||
entity: EntityToFix, | ||
options: FixOptions, | ||
): Promise<PluginFixResponse> { | ||
const handlerResult: PluginFixResponse = { | ||
succeeded: [], | ||
failed: [], | ||
skipped: [], | ||
}; | ||
let poetryCommand; | ||
try { | ||
const { upgrades, devUpgrades } = await generateUpgrades(entity); | ||
const { remediation, targetFile } = validateRequiredData(entity); | ||
const targetFilePath = pathLib.resolve(entity.workspace.path, targetFile); | ||
const { dir } = pathLib.parse(targetFilePath); | ||
// TODO: for better support we need to: | ||
// 1. parse the manifest and extract original requirements, version spec etc | ||
// 2. swap out only the version and retain original spec | ||
// 3. re-lock the lockfile | ||
|
||
// update prod dependencies first | ||
if (!options.dryRun && upgrades.length) { | ||
const res = await poetryFix.poetryAdd(dir, upgrades, {}); | ||
if (res.exitCode !== 0) { | ||
poetryCommand = res.command; | ||
throwPoetryError(res.stderr ? res.stderr : res.stdout, res.command); | ||
} | ||
} | ||
|
||
// update dev dependencies second | ||
if (!options.dryRun && devUpgrades.length) { | ||
const res = await poetryFix.poetryAdd(dir, devUpgrades, { | ||
dev: true, | ||
}); | ||
if (res.exitCode !== 0) { | ||
poetryCommand = res.command; | ||
throwPoetryError(res.stderr ? res.stderr : res.stdout, res.command); | ||
} | ||
} | ||
const changes = generateSuccessfulChanges(remediation.pin); | ||
handlerResult.succeeded.push({ original: entity, changes }); | ||
} catch (error) { | ||
debug( | ||
`Failed to fix ${entity.scanResult.identity.targetFile}.\nERROR: ${error}`, | ||
); | ||
handlerResult.failed.push({ | ||
original: entity, | ||
error, | ||
tip: poetryCommand ? `Try running \`${poetryCommand}\`` : undefined, | ||
}); | ||
} | ||
return handlerResult; | ||
} | ||
|
||
export function generateSuccessfulChanges( | ||
pins: DependencyPins, | ||
): FixChangesSummary[] { | ||
const changes: FixChangesSummary[] = []; | ||
for (const pkgAtVersion of Object.keys(pins)) { | ||
const pin = pins[pkgAtVersion]; | ||
const updatedMessage = pin.isTransitive ? 'Pinned' : 'Upgraded'; | ||
const newVersion = pin.upgradeTo.split('@')[1]; | ||
const [pkgName, version] = pkgAtVersion.split('@'); | ||
|
||
changes.push({ | ||
success: true, | ||
userMessage: `${updatedMessage} ${pkgName} from ${version} to ${newVersion}`, | ||
issueIds: pin.vulns, | ||
from: pkgAtVersion, | ||
to: `${pkgName}@${newVersion}`, | ||
}); | ||
} | ||
return changes; | ||
} | ||
|
||
export async function generateUpgrades( | ||
entity: EntityToFix, | ||
): Promise<{ upgrades: string[]; devUpgrades: string[] }> { | ||
const { remediation, targetFile } = validateRequiredData(entity); | ||
const pins = remediation.pin; | ||
|
||
const targetFilePath = pathLib.resolve(entity.workspace.path, targetFile); | ||
const { dir } = pathLib.parse(targetFilePath); | ||
const pyProjectTomlRaw = await entity.workspace.readFile( | ||
pathLib.resolve(dir, 'pyproject.toml'), | ||
); | ||
const pyProjectToml: PyProjectToml = toml.parse(pyProjectTomlRaw); | ||
|
||
const prodTopLevelDeps = Object.keys( | ||
pyProjectToml.tool.poetry.dependencies ?? {}, | ||
); | ||
const devTopLevelDeps = Object.keys( | ||
pyProjectToml.tool.poetry['dev-dependencies'] ?? {}, | ||
); | ||
|
||
const upgrades: string[] = []; | ||
const devUpgrades: string[] = []; | ||
for (const pkgAtVersion of Object.keys(pins)) { | ||
const pin = pins[pkgAtVersion]; | ||
const newVersion = pin.upgradeTo.split('@')[1]; | ||
const [pkgName] = pkgAtVersion.split('@'); | ||
|
||
const upgrade = `${standardizePackageName(pkgName)}==${newVersion}`; | ||
|
||
if (pin.isTransitive) { | ||
// transitive and it could have come from a dev or prod dep | ||
// since we can't tell right now let be pinned into production deps | ||
upgrades.push(upgrade); | ||
} else if (prodTopLevelDeps.includes(pkgName)) { | ||
upgrades.push(upgrade); | ||
} else if (entity.options.dev && devTopLevelDeps.includes(pkgName)) { | ||
devUpgrades.push(upgrade); | ||
} else { | ||
debug( | ||
`Could not determine what type of upgrade ${upgrade} is. When choosing between: transitive upgrade, production or dev direct upgrade. `, | ||
); | ||
} | ||
} | ||
return { upgrades, devUpgrades }; | ||
} | ||
|
||
function throwPoetryError(stderr: string, command?: string) { | ||
const ALREADY_UP_TO_DATE = 'No dependencies to install or update'; | ||
const INCOMPATIBLE_PYTHON = new RegExp( | ||
/Python requirement (.*) is not compatible/g, | ||
'gm', | ||
); | ||
const match = INCOMPATIBLE_PYTHON.exec(stderr); | ||
if (match) { | ||
throw new CommandFailedError( | ||
`The current project's Python requirement ${match[1]} is not compatible with some of the required packages`, | ||
command, | ||
); | ||
} | ||
// TODO: test this | ||
if (stderr.includes(ALREADY_UP_TO_DATE)) { | ||
throw new CommandFailedError( | ||
'No dependencies could be updated as they seem to be at the correct versions. Make sure installed dependencies in the environment match those in the lockfile by running `poetry update`', | ||
command, | ||
); | ||
} | ||
throw new NoFixesCouldBeAppliedError(); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.