diff --git a/changelog.d/20250918_172922_yarikoptic_enh_schema_uri.md b/changelog.d/20250918_172922_yarikoptic_enh_schema_uri.md new file mode 100644 index 00000000..c2dfda74 --- /dev/null +++ b/changelog.d/20250918_172922_yarikoptic_enh_schema_uri.md @@ -0,0 +1,10 @@ + + +### Added + +- Schema source reporting in validation output. The validator now includes `schemaSource` field in JSON output and displays schema information (version and source) in console output, helping users understand which schema was used for validation (URL, file path, or version tag). diff --git a/src/setup/loadSchema.test.ts b/src/setup/loadSchema.test.ts index 1dc9b2e6..35ecb47f 100644 --- a/src/setup/loadSchema.test.ts +++ b/src/setup/loadSchema.test.ts @@ -1,36 +1,75 @@ -import { assert, assertObjectMatch } from '@std/assert' -import { loadSchema } from './loadSchema.ts' +import { assertEquals, assert, assertRejects } from '@std/assert' +import { loadSchemaWithSource, loadSchema } from './loadSchema.ts' -Deno.test('schema loader', async (t) => { - await t.step('reads in top level files document', async () => { - const schemaDefs = await loadSchema() - // Look for some stable fields in top level files - if ( - typeof schemaDefs.rules.files.common === 'object' && - schemaDefs.rules.files.common.core !== null - ) { - const top_level = schemaDefs.rules.files.common.core as Record< - string, - any - > - if (top_level.hasOwnProperty('README')) { - assertObjectMatch(top_level.README, { - level: 'recommended', - stem: 'README', - extensions: ['', '.md', '.rst', '.txt'], - }) - } +Deno.test('loadSchemaWithSource function', async (t) => { + await t.step('loadSchema returns just Schema for backward compatibility', async () => { + const schema = await loadSchema() + assert(schema.schema_version) + assert(!('source' in schema)) + }) + + await t.step('loadSchemaWithSource returns SchemaWithSource', async () => { + const result = await loadSchemaWithSource() + assert(result.schema) + assert(result.schema.schema_version) + // When no custom schema is provided, source should be undefined + assertEquals(result.source, undefined) + }) + + await t.step('loadSchemaWithSource throws error when custom URL fails without network', async () => { + // Check if network permission is granted + const netPermission = await Deno.permissions.query({ name: 'net' }) + + if (netPermission.state !== 'granted') { + // Without network permission, it should throw an error + const customUrl = 'https://example.com/custom-schema.json' + await assertRejects( + async () => await loadSchemaWithSource(customUrl), + Error, + 'Failed to load schema from https://example.com/custom-schema.json' + ) } else { - assert(false, 'failed to test schema defs') + // With network permission, test might behave differently + // Skip this specific test when network is available + console.log('Skipping test - network permission granted') } }) - await t.step('loads all schema files', async () => { - const schemaDefs = await loadSchema() - if ( - !(typeof schemaDefs.objects === 'object') || - !(typeof schemaDefs.rules === 'object') - ) { - assert(false, 'failed to load objects/rules') + + await t.step('loadSchemaWithSource with environment variable throws without network', async () => { + // Check if network permission is granted + const netPermission = await Deno.permissions.query({ name: 'net' }) + + const originalEnv = Deno.env.get('BIDS_SCHEMA') + try { + const customUrl = 'https://env-schema.example.com/schema.json' + Deno.env.set('BIDS_SCHEMA', customUrl) + + if (netPermission.state !== 'granted') { + // Without network permission, it should throw + await assertRejects( + async () => await loadSchemaWithSource(), + Error, + 'Failed to load schema from https://env-schema.example.com/schema.json' + ) + } else { + // With network, might still fail but for different reasons (404, etc) + try { + const result = await loadSchemaWithSource() + // If it succeeds, check the result + assert(result.schema) + assert(result.schema.schema_version) + } catch (error) { + // Expected to fail with unreachable URL + assert(error instanceof Error) + assert(error.message.includes('Failed to load schema')) + } + } + } finally { + if (originalEnv !== undefined) { + Deno.env.set('BIDS_SCHEMA', originalEnv) + } else { + Deno.env.delete('BIDS_SCHEMA') + } } }) -}) +}) \ No newline at end of file diff --git a/src/setup/loadSchema.ts b/src/setup/loadSchema.ts index be4d5a61..ddb9706a 100644 --- a/src/setup/loadSchema.ts +++ b/src/setup/loadSchema.ts @@ -3,10 +3,15 @@ import { objectPathHandler } from '../utils/objectPathHandler.ts' import { schema as schemaDefault } from '@bids/schema' import { setCustomMetadataFormats } from '../validators/json.ts' +export interface SchemaWithSource { + schema: Schema + source?: string +} + /** - * Load the schema from the specification + * Load the schema from the specification with source tracking */ -export async function loadSchema(version?: string): Promise { +export async function loadSchemaWithSource(version?: string): Promise { let schemaUrl = version const bidsSchema = typeof Deno !== 'undefined' ? Deno.env.get('BIDS_SCHEMA') : undefined if (bidsSchema !== undefined) { @@ -19,6 +24,7 @@ export async function loadSchema(version?: string): Promise { schemaDefault as object, objectPathHandler, ) as Schema + let actualSchemaSource: string | undefined if (schemaUrl !== undefined) { try { @@ -28,6 +34,7 @@ export async function loadSchema(version?: string): Promise { jsonData as object, objectPathHandler, ) as Schema + actualSchemaSource = schemaUrl } catch (error) { // If a custom schema URL was explicitly provided, fail rather than falling back console.error(error) @@ -39,5 +46,13 @@ export async function loadSchema(version?: string): Promise { } } setCustomMetadataFormats(schema) - return schema + return { schema, source: actualSchemaSource } +} + +/** + * Load the schema from the specification + */ +export async function loadSchema(version?: string): Promise { + const result = await loadSchemaWithSource(version) + return result.schema } diff --git a/src/summary/summary.ts b/src/summary/summary.ts index 13bedaa0..7aa75bbb 100644 --- a/src/summary/summary.ts +++ b/src/summary/summary.ts @@ -67,6 +67,7 @@ export class Summary { secondaryModalitiesCount: Record dataTypes: Set schemaVersion: string + schemaSource?: string constructor() { this.dataProcessed = false this.totalFiles = 0 @@ -153,7 +154,7 @@ export class Summary { } formatOutput(): SummaryOutput { - return { + const output: SummaryOutput = { sessions: Array.from(this.sessions), subjects: Array.from(this.subjects), subjectMetadata: this.subjectMetadata, @@ -166,6 +167,8 @@ export class Summary { pet: this.pet, dataTypes: Array.from(this.dataTypes), schemaVersion: this.schemaVersion, + schemaSource: this.schemaSource, } + return output } } diff --git a/src/types/validation-result.ts b/src/types/validation-result.ts index 77ef1298..d39dde18 100644 --- a/src/types/validation-result.ts +++ b/src/types/validation-result.ts @@ -26,6 +26,7 @@ export interface SummaryOutput { pet: Record dataTypes: string[] schemaVersion: string + schemaSource?: string } /** diff --git a/src/utils/output.ts b/src/utils/output.ts index ec8c6ae8..6b07aa5b 100644 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -156,6 +156,15 @@ function formatSummary(summary: SummaryOutput): string { output.push('') + // Add schema information + output.push(pad + colors.magenta('Schema Information:')) + output.push(pad + 'Version: ' + summary.schemaVersion) + if (summary.schemaSource) { + output.push(pad + 'Source: ' + summary.schemaSource) + } + + output.push('') + //Neurostars message output.push( colors.cyan( diff --git a/src/validators/bids.test.ts b/src/validators/bids.test.ts index ea66b0bf..73fa91f7 100644 --- a/src/validators/bids.test.ts +++ b/src/validators/bids.test.ts @@ -1,4 +1,4 @@ -import { assert } from '@std/assert' +import { assert, assertRejects } from '@std/assert' import { pathsToTree } from '../files/filetree.ts' import { validate } from './bids.ts' @@ -66,4 +66,138 @@ Deno.test('Smoke tests of main validation function', async (t) => { assert(errors.get({ location: '/dataset_description.json' }).length === 0) assert(warnings.get({ location: '/dataset_description.json' }).length === 0) }) + await t.step('Schema source is reported in validation output', async () => { + // Check network permission status + const netPermission = await Deno.permissions.query({ name: 'net' }) + + // Test with default schema (no source should be provided) + let result = await validate(dataset, { + datasetPath: '/dataset', + debug: 'INFO', + ignoreNiftiHeaders: true, + blacklistModalities: [], + datasetTypes: [], + }) + assert(result.summary.schemaVersion) + assert(result.summary.schemaSource === undefined) + + // Test with custom schema URL - should throw error without network + if (netPermission.state !== 'granted') { + await assertRejects( + async () => await validate(dataset, { + datasetPath: '/dataset', + debug: 'INFO', + ignoreNiftiHeaders: true, + blacklistModalities: [], + datasetTypes: [], + schema: 'https://example.com/schema.json', + }), + Error, + 'Failed to load schema' + ) + } else { + // With network, might fail with 404 or succeed with source set + try { + result = await validate(dataset, { + datasetPath: '/dataset', + debug: 'INFO', + ignoreNiftiHeaders: true, + blacklistModalities: [], + datasetTypes: [], + schema: 'https://example.com/schema.json', + }) + // If it works, source should be set + assert(result.summary.schemaVersion) + assert(result.summary.schemaSource === 'https://example.com/schema.json') + } catch (error) { + // Expected to fail with unreachable URL + assert(error instanceof Error) + // The error message should mention the schema loading failure + assert(error.message.includes('Failed to load schema') || error.message.includes('fetch')) + } + } + + // Test with version tag + if (netPermission.state !== 'granted') { + await assertRejects( + async () => await validate(dataset, { + datasetPath: '/dataset', + debug: 'INFO', + ignoreNiftiHeaders: true, + blacklistModalities: [], + datasetTypes: [], + schema: 'v1.9.0', + }), + Error, + 'Failed to load schema' + ) + } else { + // With network, might succeed + try { + result = await validate(dataset, { + datasetPath: '/dataset', + debug: 'INFO', + ignoreNiftiHeaders: true, + blacklistModalities: [], + datasetTypes: [], + schema: 'v1.9.0', + }) + assert(result.summary.schemaVersion) + // If successful, source should be the constructed URL + if (result.summary.schemaSource) { + assert(result.summary.schemaSource.includes('v1.9.0')) + } + } catch (error) { + // Could fail if version doesn't exist + assert(error instanceof Error) + // In CI with network, might have different error messages + console.log('Schema version load error:', error.message) + } + } + + // Test with BIDS_SCHEMA environment variable + const originalEnv = Deno.env.get('BIDS_SCHEMA') + try { + Deno.env.set('BIDS_SCHEMA', 'https://custom-schema.example.com/schema.json') + + if (netPermission.state !== 'granted') { + await assertRejects( + async () => await validate(dataset, { + datasetPath: '/dataset', + debug: 'INFO', + ignoreNiftiHeaders: true, + blacklistModalities: [], + datasetTypes: [], + }), + Error, + 'Failed to load schema' + ) + } else { + // With network, might fail with 404 + try { + result = await validate(dataset, { + datasetPath: '/dataset', + debug: 'INFO', + ignoreNiftiHeaders: true, + blacklistModalities: [], + datasetTypes: [], + }) + assert(result.summary.schemaVersion) + if (result.summary.schemaSource) { + assert(result.summary.schemaSource === 'https://custom-schema.example.com/schema.json') + } + } catch (error) { + assert(error instanceof Error) + // The error message should indicate a schema loading issue + console.log('Schema env var load error:', error.message) + } + } + } finally { + if (originalEnv !== undefined) { + Deno.env.set('BIDS_SCHEMA', originalEnv) + } else { + Deno.env.delete('BIDS_SCHEMA') + } + } + }) }) diff --git a/src/validators/bids.ts b/src/validators/bids.ts index 32e57efb..cdeea968 100644 --- a/src/validators/bids.ts +++ b/src/validators/bids.ts @@ -6,7 +6,7 @@ import type { GenericSchema } from '../types/schema.ts' import type { ValidationResult } from '../types/validation-result.ts' import { applyRules } from '../schema/applyRules.ts' import { walkFileTree } from '../schema/walk.ts' -import { loadSchema } from '../setup/loadSchema.ts' +import { loadSchemaWithSource } from '../setup/loadSchema.ts' import type { Config, ValidatorOptions } from '../setup/options.ts' import { Summary } from '../summary/summary.ts' import { filenameIdentify } from './filenameIdentify.ts' @@ -46,8 +46,10 @@ export async function validate( config?: Config, ): Promise { const summary = new Summary() - const schema = await loadSchema(options.schema) + const schemaResult = await loadSchemaWithSource(options.schema) + const schema = schemaResult.schema summary.schemaVersion = schema.schema_version + summary.schemaSource = schemaResult.source /* There should be a dataset_description in root, this will tell us if we * are dealing with a derivative dataset