diff --git a/lib/modules/datasource/conda/index.spec.ts b/lib/modules/datasource/conda/index.spec.ts index f4843f4d5aa..c3b49e1d8cf 100644 --- a/lib/modules/datasource/conda/index.spec.ts +++ b/lib/modules/datasource/conda/index.spec.ts @@ -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 diff --git a/lib/modules/datasource/conda/schema.ts b/lib/modules/datasource/conda/schema.ts index 37e36fdae3e..5b75e703ff5 100644 --- a/lib/modules/datasource/conda/schema.ts +++ b/lib/modules/datasource/conda/schema.ts @@ -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(), }); diff --git a/lib/modules/datasource/pypi/index.ts b/lib/modules/datasource/pypi/index.ts index bde8252aec9..66d8d23d9fb 100644 --- a/lib/modules/datasource/pypi/index.ts +++ b/lib/modules/datasource/pypi/index.ts @@ -156,6 +156,7 @@ export class PypiDatasource extends Datasource { const lower = name.toLowerCase(); if ( + projectUrl && !dependency.sourceUrl && (lower.startsWith('repo') || lower === 'code' || diff --git a/lib/modules/datasource/pypi/schema.spec.ts b/lib/modules/datasource/pypi/schema.spec.ts index 6d6dc65a433..50f8ac464a2 100644 --- a/lib/modules/datasource/pypi/schema.spec.ts +++ b/lib/modules/datasource/pypi/schema.spec.ts @@ -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, }, @@ -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', () => { @@ -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, ); diff --git a/lib/modules/datasource/pypi/schema.ts b/lib/modules/datasource/pypi/schema.ts index 3051016f0da..ac1211730a6 100644 --- a/lib/modules/datasource/pypi/schema.ts +++ b/lib/modules/datasource/pypi/schema.ts @@ -1,9 +1,10 @@ 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; @@ -11,9 +12,9 @@ export type PypiRelease = z.infer; 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(), diff --git a/lib/util/schema-utils/index.spec.ts b/lib/util/schema-utils/index.spec.ts index d5f376d0e05..e526922c726 100644 --- a/lib/util/schema-utils/index.spec.ts +++ b/lib/util/schema-utils/index.spec.ts @@ -10,6 +10,7 @@ import { LooseRecord, MultidocYaml, NotCircular, + Nullish, Toml, UtcDate, Yaml, @@ -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') })); diff --git a/lib/util/schema-utils/index.ts b/lib/util/schema-utils/index.ts index 6dc626f89dd..5c4356456f9 100644 --- a/lib/util/schema-utils/index.ts +++ b/lib/util/schema-utils/index.ts @@ -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: Schema, +): z.ZodOptional | undefined>> { + return schema + .nullable() + .transform((value) => value ?? undefined) + .optional(); +} + export const Json = z.string().transform((str, ctx): unknown => { try { return JSON.parse(str);