feat(manager/pip-compile): Add support for non-default package repos to the pip-compile manager (#26853)

Co-authored-by: HonkingGoose <34918129+HonkingGoose@users.noreply.github.com>
This commit is contained in:
Miles Budnek 2024-02-12 12:25:14 -05:00 committed by GitHub
parent 2610464754
commit c3e57e7b51
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 256 additions and 7 deletions

View file

@ -461,6 +461,34 @@ verify_ssl = true
name = "pypi" name = "pypi"
``` ```
### pip-compile
The pip-compile manager extracts `--index-url` and `--extra-index-url` directives from its input file.
Renovate will match those URLs with credentials from matching `hostRules` blocks in its configuration and pass appropriate `PIP_INDEX_URL` or `PIP_EXTRA_INDEX_URL` environment variables `pip-compile` with those credentials while generating the output file.
```title="requirements.in"
--extra-index-url https://pypi.my.domain/simple
private-package==1.2.3
```
```json
{
"pip-compile": {
"fileMatch": ["requirements.in"]
},
"hostRules": [
{
"matchHost": "pypi.my.domain",
"username": "myuser",
"password": "mypassword"
}
]
}
```
Note: When credentials are passed to `pip-compile` this way Renovate will also pass the `--no-emit-index-url` flag to avoid leaking plain-text credentials to the output file.
### poetry ### poetry
For every Poetry source, a `hostRules` search is done and then any found credentials are added to env like `POETRY_HTTP_BASIC_X_USERNAME` and `POETRY_HTTP_BASIC_X_PASSWORD`, where `X` represents the normalized name of the source in `pyproject.toml`. For every Poetry source, a `hostRules` search is done and then any found credentials are added to env like `POETRY_HTTP_BASIC_X_USERNAME` and `POETRY_HTTP_BASIC_X_PASSWORD`, where `X` represents the normalized name of the source in `pyproject.toml`.

View file

@ -279,16 +279,42 @@ describe('modules/manager/pip-compile/artifacts', () => {
Fixtures.get('requirementsNoHeaders.txt'), Fixtures.get('requirementsNoHeaders.txt'),
'subdir/requirements.in', 'subdir/requirements.in',
'subdir/requirements.txt', 'subdir/requirements.txt',
false,
), ),
).toBe('pip-compile requirements.in'); ).toBe('pip-compile requirements.in');
}); });
it('returns --no-emit-index-url when credentials are present in URLs', () => {
expect(
constructPipCompileCmd(
Fixtures.get('requirementsNoHeaders.txt'),
'subdir/requirements.in',
'subdir/requirements.txt',
true,
),
).toBe('pip-compile --no-emit-index-url requirements.in');
});
it('returns extracted common arguments (like those featured in the README)', () => { it('returns extracted common arguments (like those featured in the README)', () => {
expect( expect(
constructPipCompileCmd( constructPipCompileCmd(
Fixtures.get('requirementsWithHashes.txt'), Fixtures.get('requirementsWithHashes.txt'),
'subdir/requirements.in', 'subdir/requirements.in',
'subdir/requirements.txt', 'subdir/requirements.txt',
false,
),
).toBe(
'pip-compile --allow-unsafe --generate-hashes --no-emit-index-url --strip-extras --resolver=backtracking --output-file=requirements.txt requirements.in',
);
});
it('returns --no-emit-index-url only once when its in the header and credentials are present in URLs', () => {
expect(
constructPipCompileCmd(
Fixtures.get('requirementsWithHashes.txt'),
'subdir/requirements.in',
'subdir/requirements.txt',
true,
), ),
).toBe( ).toBe(
'pip-compile --allow-unsafe --generate-hashes --no-emit-index-url --strip-extras --resolver=backtracking --output-file=requirements.txt requirements.in', 'pip-compile --allow-unsafe --generate-hashes --no-emit-index-url --strip-extras --resolver=backtracking --output-file=requirements.txt requirements.in',
@ -301,6 +327,7 @@ describe('modules/manager/pip-compile/artifacts', () => {
Fixtures.get('requirementsWithUnknownArguments.txt'), Fixtures.get('requirementsWithUnknownArguments.txt'),
'subdir/requirements.in', 'subdir/requirements.in',
'subdir/requirements.txt', 'subdir/requirements.txt',
false,
), ),
).toBe('pip-compile --generate-hashes requirements.in'); ).toBe('pip-compile --generate-hashes requirements.in');
expect(logger.trace).toHaveBeenCalledWith( expect(logger.trace).toHaveBeenCalledWith(
@ -319,6 +346,7 @@ describe('modules/manager/pip-compile/artifacts', () => {
Fixtures.get('requirementsWithExploitingArguments.txt'), Fixtures.get('requirementsWithExploitingArguments.txt'),
'subdir/requirements.in', 'subdir/requirements.in',
'subdir/requirements.txt', 'subdir/requirements.txt',
false,
), ),
).toBe( ).toBe(
'pip-compile --generate-hashes --output-file=requirements.txt requirements.in', 'pip-compile --generate-hashes --output-file=requirements.txt requirements.in',

View file

@ -10,23 +10,30 @@ import {
} from '../../../util/fs'; } from '../../../util/fs';
import { getRepoStatus } from '../../../util/git'; import { getRepoStatus } from '../../../util/git';
import { regEx } from '../../../util/regex'; import { regEx } from '../../../util/regex';
import * as pipRequirements from '../pip_requirements';
import type { UpdateArtifact, UpdateArtifactsResult } from '../types'; import type { UpdateArtifact, UpdateArtifactsResult } from '../types';
import { import {
constraintLineRegex, constraintLineRegex,
deprecatedAllowedPipArguments, deprecatedAllowedPipArguments,
getExecOptions, getExecOptions,
getRegistryUrlVarsFromPackageFile,
} from './common'; } from './common';
export function constructPipCompileCmd( export function constructPipCompileCmd(
content: string, content: string,
inputFileName: string, inputFileName: string,
outputFileName: string, outputFileName: string,
haveCredentials: boolean,
): string { ): string {
const headers = constraintLineRegex.exec(content); const headers = constraintLineRegex.exec(content);
const args = ['pip-compile']; const args = ['pip-compile'];
if (headers?.groups) { if (!!headers?.groups || haveCredentials) {
logger.debug(`Found pip-compile header: ${headers[0]}`); logger.debug(`Found pip-compile header: ${headers?.[0]}`);
for (const argument of split(headers.groups.arguments)) { const headerArguments = split(headers?.groups?.arguments ?? '');
if (haveCredentials && !headerArguments.includes('--no-emit-index-url')) {
headerArguments.push('--no-emit-index-url');
}
for (const argument of headerArguments) {
if (deprecatedAllowedPipArguments.includes(argument)) { if (deprecatedAllowedPipArguments.includes(argument)) {
args.push(argument); args.push(argument);
} else if (argument.startsWith('--output-file=')) { } else if (argument.startsWith('--output-file=')) {
@ -93,13 +100,21 @@ export async function updateArtifacts({
if (config.isLockFileMaintenance) { if (config.isLockFileMaintenance) {
await deleteLocalFile(outputFileName); await deleteLocalFile(outputFileName);
} }
const packageFile = pipRequirements.extractPackageFile(newInputContent);
const registryUrlVars = getRegistryUrlVarsFromPackageFile(packageFile);
const cmd = constructPipCompileCmd( const cmd = constructPipCompileCmd(
existingOutput, existingOutput,
inputFileName, inputFileName,
outputFileName, outputFileName,
registryUrlVars.haveCredentials,
);
const execOptions = await getExecOptions(
config,
inputFileName,
registryUrlVars.environmentVars,
); );
const execOptions = await getExecOptions(config, inputFileName);
logger.trace({ cmd }, 'pip-compile command'); logger.trace({ cmd }, 'pip-compile command');
logger.trace({ env: execOptions.extraEnv }, 'pip-compile extra env vars');
await exec(cmd, execOptions); await exec(cmd, execOptions);
const status = await getRepoStatus(); const status = await getRepoStatus();
if (!status?.modified.includes(outputFileName)) { if (!status?.modified.includes(outputFileName)) {

View file

@ -1,4 +1,12 @@
import { allowedPipOptions, extractHeaderCommand } from './common'; import { mockDeep } from 'jest-mock-extended';
import { hostRules } from '../../../../test/util';
import {
allowedPipOptions,
extractHeaderCommand,
getRegistryUrlVarsFromPackageFile,
} from './common';
jest.mock('../../../util/host-rules', () => mockDeep());
function getCommandInHeader(command: string) { function getCommandInHeader(command: string) {
return `# return `#
@ -141,4 +149,93 @@ describe('modules/manager/pip-compile/common', () => {
).toHaveProperty('isCustomCommand', true); ).toHaveProperty('isCustomCommand', true);
}); });
}); });
describe('getRegistryUrlFlagsFromPackageFile()', () => {
it('handles both registryUrls and additionalRegistryUrls', () => {
hostRules.find.mockReturnValue({});
expect(
getRegistryUrlVarsFromPackageFile({
deps: [],
registryUrls: ['https://example.com/pypi/simple'],
additionalRegistryUrls: ['https://example2.com/pypi/simple'],
}),
).toEqual({
haveCredentials: false,
environmentVars: {
PIP_INDEX_URL: 'https://example.com/pypi/simple',
PIP_EXTRA_INDEX_URL: 'https://example2.com/pypi/simple',
},
});
});
it('handles multiple additionalRegistryUrls', () => {
hostRules.find.mockReturnValue({});
expect(
getRegistryUrlVarsFromPackageFile({
deps: [],
additionalRegistryUrls: [
'https://example.com/pypi/simple',
'https://example2.com/pypi/simple',
],
}),
).toEqual({
haveCredentials: false,
environmentVars: {
PIP_EXTRA_INDEX_URL:
'https://example.com/pypi/simple https://example2.com/pypi/simple',
},
});
});
it('uses extra index URLs with no auth', () => {
hostRules.find.mockReturnValue({});
expect(
getRegistryUrlVarsFromPackageFile({
deps: [],
registryUrls: ['https://example.com/pypi/simple'],
}),
).toEqual({
haveCredentials: false,
environmentVars: {
PIP_INDEX_URL: 'https://example.com/pypi/simple',
},
});
});
it('uses auth from extra index URLs matching host rules', () => {
hostRules.find.mockReturnValue({
username: 'user',
password: 'password',
});
expect(
getRegistryUrlVarsFromPackageFile({
deps: [],
registryUrls: ['https://example.com/pypi/simple'],
}),
).toEqual({
haveCredentials: true,
environmentVars: {
PIP_INDEX_URL: 'https://user:password@example.com/pypi/simple',
},
});
});
it('handles invalid URLs', () => {
hostRules.find.mockReturnValue({});
expect(
getRegistryUrlVarsFromPackageFile({
deps: [],
additionalRegistryUrls: [
'https://example.com/pypi/simple',
'this is not a valid URL',
],
}),
).toEqual({
haveCredentials: false,
environmentVars: {
PIP_EXTRA_INDEX_URL: 'https://example.com/pypi/simple',
},
});
});
});
}); });

View file

@ -1,11 +1,13 @@
import is from '@sindresorhus/is'; import is from '@sindresorhus/is';
import { split } from 'shlex'; import { split } from 'shlex';
import { logger } from '../../../logger'; import { logger } from '../../../logger';
import { isNotNullOrUndefined } from '../../../util/array';
import type { ExecOptions } from '../../../util/exec/types'; import type { ExecOptions } from '../../../util/exec/types';
import { ensureCacheDir } from '../../../util/fs'; import { ensureCacheDir } from '../../../util/fs';
import * as hostRules from '../../../util/host-rules';
import { regEx } from '../../../util/regex'; import { regEx } from '../../../util/regex';
import type { UpdateArtifactsConfig } from '../types'; import type { PackageFileContent, UpdateArtifactsConfig } from '../types';
import type { PipCompileArgs } from './types'; import type { GetRegistryUrlVarsResult, PipCompileArgs } from './types';
export function getPythonConstraint( export function getPythonConstraint(
config: UpdateArtifactsConfig, config: UpdateArtifactsConfig,
@ -35,6 +37,7 @@ export function getPipToolsConstraint(config: UpdateArtifactsConfig): string {
export async function getExecOptions( export async function getExecOptions(
config: UpdateArtifactsConfig, config: UpdateArtifactsConfig,
inputFileName: string, inputFileName: string,
extraEnv: Record<string, string>,
): Promise<ExecOptions> { ): Promise<ExecOptions> {
const constraint = getPythonConstraint(config); const constraint = getPythonConstraint(config);
const pipToolsConstraint = getPipToolsConstraint(config); const pipToolsConstraint = getPipToolsConstraint(config);
@ -53,6 +56,7 @@ export async function getExecOptions(
], ],
extraEnv: { extraEnv: {
PIP_CACHE_DIR: await ensureCacheDir('pip'), PIP_CACHE_DIR: await ensureCacheDir('pip'),
...extraEnv,
}, },
}; };
return execOptions; return execOptions;
@ -217,3 +221,72 @@ function throwForUnknownOption(arg: string): void {
} }
throw new Error(`Option ${arg} not supported (yet)`); throw new Error(`Option ${arg} not supported (yet)`);
} }
function buildRegistryUrl(url: string): URL | null {
try {
const ret = new URL(url);
const hostRule = hostRules.find({ url });
if (!ret.username && !ret.password) {
ret.username = hostRule.username ?? '';
ret.password = hostRule.password ?? '';
}
return ret;
} catch {
return null;
}
}
function getRegistryUrlVarFromUrls(
varName: keyof GetRegistryUrlVarsResult['environmentVars'],
urls: URL[],
): GetRegistryUrlVarsResult {
if (!urls.length) {
return {
haveCredentials: false,
environmentVars: {},
};
}
let haveCredentials = false;
for (const url of urls) {
if (url.username || url.password) {
haveCredentials = true;
}
}
const registryUrlsString = urls.map((url) => url.href).join(' ');
const ret: GetRegistryUrlVarsResult = {
haveCredentials,
environmentVars: {},
};
if (registryUrlsString) {
ret.environmentVars[varName] = registryUrlsString;
}
return ret;
}
export function getRegistryUrlVarsFromPackageFile(
packageFile: PackageFileContent | null,
): GetRegistryUrlVarsResult {
// There should only ever be one element in registryUrls, since pip_requirements gets them from --index-url
// flags in the input file, and that only makes sense once
const indexUrl = getRegistryUrlVarFromUrls(
'PIP_INDEX_URL',
packageFile?.registryUrls
?.map(buildRegistryUrl)
.filter(isNotNullOrUndefined) ?? [],
);
const extraIndexUrls = getRegistryUrlVarFromUrls(
'PIP_EXTRA_INDEX_URL',
packageFile?.additionalRegistryUrls
?.map(buildRegistryUrl)
.filter(isNotNullOrUndefined) ?? [],
);
return {
haveCredentials: indexUrl.haveCredentials || extraIndexUrls.haveCredentials,
environmentVars: {
...indexUrl.environmentVars,
...extraIndexUrls.environmentVars,
},
};
}

View file

@ -1,3 +1,11 @@
export interface GetRegistryUrlVarsResult {
haveCredentials: boolean;
environmentVars: {
PIP_INDEX_URL?: string;
PIP_EXTRA_INDEX_URL?: string;
};
}
// managers supported by pip-tools Python package // managers supported by pip-tools Python package
export type SupportedManagers = export type SupportedManagers =
| 'pip_requirements' | 'pip_requirements'