mirror of
https://github.com/renovatebot/renovate.git
synced 2026-11-01 02:00:10 +00:00
2606ae2bd3
* feat(pypi): add support for JSON-based Simple API (PEP 691)
* Fix some stuff
* Clean some things up
* Fix coverage
* Simplify JSON parse
* Remove useless tests
* Align terminology
* Incorporate some changes from PR #44182
Co-authored-by: Andreas Hippler <andreas.hippler@goto.com>
* Use isNonEmptyString to filter requires_python
* Address review feedback
* Warn once actually
* fixup! review comments
Co-authored-by: Claude Sonnet 5 <jamie.tanna+claude-code@mend.io>
* fixup! fixup! review comments
Co-authored-by: Claude Opus 5 <jamie.tanna+claude-code@mend.io>
* fixup! review comments
As the list of status codes we retry on was based on an unverified
claim that Artifactory, Nexus and devpi answer a rejected `Accept`
header with `400`/`403`/`415`, we should only retry on `406`, which is
what RFC 9110 specifies for a request whose `Accept` cannot be
satisfied.
Our `Accept` header ends with `text/html; q=0.01`, so any conforming
registry can satisfy it, `415` refers to the request body's
`Content-Type` rather than `Accept`, and retrying on `403` doubles the
requests for a registry we're not authenticated against.
The unwrapping of `ExternalHostError` is unaffected, as that's needed
for the `abortOnError` case regardless of which status codes we retry.
Co-Authored-By: Claude Opus 5 <claude-code@jamietanna.co.uk>
* fixup! review comments
As the note about `upload-time` sat in the paragraph about the Simple
API, it read as if `minimumReleaseAge` needs a registry which supports
the JSON-based Simple API (PEP 691).
That's only true of the Simple API. The JSON API has always exposed
`upload_time`, and as the default registry of `https://pypi.org/pypi/`
doesn't end in `/simple/`, that's the lookup most users get, so
`minimumReleaseAge` already works for them.
We can split the two apart, so that the guarantee for each API is
stated separately.
Co-Authored-By: Claude Opus 5 <claude-code@jamietanna.co.uk>
* reword
* fixup! review comments
As the JSON-based Simple API's release timestamps were only covered by
hand-written responses, we didn't have the equivalent of the real world
coverage added for the JSON API in
e942788539.
We can add a `numpy` fixture from PyPI's JSON-based Simple API, trimmed
to 1.5.0 and 1.5.1, so we assert against a response a registry has
actually served. 1.5.1 is the interesting case, as it was released in
2010 but gained a wheel in 2014 which PyPI lists first.
This also covers the `upload-time` format the Simple API uses, of
`2010-11-18T14:16:58.801332Z`, rather than the JSON API's
`2010-11-18T14:16:58`, so that we know `asTimestamp` normalises both.
Unlike the JSON API, there's no previous behaviour to characterise
first, as before these changes the Simple API exposed no timestamps at
all.
Co-Authored-By: Claude Opus 5 <claude-code@jamietanna.co.uk>
* fixup! fixup! review comments
* fixups
* fixups
Co-authored-by: Claude Opus 5 <jamie.tanna+claude-code@mend.io>
* fixup! comments
Co-authored-by: Claude Sonnet 5 <jamie.tanna+claude-code@mend.io>
---------
Co-authored-by: Andreas Hippler <andreas.hippler@goto.com>
Co-authored-by: Jamie Tanna <jamie.tanna@mend.io>
Co-authored-by: Claude Sonnet 5 <jamie.tanna+claude-code@mend.io>
Co-authored-by: Claude Opus 5 <claude-code@jamietanna.co.uk>
212 lines
5.9 KiB
TypeScript
212 lines
5.9 KiB
TypeScript
import { PypiResponse, PypiSimpleFile, PypiSimpleResponse } from './schema.ts';
|
|
|
|
describe('modules/datasource/pypi/schema', () => {
|
|
describe('PypiResponseSchema', () => {
|
|
it('parses a valid PyPI JSON response', () => {
|
|
const input = {
|
|
info: {
|
|
name: 'requests',
|
|
home_page: 'https://requests.readthedocs.io',
|
|
project_urls: {
|
|
Homepage: 'https://requests.readthedocs.io',
|
|
},
|
|
},
|
|
releases: {
|
|
'2.28.0': [
|
|
{
|
|
requires_python: '>=3.7',
|
|
upload_time: '2022-06-09T11:11:55',
|
|
yanked: false,
|
|
},
|
|
],
|
|
'2.27.0': [
|
|
{
|
|
requires_python: null,
|
|
upload_time: '2021-11-16T12:00:00',
|
|
yanked: true,
|
|
},
|
|
],
|
|
},
|
|
};
|
|
const result = PypiResponse.parse(input);
|
|
expect(result?.info?.name).toBe('requests');
|
|
expect(result?.releases?.['2.28.0']?.[0].requires_python).toBe('>=3.7');
|
|
expect(result?.releases?.['2.27.0']?.[0].requires_python).toBeUndefined();
|
|
});
|
|
|
|
it('normalizes null home_page to undefined', () => {
|
|
const input = {
|
|
info: { name: 'pkg', home_page: null },
|
|
releases: {},
|
|
};
|
|
const result = PypiResponse.parse(input);
|
|
expect(result?.info?.home_page).toBeUndefined();
|
|
});
|
|
|
|
it('parses a PyPI JSON response with a null home_page', () => {
|
|
const input = {
|
|
info: {
|
|
name: 'mypackage',
|
|
home_page: null,
|
|
project_urls: {
|
|
Repository: 'https://github.com/example/mypackage',
|
|
},
|
|
},
|
|
releases: {},
|
|
};
|
|
const result = PypiResponse.parse(input);
|
|
expect(result?.info?.home_page).toBeUndefined();
|
|
expect(result?.info?.project_urls?.Repository).toBe(
|
|
input.info.project_urls.Repository,
|
|
);
|
|
});
|
|
|
|
it('parses a PyPI JSON response with a missing home_page', () => {
|
|
const input = {
|
|
info: {
|
|
name: 'mypackage',
|
|
},
|
|
releases: {},
|
|
};
|
|
const result = PypiResponse.parse(input);
|
|
expect(result?.info?.home_page).toBeUndefined();
|
|
expect(result?.info?.name).toBe(input.info.name);
|
|
});
|
|
|
|
it('throws for genuinely invalid input (no error swallowing)', () => {
|
|
expect(() => PypiResponse.parse('not-an-object')).toThrow(
|
|
'Invalid input: expected object, received string',
|
|
);
|
|
});
|
|
|
|
it('ignores extra fields in info', () => {
|
|
const input = {
|
|
info: {
|
|
name: 'mypackage',
|
|
home_page: 'https://example.com',
|
|
author: 'Someone', // extra field
|
|
license: 'MIT', // extra field
|
|
},
|
|
releases: {},
|
|
};
|
|
const result = PypiResponse.parse(input);
|
|
expect(result?.info?.name).toBe('mypackage');
|
|
});
|
|
});
|
|
|
|
describe('PypiSimpleFile', () => {
|
|
it('parses a valid file entry', () => {
|
|
const input = {
|
|
filename: 'package-1.0.0.tar.gz',
|
|
'requires-python': '>=3.7',
|
|
yanked: false,
|
|
'upload-time': '2023-01-01T00:00:00.000Z',
|
|
};
|
|
|
|
const result = PypiSimpleFile.parse(input);
|
|
|
|
expect(result.filename).toBe('package-1.0.0.tar.gz');
|
|
expect(result.requires_python).toBe('>=3.7');
|
|
expect(result.yanked).toBe(false);
|
|
expect(result.upload_time).toBe('2023-01-01T00:00:00.000Z');
|
|
});
|
|
|
|
it('handles missing optional fields', () => {
|
|
const input = {
|
|
filename: 'package-1.0.0.tar.gz',
|
|
};
|
|
|
|
const result = PypiSimpleFile.parse(input);
|
|
|
|
expect(result.filename).toBe('package-1.0.0.tar.gz');
|
|
expect(result.requires_python).toBeUndefined();
|
|
expect(result.yanked).toBe(false);
|
|
expect(result.upload_time).toBeUndefined();
|
|
});
|
|
|
|
it('normalizes null optional fields to undefined', () => {
|
|
const input = {
|
|
filename: 'package-1.0.0.tar.gz',
|
|
'requires-python': null,
|
|
'upload-time': null,
|
|
};
|
|
|
|
const result = PypiSimpleFile.parse(input);
|
|
|
|
expect(result.requires_python).toBeUndefined();
|
|
expect(result.upload_time).toBeUndefined();
|
|
});
|
|
|
|
it('treats yanked string reason as truthy', () => {
|
|
const input = {
|
|
filename: 'package-1.0.0.tar.gz',
|
|
yanked: 'security vulnerability',
|
|
};
|
|
|
|
const result = PypiSimpleFile.parse(input);
|
|
|
|
expect(result.yanked).toBe(true);
|
|
});
|
|
|
|
it('handles yanked as true', () => {
|
|
const input = {
|
|
filename: 'package-1.0.0.tar.gz',
|
|
yanked: true,
|
|
};
|
|
|
|
const result = PypiSimpleFile.parse(input);
|
|
|
|
expect(result.yanked).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('PypiSimpleResponse', () => {
|
|
it('parses a valid response with files', () => {
|
|
const input = {
|
|
files: [
|
|
{
|
|
filename: 'package-1.0.0.tar.gz',
|
|
'requires-python': '>=3.7',
|
|
yanked: false,
|
|
'upload-time': '2023-01-01T00:00:00.000Z',
|
|
},
|
|
{
|
|
filename: 'package-2.0.0.tar.gz',
|
|
yanked: true,
|
|
},
|
|
],
|
|
};
|
|
|
|
const result = PypiSimpleResponse.parse(input);
|
|
|
|
expect(result.files).toHaveLength(2);
|
|
expect(result.files[0].filename).toBe('package-1.0.0.tar.gz');
|
|
expect(result.files[1].yanked).toBe(true);
|
|
});
|
|
|
|
it('filters out invalid file entries via LooseArray', () => {
|
|
const input = {
|
|
files: [
|
|
{ filename: 'package-1.0.0.tar.gz' },
|
|
{ notAFilename: 'invalid' },
|
|
{ filename: 'package-2.0.0.tar.gz' },
|
|
],
|
|
};
|
|
|
|
const result = PypiSimpleResponse.parse(input);
|
|
|
|
expect(result.files).toHaveLength(2);
|
|
expect(result.files[0].filename).toBe('package-1.0.0.tar.gz');
|
|
expect(result.files[1].filename).toBe('package-2.0.0.tar.gz');
|
|
});
|
|
|
|
it('parses an empty files array', () => {
|
|
const input = { files: [] };
|
|
|
|
const result = PypiSimpleResponse.parse(input);
|
|
|
|
expect(result.files).toHaveLength(0);
|
|
});
|
|
});
|
|
});
|