mirror of
https://github.com/renovatebot/renovate.git
synced 2025-01-12 06:56:24 +00:00
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:
parent
2610464754
commit
c3e57e7b51
6 changed files with 256 additions and 7 deletions
|
@ -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`.
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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)) {
|
||||||
|
|
|
@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
@ -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'
|
||||||
|
|
Loading…
Reference in a new issue