Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions lib/modules/datasource/conda/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,27 @@ describe('modules/datasource/conda/index', () => {
expect(res).toBeNull();
});

it('handles null html_url and dev_url without throwing', async () => {
const packageName = 'pytest';
httpMock
.scope('https://api.anaconda.org/package/conda-forge')
.get(`/${packageName}`)
.reply(200, {
html_url: null,
dev_url: null,
versions: ['1.0.0'],
files: [],
});
const res = await getPkgReleases({
registryUrls: ['https://api.anaconda.org/package/conda-forge'],
datasource,
packageName,
});
expect(res).toMatchObject({ releases: [{ version: '1.0.0' }] });
expect(res?.homepage).toBeUndefined();
expect(res?.sourceUrl).toBeUndefined();
});

it('supports multiple custom datasource urls', async () => {
const packageName = 'pytest';
httpMock
Expand Down
8 changes: 4 additions & 4 deletions lib/modules/datasource/conda/schema.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { z } from 'zod/v4';
import { LooseArray } from '../../../util/schema-utils/index.ts';
import { LooseArray, Nullish } from '../../../util/schema-utils/index.ts';

export const CondaFile = z.object({
version: z.string(),
upload_time: z.string().optional(),
upload_time: Nullish(z.string()),
});

export const CondaPackage = z.object({
html_url: z.string().optional(),
dev_url: z.string().optional(),
html_url: Nullish(z.string()),
dev_url: Nullish(z.string()),
files: LooseArray(CondaFile).optional(),
versions: z.array(z.string()).optional(),
});
Expand Down
1 change: 1 addition & 0 deletions lib/modules/datasource/pypi/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ export class PypiDatasource extends Datasource {
const lower = name.toLowerCase();

if (
projectUrl &&
!dependency.sourceUrl &&
(lower.startsWith('repo') ||
lower === 'code' ||
Expand Down
15 changes: 12 additions & 3 deletions lib/modules/datasource/pypi/schema.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ describe('modules/datasource/pypi/schema', () => {
],
'2.27.0': [
{
requires_python: null, // null is allowed
requires_python: null,
upload_time: '2021-11-16T12:00:00',
yanked: true,
},
Expand All @@ -31,7 +31,16 @@ describe('modules/datasource/pypi/schema', () => {
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).toBeNull();
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', () => {
Expand All @@ -46,7 +55,7 @@ describe('modules/datasource/pypi/schema', () => {
releases: {},
};
const result = PypiResponse.parse(input);
expect(result?.info?.home_page).toBeNull();
expect(result?.info?.home_page).toBeUndefined();
expect(result?.info?.project_urls?.Repository).toBe(
input.info.project_urls.Repository,
);
Expand Down
13 changes: 7 additions & 6 deletions lib/modules/datasource/pypi/schema.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
import { z } from 'zod/v4';
import { Nullish } from '../../../util/schema-utils/index.ts';

export const PypiRelease = z.object({
requires_python: z.string().nullish(),
upload_time: z.string().optional(),
yanked: z.boolean().optional(),
requires_python: Nullish(z.string()),
upload_time: Nullish(z.string()),
yanked: Nullish(z.boolean()).default(false),
});

export type PypiRelease = z.infer<typeof PypiRelease>;

export const PypiResponse = z.object({
info: z
.object({
name: z.string().optional(),
home_page: z.string().optional().nullish(),
project_urls: z.record(z.string(), z.string()).optional(),
name: Nullish(z.string()),
home_page: Nullish(z.string()),
project_urls: z.record(z.string(), Nullish(z.string())).optional(),
})
.optional(),
releases: z.record(z.string(), z.array(PypiRelease)).optional(),
Expand Down
23 changes: 23 additions & 0 deletions lib/util/schema-utils/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
LooseRecord,
MultidocYaml,
NotCircular,
Nullish,
Toml,
UtcDate,
Yaml,
Expand Down Expand Up @@ -141,6 +142,28 @@ describe('util/schema-utils/index', () => {
});
});

describe('Nullish', () => {
it('converts null to undefined', () => {
const s = z.object({ a: Nullish(z.string()) });
expect(s.parse({ a: null })).toEqual({});
});

it('converts undefined to undefined (key absent)', () => {
const s = z.object({ a: Nullish(z.string()) });
expect(s.parse({})).toEqual({});
});

it('preserves valid string values', () => {
const s = z.object({ a: Nullish(z.string()) });
expect(s.parse({ a: 'hello' })).toEqual({ a: 'hello' });
});

it('rejects wrong types', () => {
const s = Nullish(z.string());
expect(() => s.parse(42)).toThrow();
});
});

describe('Json', () => {
it('parses json', () => {
const Schema = Json.pipe(z.object({ foo: z.literal('bar') }));
Expand Down
14 changes: 14 additions & 0 deletions lib/util/schema-utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,20 @@ export function LooseRecord<
});
}

/**
* Accepts `null`, `undefined`, or an absent key and normalizes all to
* `undefined`. Keeps the output type as `T | undefined` (never `null`), so
* assignment into non-nullable targets needs no `?? undefined` coalescing.
*/
export function Nullish<Schema extends z.ZodTypeAny>(
schema: Schema,
): z.ZodOptional<z.ZodType<z.output<Schema> | undefined>> {
return schema
.nullable()
.transform((value) => value ?? undefined)
.optional();
}

export const Json = z.string().transform((str, ctx): unknown => {
try {
return JSON.parse(str);
Expand Down