XSV (CSV/TSV) Schema & Pipeline Specification¶
This document specifies how an XSV JSON Schema (Draft 2020-12 or the KBase Draft 2020-12 XSV metaschema)
and the XSV ingestion pipeline (cdm_data_loaders.pipelines.xsv and cdm_data_loaders.readers.jsonschema_xsv)
determine:
- Schema validation and
x-xsv-configparser configuration (validate_jsonschema,resolve_xsv_parsing_config). - The PyArrow
pa.Schemaproduced byconvert_xsv_schema_to_pyarrow. - Two-pass
qsvcleaning, null replacement, and row validation (clean_validate_file). - DuckDB typed CSV reading and PyArrow table coercion (
resolve_duckdb_column_config,read_validated_rows). - Pipeline execution, dead-letter routing (
_rejected), and table transforms (_process_xsv_file,run_xsv_pipeline). - Errors (
XsvSchemaErrorsubclasses) raised for invalid or unsupported schemas.
| Property | Value |
|---|---|
| Base Pipeline Family | xsv |
| Base CLI Entry Point | xsv |
| Base Settings Class | cdm_data_loaders.pipelines.xsv.settings.XsvIngestSettings |
| Pipeline Runner | cdm_data_loaders.pipelines.xsv.pipeline.run_xsv_pipeline |
| Preset Subclasses Covered | — |
| Specification Directory | specs/pipelines/xsv/ |
Running Smoke Tests Across Environments¶
# Run all base and preset smoke tests for xsv across built-in environments
uv run pytest tests/cdm_data_loaders/pipelines/test_pipeline_smoke_spec.py --smoke-pipeline xsv
# Run against a custom environment directory (config.toml, secrets.toml, settings.toml)
uv run pytest tests/cdm_data_loaders/pipelines/test_pipeline_smoke_spec.py \
--smoke-pipeline xsv \
--dlt-config-dir /path/to/env_dlt_config \
--settings-file /path/to/env_dlt_config/settings.toml
1. Conversion & Pipeline Rules¶
1.1 Schema Validation and x-xsv-config Resolution¶
- R-XSV-DIALECT: The root schema must declare
"$schema": "https://json-schema.org/draft/2020-12/schema"or the KBase XSV metaschema"https://kbase.github.io/cdm-data-loaders/schemas/xsv_config/0.1.0/schema.json"; missing or unsupported$schemaURIs raise E1 (UnsupportedXsvDialectError).
Cases: spec/01_scalars_nullability_and_column_order, spec/02_delimiters_comments_quotes_and_escapes, spec/03_missing_header_and_defs_ref
- R-XSV-REQUIRED-COLS: Top-level
requiredmust be a non-empty list of column header strings declaring the ordered columns of the XSV file; a missing or emptyrequiredlist raises E2 (MissingRequiredColumnsError).
Cases: spec/01_scalars_nullability_and_column_order, spec/02_delimiters_comments_quotes_and_escapes, spec/03_missing_header_and_defs_ref, spec/04_null_regex_and_null_cols
- R-XSV-METASCHEMA: Schemas declaring
"https://kbase.github.io/cdm-data-loaders/schemas/xsv_config/0.1.0/schema.json"lazily load the custom metaschema (from bundledschemas/xsv_config/0.1.0/schema.jsonor its published URL), validate it against Draft 2020-12, and validate the pipeline schema against it (get_xsv_meta_schema). Standard Draft 2020-12 schemas containing anx-xsv-configblock validate that block against thex-xsv-configsubschema (get_xsv_config_schema). Invalid JSON Schema keywords or malformedx-xsv-configproperties raise E3 (InvalidXsvSchemaDefinitionError).
Cases: spec/01_scalars_nullability_and_column_order, spec/02_delimiters_comments_quotes_and_escapes, spec/03_missing_header_and_defs_ref, spec/04_null_regex_and_null_cols
- R-XSV-CONFIG:
resolve_xsv_parsing_configmapsx-xsv-configkeys (after stripping thex-prefix and replacing-with_) toCleanerValidatorArgsfields per the table below. Schemas withoutx-xsv-configfall back toCleanerValidatorArgsdefaults (delimiter="\t",comment_char="#",missing_header=False,quote=None,escape=None,null_regex=None,null_regex_cols=None).x-quoting-policyis validated as output metadata and omitted from reader kwargs.
Cases: spec/01_scalars_nullability_and_column_order, spec/02_delimiters_comments_quotes_and_escapes, spec/03_missing_header_and_defs_ref, spec/04_null_regex_and_null_cols
x-xsv-config key |
JSON Schema constraint | CleanerValidatorArgs field |
Default |
|---|---|---|---|
x-has-header |
{"type": "boolean"} |
missing_header (inverted: not x-has-header) |
False (x-has-header=True) |
x-delimiter |
{"type": "string", "minLength": 1, "maxLength": 1} |
delimiter |
'\t' |
x-comment-char |
{"type": "string", "minLength": 1, "maxLength": 1} |
comment_char |
'#' |
x-quote |
{"type": "string", "minLength": 1, "maxLength": 1} |
quote |
None |
x-escape |
{"type": "string", "minLength": 1, "maxLength": 1} |
escape |
None |
x-quoting-policy |
{"type": "string", "enum": ["all", "necessary", "nonnumeric", "never"]} |
(output metadata only; not passed to CleanerValidatorArgs) |
— |
x-null-regex |
{"type": "string"} |
null_regex |
None |
x-null-cols |
{"type": "array", "items": {"type": "string"}, "minItems": 1} |
null_regex_cols |
None |
1.2 PyArrow Schema Generation¶
- R-XSV-DEREF:
convert_xsv_schema_to_pyarrowresolves all local$refpointers and mergesallOfsubschemas viadereference_schemabefore inspecting properties. An unresolvable$refraises E4 (UnresolvableXsvRefError), and a non-struct root or unsupported column construct raises E5 (UnsupportedXsvArrowConstructError).
Cases: spec/03_missing_header_and_defs_ref
- R-XSV-COL-ORDER: Top-level
pa.Schemafields are emitted in the exact order of top-levelrequired, followed by any extrapropertiesnot listed inrequiredin their declaration order.
Cases: spec/01_scalars_nullability_and_column_order
- R-XSV-UNLISTED-COL: Any column listed in top-level
requiredthat is absent frompropertiesdefaults to{"type": ["string", "null"]}(pa.field(col, pa.string(), nullable=True)).
Cases: spec/01_scalars_nullability_and_column_order
- R-XSV-NULLABILITY: Top-level
requireddefines the file's column headers, not value non-nullability. A column isnullable=Falsein PyArrow if and only if it is inrequiredand its property schema does not permitnull(via"null"intype, a nullableanyOf/oneOfbranch, or an untyped{}schema). Extrapropertiesabsent fromrequiredarenullable=True.
Cases: spec/01_scalars_nullability_and_column_order, spec/03_missing_header_and_defs_ref, spec/04_null_regex_and_null_cols
- R-XSV-SCALAR-MAP: Column schemas convert to PyArrow types via
JSONSchemaToPyArrowper the table below. Every scalar field carriesmetadata["jsonschema_type"], and a non-emptydescriptionis stored inmetadata["comment"].
Cases: spec/01_scalars_nullability_and_column_order, spec/03_missing_header_and_defs_ref
| Column JSON Schema | DuckDB CSV dtype |
force_not_null |
PyArrow DataType |
Field.nullable |
|---|---|---|---|---|
{"type": "boolean"} |
BOOLEAN |
False |
pa.bool_() |
False |
{"type": ["boolean", "null"]} |
BOOLEAN |
False |
pa.bool_() |
True |
{"type": "integer"} |
BIGINT |
False |
pa.int64() |
False |
{"type": ["integer", "null"]} |
BIGINT |
False |
pa.int64() |
True |
{"type": "number"} |
DOUBLE |
False |
pa.float64() |
False |
{"type": ["number", "null"]} |
DOUBLE |
False |
pa.float64() |
True |
{"type": "string"} |
VARCHAR |
True |
pa.string() |
False |
{"type": ["string", "null"]} |
VARCHAR |
False |
pa.string() |
True |
{"type": "integer", "minimum": 0, "maximum": 1000} |
BIGINT |
False |
pa.int64() |
False |
{"type": "number", "multipleOf": 0.01} |
DOUBLE |
False |
pa.float64() |
False |
{"type": "string", "format": "date"} |
DATE |
False |
pa.date32() |
False |
{"type": "string", "format": "date-time"} |
VARCHAR |
True |
pa.string() |
False |
{"type": "string", "pattern": "^G[0-9]{4}$"} |
VARCHAR |
True |
pa.string() |
False |
{"anyOf": [{"type": "string"}, {"type": "null"}]} |
VARCHAR |
False |
pa.string() |
True |
(column in required, omitted from properties) |
VARCHAR (auto) |
False |
pa.string() |
True |
1.3 Two-Pass qsv Cleaning and Validation¶
- R-XSV-HEADER: When
x-has-headerisfalse(missing_header=True),clean_validate_fileprepends a synthetic header line generated fromrequired_colsjoined by the configureddelimiterbefore runningqsv. Whenx-has-headeristrue(default), the file's first non-comment row serves as the header.
Cases: spec/03_missing_header_and_defs_ref
- R-XSV-FIRST-PASS: First-pass validation (
run_qsv_validate(..., first_pass=True)) runs beforeqsv inputand checks structural column count againstgenerate_first_pass_schema(where everyrequiredcolumn is{"type": ["string", "null"]}) using--no-format-validation --split-ragged --trimandQSV_COMMENT_CHAR=<comment_char>, routing ragged rows to.invalidand promoting surviving.validrows to the cleaning step.
Cases: spec/01_scalars_nullability_and_column_order, spec/03_missing_header_and_defs_ref, spec/04_null_regex_and_null_cols, spec/05_partial_and_total_validation_rejections, spec/06_multi_file_glob_and_subdirs
- R-XSV-CLEAN-INPUT:
run_qsv_inputrunsqsv input --trim-headers --trim-fields --encoding-errors strict --delimiter <delimiter>(plus--quoteand--escapewhen set) withQSV_COMMENT_CHAR=<comment_char>, stripping comment lines, trimming header and field whitespace, and normalizing quotes and escapes.
Cases: spec/01_scalars_nullability_and_column_order, spec/02_delimiters_comments_quotes_and_escapes, spec/03_missing_header_and_defs_ref
- R-XSV-NULL-REPLACE: When
x-null-regexis set,run_qsv_null_replacementinvokesqsv replace --not-oneto replace matching cell values with""across all columns, or only across--select <x-null-cols>whenx-null-colsis specified.
Cases: spec/04_null_regex_and_null_cols
- R-XSV-SECOND-PASS: Second-pass validation (
run_qsv_validate(..., first_pass=False)) validates normalized rows against the full schema (rewriting the KBase XSV metaschema URI to standard Draft 2020-12 for theqsvbinary). If all rows are valid,--valid-outputis used directly; if some rows fail (returncode != 0) but.validretains at least one data row, the surviving rows are promoted and anErrorRecordis appended; if no data rows survive,clean_validate_filereturnsNoneand records anErrorRecord.
Cases: spec/01_scalars_nullability_and_column_order, spec/02_delimiters_comments_quotes_and_escapes, spec/03_missing_header_and_defs_ref, spec/04_null_regex_and_null_cols, spec/05_partial_and_total_validation_rejections, spec/06_multi_file_glob_and_subdirs
1.4 DuckDB Reading and PyArrow Coercion¶
- R-XSV-DUCKDB-TYPES:
resolve_duckdb_column_configmaps each field inpa_schemato aDuckDBPyTypeviaduckdb.from_arrow(and unlisted columns ->VARCHARviaauto_type_candidates=["VARCHAR"]). Non-nullable string columns are listed inforce_not_nullso empty cells remain""rather thanNone.
Cases: spec/01_scalars_nullability_and_column_order, spec/02_delimiters_comments_quotes_and_escapes, spec/03_missing_header_and_defs_ref, spec/04_null_regex_and_null_cols, spec/05_partial_and_total_validation_rejections, spec/06_multi_file_glob_and_subdirs, spec/07_table_transforms_and_output_schemas
- R-XSV-ARROW-CAST: When no table transform is registered,
read_validated_rows(..., use_pyarrow=True, pa_schema=pa_schema)converts each non-empty DuckDBRecordBatchto apa.Table, appends typed null columns for anypa_schemafields missing from the file, selectspa_schema.namesin schema order, and casts the table topa_schema. Empty or header-only files yield no batches.
Cases: spec/01_scalars_nullability_and_column_order, spec/02_delimiters_comments_quotes_and_escapes, spec/03_missing_header_and_defs_ref, spec/04_null_regex_and_null_cols, spec/05_partial_and_total_validation_rejections, spec/06_multi_file_glob_and_subdirs
1.5 Pipeline Execution, Dead-Letter Routing, and Transforms¶
- R-XSV-GLOB:
run_xsv_pipelinediscovers files insettings.input_dirmatchingsettings.file_glob(including nested subdirectories) and processes each file independently via_process_xsv_file.
Cases: spec/01_scalars_nullability_and_column_order, spec/06_multi_file_glob_and_subdirs
- R-XSV-REJECTED: Every
ErrorRecordrecorded while cleaning or validating a file is emitted to the_rejectedtable (REJECTED_PYARROW_SCHEMA) viaErrorRecord.to_rejected_rowwithtable_name=settings.table_name,source_fileset to the file path relative toinput_dir,line_no=None,raw_record=None, anderror_detailset to theqsvdiagnostic message.
Cases: spec/05_partial_and_total_validation_rejections, spec/06_multi_file_glob_and_subdirs
- R-XSV-TRANSFORMS: When
settings.table_transformsdefines a callable forsettings.table_name,read_validated_rowsyields Python row dicts (use_pyarrow=False) routed throughroute_and_coerce_recordandDictBuffer. The transform may mutate rows, drop rows (None/[]), fan out records across multiple tables, and coerce each output table againstsettings.output_schemas(or the default XSV PyArrow schema).
Cases: spec/07_table_transforms_and_output_schemas
2. Case Coverage Matrix¶
3. Base Pipeline Specification Cases (xsv / spec/)¶
Shared Fixture Module (specs/pipelines/xsv/transforms.py)
"""Table transforms and PyArrow output schemas for XSV specification case 07."""
from typing import Any
import pyarrow as pa
READINGS_SCHEMA = pa.schema(
[
pa.field("sensor_id", pa.string(), nullable=False),
pa.field("celsius", pa.float64(), nullable=False),
pa.field("kelvin", pa.float64(), nullable=False),
]
)
ALERTS_SCHEMA = pa.schema(
[
pa.field("sensor_id", pa.string(), nullable=False),
pa.field("celsius", pa.float64(), nullable=False),
]
)
def transform_readings(row: dict[str, Any]) -> dict[str, list[dict[str, Any]]]:
"""Filter dropped sensor rows, enrich with Kelvin temperature, and fan out high-temperature alerts."""
if row["status"] == "drop":
return {}
sensor_id = str(row["sensor_id"]).upper()
celsius = float(row["celsius"])
routed: dict[str, list[dict[str, Any]]] = {
"readings": [
{
"sensor_id": sensor_id,
"celsius": celsius,
"kelvin": round(celsius + 273.15, 2),
}
]
}
if celsius >= 100.0: # noqa: PLR2004
routed["alerts"] = [{"sensor_id": sensor_id, "celsius": celsius}]
return routed
TABLE_TRANSFORMS = {"readings": transform_readings}
OUTPUT_SCHEMAS = {"readings": READINGS_SCHEMA, "alerts": ALERTS_SCHEMA}
Scalars, Nullability, Column Ordering, and Unlisted Columns (spec/01_scalars_nullability_and_column_order)¶
Rules: R-XSV-DIALECT, R-XSV-REQUIRED-COLS, R-XSV-METASCHEMA, R-XSV-CONFIG, R-XSV-COL-ORDER, R-XSV-UNLISTED-COL, R-XSV-NULLABILITY, R-XSV-SCALAR-MAP, R-XSV-CLEAN-INPUT, R-XSV-FIRST-PASS, R-XSV-SECOND-PASS, R-XSV-DUCKDB-TYPES, R-XSV-ARROW-CAST, R-XSV-GLOB
- The schema omits
x-xsv-config, soCleanerValidatorArgsdefaults apply (delimiter="\t",comment_char="#",missing_header=False). propertiesdeclaresscorebeforeid, but top-levelrequiredorders the output columns starting withid,count,rank32,score, ...unlisted_tagis listed inrequiredbut absent fromproperties, so it defaults topa.field("unlisted_tag", pa.string(), nullable=True).extra_colis declared inpropertiesbut absent fromrequiredand the TSV file; it is appended at the end ofpa.Schema(nullable=True) and filled withnullvalues on read.status({"type": "string"}) is non-nullable (nullable=False,force_not_null), whileopt_note({"type": ["string", "null"]}) andunlisted_tagallownulland becomeNonewhen empty.
Schema (schema.json)¶
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "ScalarsNullabilityAndOrder",
"type": "object",
"required": [
"id",
"count",
"rank32",
"score",
"price",
"active",
"created_date",
"updated_at",
"status",
"opt_note",
"unlisted_tag"
],
"properties": {
"score": {
"type": "number",
"description": "Floating-point score."
},
"id": {
"type": "string",
"title": "RecordId",
"description": "Primary record identifier."
},
"count": {
"type": "integer"
},
"rank32": {
"type": "integer",
"minimum": 0,
"maximum": 1000
},
"price": {
"type": "number",
"multipleOf": 0.01
},
"active": {
"type": "boolean"
},
"created_date": {
"type": "string",
"format": "date"
},
"updated_at": {
"type": "string",
"format": "date-time"
},
"status": {
"type": "string"
},
"opt_note": {
"type": [
"string",
"null"
]
},
"extra_col": {
"type": [
"integer",
"null"
],
"description": "Extra property omitted from required."
}
}
}
Input (data.tsv)¶
# Leading comment stripped by qsv
id count rank32 score price active created_date updated_at status opt_note unlisted_tag
rec-1 42 10 3.5 19.99 true 2025-01-15 2025-01-15T08:30:00Z active first note alpha
# Interleaved comment line
rec-2 -100 0 -0.25 0.50 false 2025-02-20 2025-02-20T14:00:00Z inactive
Converted rows: run default¶
[
{
"id": "rec-1",
"count": 42,
"rank32": 10,
"score": 3.5,
"price": 19.99,
"active": true,
"created_date": "2025-01-15",
"updated_at": "2025-01-15T08:30:00Z",
"status": "active",
"opt_note": "first note",
"unlisted_tag": "alpha",
"extra_col": null
},
{
"id": "rec-2",
"count": -100,
"rank32": 0,
"score": -0.25,
"price": 0.5,
"active": false,
"created_date": "2025-02-20",
"updated_at": "2025-02-20T14:00:00Z",
"status": "inactive",
"opt_note": null,
"unlisted_tag": null,
"extra_col": null
}
]
PyArrow schema (expected_schema.py)¶
"""Expected PyArrow schema for 01_scalars_nullability_and_column_order."""
import pyarrow as pa
EXPECTED_SCHEMA = pa.schema(
[
pa.field(
"id",
pa.string(),
nullable=False,
metadata={"comment": "Primary record identifier.", "jsonschema_type": "string"},
),
pa.field("count", pa.int64(), nullable=False, metadata={"jsonschema_type": "integer"}),
pa.field("rank32", pa.int64(), nullable=False, metadata={"jsonschema_type": "integer"}),
pa.field(
"score",
pa.float64(),
nullable=False,
metadata={"comment": "Floating-point score.", "jsonschema_type": "number"},
),
pa.field("price", pa.float64(), nullable=False, metadata={"jsonschema_type": "number"}),
pa.field("active", pa.bool_(), nullable=False, metadata={"jsonschema_type": "boolean"}),
pa.field("created_date", pa.date32(), nullable=False, metadata={"jsonschema_type": "string (date)"}),
pa.field("updated_at", pa.string(), nullable=False, metadata={"jsonschema_type": "string (date-time)"}),
pa.field("status", pa.string(), nullable=False, metadata={"jsonschema_type": "string"}),
pa.field("opt_note", pa.string(), nullable=True, metadata={"jsonschema_type": "string"}),
pa.field("unlisted_tag", pa.string(), nullable=True, metadata={"jsonschema_type": "string"}),
pa.field(
"extra_col",
pa.int64(),
nullable=True,
metadata={"comment": "Extra property omitted from required.", "jsonschema_type": "integer"},
),
]
)
Custom Delimiters, Comment Characters, Quotes, and Escapes (spec/02_delimiters_comments_quotes_and_escapes)¶
Rules: R-XSV-DIALECT, R-XSV-REQUIRED-COLS, R-XSV-METASCHEMA, R-XSV-CONFIG, R-XSV-CLEAN-INPUT, R-XSV-SECOND-PASS, R-XSV-DUCKDB-TYPES, R-XSV-ARROW-CAST
- Declares
"$schema": "https://kbase.github.io/cdm-data-loaders/schemas/xsv_config/0.1.0/schema.json"withx-delimiter: ";",x-comment-char: "%",x-quote: "'",x-escape: "\\", andx-quoting-policy: "necessary". - Lines starting with
%are stripped as comments, while#insidesample_id("S#2") is preserved as data. qsv inputstrips surrounding single quotes (') and unescapes\'sequences inside quoted fields.
Schema (schema.json)¶
{
"$schema": "https://kbase.github.io/cdm-data-loaders/schemas/xsv_config/0.1.0/schema.json",
"title": "CustomDelimitersQuotesAndEscapes",
"type": "object",
"required": [
"sample_id",
"measurement",
"label"
],
"properties": {
"sample_id": {
"type": "string"
},
"measurement": {
"type": "number"
},
"label": {
"type": "string"
}
},
"x-xsv-config": {
"x-delimiter": ";",
"x-comment-char": "%",
"x-quote": "'",
"x-escape": "\\",
"x-has-header": true,
"x-quoting-policy": "necessary"
}
}
Input (data.csv)¶
% Semicolon-delimited file with single-quote quoting and backslash escaping
sample_id;measurement;label
S-1;12.5;'quoted label'
% Mid-file comment
S#2;8.75;'escaped \'quote\' inside'
Converted rows: run default¶
[
{
"sample_id": "S-1",
"measurement": 12.5,
"label": "quoted label"
},
{
"sample_id": "S#2",
"measurement": 8.75,
"label": "escaped 'quote' inside"
}
]
PyArrow schema (expected_schema.py)¶
"""Expected PyArrow schema for 02_delimiters_comments_quotes_and_escapes."""
import pyarrow as pa
EXPECTED_SCHEMA = pa.schema(
[
pa.field("sample_id", pa.string(), nullable=False, metadata={"jsonschema_type": "string"}),
pa.field("measurement", pa.float64(), nullable=False, metadata={"jsonschema_type": "number"}),
pa.field("label", pa.string(), nullable=False, metadata={"jsonschema_type": "string"}),
]
)
Missing Header Synthesis, $defs / $ref, and anyOf Nullability (spec/03_missing_header_and_defs_ref)¶
Rules: R-XSV-DIALECT, R-XSV-REQUIRED-COLS, R-XSV-METASCHEMA, R-XSV-CONFIG, R-XSV-DEREF, R-XSV-NULLABILITY, R-XSV-SCALAR-MAP, R-XSV-HEADER, R-XSV-CLEAN-INPUT, R-XSV-FIRST-PASS, R-XSV-SECOND-PASS, R-XSV-DUCKDB-TYPES, R-XSV-ARROW-CAST
x-has-header: falsesetsmissing_header=True, causingclean_validate_fileto prepend a comma-delimited header (gene_id,reads,organism) generated fromrequired.gene_idandorganismresolve through local$defs/$refviadereference_schema.organismusesanyOf: [{"type": "string"}, {"type": "null"}], which is recognized asnullable=Truein PyArrow and nullableVARCHARin DuckDB.
Schema (schema.json)¶
{
"$schema": "https://kbase.github.io/cdm-data-loaders/schemas/xsv_config/0.1.0/schema.json",
"title": "MissingHeaderAndDefsRef",
"type": "object",
"required": [
"gene_id",
"reads",
"organism"
],
"$defs": {
"GeneId": {
"type": "string",
"pattern": "^G[0-9]{4}$",
"description": "Standard gene identifier."
},
"NullableOrganism": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Organism name or null."
}
},
"properties": {
"gene_id": {
"$ref": "#/$defs/GeneId"
},
"reads": {
"type": [
"integer",
"null"
],
"description": "Read count or null when unmeasured."
},
"organism": {
"$ref": "#/$defs/NullableOrganism"
}
},
"x-xsv-config": {
"x-delimiter": ",",
"x-has-header": false
}
}
Input (data.csv)¶
Converted rows: run default¶
[
{
"gene_id": "G0001",
"reads": 1500,
"organism": "Escherichia coli"
},
{
"gene_id": "G0002",
"reads": null,
"organism": "Bacillus subtilis"
},
{
"gene_id": "G0003",
"reads": 0,
"organism": "Saccharomyces cerevisiae"
}
]
PyArrow schema (expected_schema.py)¶
"""Expected PyArrow schema for 03_missing_header_and_defs_ref."""
import pyarrow as pa
EXPECTED_SCHEMA = pa.schema(
[
pa.field(
"gene_id",
pa.string(),
nullable=False,
metadata={"comment": "Standard gene identifier.", "jsonschema_type": "string"},
),
pa.field(
"reads",
pa.int64(),
nullable=True,
metadata={"comment": "Read count or null when unmeasured.", "jsonschema_type": "integer"},
),
pa.field(
"organism",
pa.string(),
nullable=True,
metadata={"comment": "Organism name or null.", "jsonschema_type": "string"},
),
]
)
Selective Null Regex Replacement (x-null-regex and x-null-cols) (spec/04_null_regex_and_null_cols)¶
Rules: R-XSV-REQUIRED-COLS, R-XSV-METASCHEMA, R-XSV-CONFIG, R-XSV-NULLABILITY, R-XSV-FIRST-PASS, R-XSV-NULL-REPLACE, R-XSV-SECOND-PASS, R-XSV-DUCKDB-TYPES, R-XSV-ARROW-CAST
- First-pass validation uses
generate_first_pass_schema(where all columns accept strings), allowing placeholder strings"NA","N/A","null", and"-"in numeric columnsdepthandphto pass structural checks. qsv replacethen replaces matches of^(NA|N/A|null|-)$with""only inx-null-cols: ["depth", "ph"]before second-pass validation checksintegerandnumbertypes.- Because
raw_codeis not listed inx-null-cols, its"NA"and"-"values are preserved verbatim.
Schema (schema.json)¶
{
"$schema": "https://kbase.github.io/cdm-data-loaders/schemas/xsv_config/0.1.0/schema.json",
"title": "SelectiveNullRegexReplacement",
"type": "object",
"required": [
"site",
"depth",
"ph",
"raw_code"
],
"properties": {
"site": {
"type": "string"
},
"depth": {
"type": [
"integer",
"null"
]
},
"ph": {
"type": [
"number",
"null"
]
},
"raw_code": {
"type": "string"
}
},
"x-xsv-config": {
"x-delimiter": "\t",
"x-null-regex": "^(NA|N/A|null|-)$",
"x-null-cols": [
"depth",
"ph"
]
}
}
Input (data.tsv)¶
site depth ph raw_code
station-1 10 7.2 NA
station-2 NA N/A -
station-3 - 6.8 null
station-4 25 null OK
Converted rows: run default¶
[
{
"site": "station-1",
"depth": 10,
"ph": 7.2,
"raw_code": "NA"
},
{
"site": "station-2",
"depth": null,
"ph": null,
"raw_code": "-"
},
{
"site": "station-3",
"depth": null,
"ph": 6.8,
"raw_code": "null"
},
{
"site": "station-4",
"depth": 25,
"ph": null,
"raw_code": "OK"
}
]
PyArrow schema (expected_schema.py)¶
"""Expected PyArrow schema for 04_null_regex_and_null_cols."""
import pyarrow as pa
EXPECTED_SCHEMA = pa.schema(
[
pa.field("site", pa.string(), nullable=False, metadata={"jsonschema_type": "string"}),
pa.field("depth", pa.int64(), nullable=True, metadata={"jsonschema_type": "integer"}),
pa.field("ph", pa.float64(), nullable=True, metadata={"jsonschema_type": "number"}),
pa.field("raw_code", pa.string(), nullable=False, metadata={"jsonschema_type": "string"}),
]
)
Partial Row Recovery vs Total File Rejection (_rejected) (spec/05_partial_and_total_validation_rejections)¶
Rules: R-XSV-FIRST-PASS, R-XSV-SECOND-PASS, R-XSV-DUCKDB-TYPES, R-XSV-ARROW-CAST, R-XSV-REJECTED
- In run
partial_recovery(partial.csv), 1 of 4 rows is ragged (too few columns) and is split out by first-pass validation ("1 out of 4 records invalid."), and 1 of the remaining 3 rows fails second-pass schema validation (amountis"not-an-int","1 out of 3 records invalid."). The 2 valid rows are promoted torecordswhile both pass errors are recorded in_rejected. - In run
total_failure(all_invalid.csv), all 2 rows fail second-pass validation ("bad-int"and"not-a-date"). Because.validcontains only the header line, no data rows are emitted torecordsand_rejectedreceives"2 out of 2 records invalid.".
Schema (schema.json)¶
{
"$schema": "https://kbase.github.io/cdm-data-loaders/schemas/xsv_config/0.1.0/schema.json",
"title": "PartialAndTotalValidationRejections",
"type": "object",
"required": [
"record_id",
"amount",
"event_date"
],
"properties": {
"record_id": {
"type": "string"
},
"amount": {
"type": "integer"
},
"event_date": {
"type": "string",
"format": "date"
}
},
"x-xsv-config": {
"x-delimiter": ",",
"x-has-header": true
}
}
Input (partial.csv)¶
record_id,amount,event_date
R1,100,2025-03-01
R_RAGGED,only_two_cols
R2,not-an-int,2025-03-02
R3,250,2025-03-03
Converted rows: run partial_recovery (table_name=records)¶
[
{
"record_id": "R1",
"amount": 100,
"event_date": "2025-03-01"
},
{
"record_id": "R3",
"amount": 250,
"event_date": "2025-03-03"
}
]
Rejected rows (_rejected): run partial_recovery (table_name=records)¶
[
{
"table_name": "records",
"source_file": "partial.csv",
"line_no": null,
"raw_record": null,
"error_detail": "1 out of 4 records invalid."
},
{
"table_name": "records",
"source_file": "partial.csv",
"line_no": null,
"raw_record": null,
"error_detail": "1 out of 3 records invalid."
}
]
Input (all_invalid.csv)¶
Converted rows: run total_failure (table_name=records)¶
Rejected rows (_rejected): run total_failure (table_name=records)¶
[
{
"table_name": "records",
"source_file": "all_invalid.csv",
"line_no": null,
"raw_record": null,
"error_detail": "2 out of 2 records invalid."
}
]
PyArrow schema (expected_schema.py)¶
"""Expected PyArrow schema for 05_partial_and_total_validation_rejections."""
import pyarrow as pa
EXPECTED_SCHEMA = pa.schema(
[
pa.field("record_id", pa.string(), nullable=False, metadata={"jsonschema_type": "string"}),
pa.field("amount", pa.int64(), nullable=False, metadata={"jsonschema_type": "integer"}),
pa.field("event_date", pa.date32(), nullable=False, metadata={"jsonschema_type": "string (date)"}),
]
)
Multi-File Glob Discovery Across Subdirectories and Per-File Rejections (spec/06_multi_file_glob_and_subdirs)¶
Rules: R-XSV-GLOB, R-XSV-FIRST-PASS, R-XSV-SECOND-PASS, R-XSV-DUCKDB-TYPES, R-XSV-ARROW-CAST, R-XSV-REJECTED
input_files/contains two subdirectories with same-stem files (batch_1/data.csvandbatch_2/data.csv) plus a non-matchingbatch_1/notes.txtfile ignored byfile_glob: "*/*.csv".batch_1/data.csvhas 2 valid rows;batch_2/data.csvhas 1 valid row and 1 ragged row (3\n), so its valid row is merged intosamplesand its failure is recorded in_rejectedwithsource_file: "batch_2/data.csv".
Schema (schema.json)¶
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "MultiFileGlobSamples",
"type": "object",
"required": [
"sample_num",
"sample_name"
],
"properties": {
"sample_num": {
"type": "integer"
},
"sample_name": {
"type": "string"
}
},
"x-xsv-config": {
"x-delimiter": ",",
"x-has-header": true
}
}
Input (input_files/batch_1/data.csv)¶
Input (input_files/batch_1/notes.txt)¶
Input (input_files/batch_2/data.csv)¶
Converted rows: run default (file_glob=*/*.csv, table_name=samples)¶
[
{
"sample_num": 1,
"sample_name": "alpha"
},
{
"sample_num": 2,
"sample_name": "beta"
},
{
"sample_num": 4,
"sample_name": "delta"
}
]
Rejected rows (_rejected): run default (file_glob=*/*.csv, table_name=samples)¶
[
{
"table_name": "samples",
"source_file": "batch_2/data.csv",
"line_no": null,
"raw_record": null,
"error_detail": "1 out of 2 records invalid."
}
]
PyArrow schema (expected_schema.py)¶
"""Expected PyArrow schema for 06_multi_file_glob_and_subdirs."""
import pyarrow as pa
EXPECTED_SCHEMA = pa.schema(
[
pa.field("sample_num", pa.int64(), nullable=False, metadata={"jsonschema_type": "integer"}),
pa.field("sample_name", pa.string(), nullable=False, metadata={"jsonschema_type": "string"}),
]
)
Table Transforms, Row Filtering, Multi-Table Fan-Out, and Output Schemas (spec/07_table_transforms_and_output_schemas)¶
Rules: R-XSV-DUCKDB-TYPES, R-XSV-TRANSFORMS
- Configures
transforms_module="specs.pipelines.xsv.transforms"andoutput_schemas_module="specs.pipelines.xsv.transforms"fortable_name="readings". - Because a transform is registered for
"readings",read_validated_rowsyields Python row dicts (use_pyarrow=False) toroute_and_coerce_recordandDictBuffer. - The transform drops rows with
status == "drop"(s-2), uppercasessensor_id, computeskelvin = round(celsius + 273.15, 2)for"readings", and fans out rows withcelsius >= 100.0(s-3) to"alerts", coercing both tables againstOUTPUT_SCHEMAS.
Schema (schema.json)¶
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SensorReadingsInput",
"type": "object",
"required": [
"sensor_id",
"celsius",
"status"
],
"properties": {
"sensor_id": {
"type": "string"
},
"celsius": {
"type": "number"
},
"status": {
"type": "string"
}
},
"x-xsv-config": {
"x-delimiter": ",",
"x-has-header": true
}
}
Input (data.csv)¶
Converted rows: run with_transforms (table_name=readings, transforms_module=specs.pipelines.xsv.transforms, output_schemas_module=specs.pipelines.xsv.transforms)¶
[
{
"sensor_id": "S-1",
"celsius": 25.0,
"kelvin": 298.15
},
{
"sensor_id": "S-3",
"celsius": 120.5,
"kelvin": 393.65
}
]
Converted rows (alerts): run with_transforms (table_name=readings, transforms_module=specs.pipelines.xsv.transforms, output_schemas_module=specs.pipelines.xsv.transforms)¶
PyArrow schema (expected_schema.py)¶
"""Expected default PyArrow schema (from schema.json before output_schemas override) for 07_table_transforms_and_output_schemas."""
import pyarrow as pa
EXPECTED_SCHEMA = pa.schema(
[
pa.field("sensor_id", pa.string(), nullable=False, metadata={"jsonschema_type": "string"}),
pa.field("celsius", pa.float64(), nullable=False, metadata={"jsonschema_type": "number"}),
pa.field("status", pa.string(), nullable=False, metadata={"jsonschema_type": "string"}),
]
)
4. Errors (Unsupported / Invalid Input)¶
Schema generation raises a subclass of XsvSchemaError (itself a RuntimeError).
| Code | Exception | Condition | Reason | Fixtures |
|---|---|---|---|---|
| E1 | UnsupportedXsvDialectError |
Missing or unsupported $schema dialect |
Only JSON Schema Draft 2020-12 and the KBase Draft 2020-12 XSV metaschema are supported. | missing_schema_keyword.json: No $schema keyword.unsupported_dialect.json: Declares an unknown $schema dialect URI. |
| E2 | MissingRequiredColumnsError |
Missing or empty required column list |
XSV schemas must declare a non-empty list of column headers in the top-level required array. |
missing_required.json: Top-level required keyword is omitted.empty_required.json: Top-level required is []. |
| E3 | InvalidXsvSchemaDefinitionError |
Invalid JSON Schema or x-xsv-config block |
The document is not valid JSON Schema or contains an invalid x-xsv-config parser block. |
invalid_xsv_config_delimiter.json: x-xsv-config.x-delimiter is a two-character string "::".invalid_xsv_config_unknown_key.json: x-xsv-config contains an unrecognized property "x-unknown".invalid_json_schema_type.json: Property declares "type": "not-a-valid-type". |
| E4 | UnresolvableXsvRefError |
Unresolvable $ref pointer |
All $ref pointers in an XSV schema must resolve against local $defs before conversion. |
unresolvable_ref.json: Property references missing #/$defs/MissingDef. |
| E5 | UnsupportedXsvArrowConstructError |
Unsupported PyArrow column construct | Every XSV column must convert to a supported PyArrow type under a top-level object schema. |
non_struct_root.json: Root declares "type": "array" despite having required.unsupported_column_construct.json: Column property uses "$dynamicRef": "#Node". |
5. Fixture Index¶
Every case directory contains a case.yaml manifest; the test suites and this document are both driven by it.
| Case | Target CLI | Manifest | Runs |
|---|---|---|---|
spec/01_scalars_nullability_and_column_order |
xsv |
case.yaml |
default |
spec/02_delimiters_comments_quotes_and_escapes |
xsv |
case.yaml |
default |
spec/03_missing_header_and_defs_ref |
xsv |
case.yaml |
default |
spec/04_null_regex_and_null_cols |
xsv |
case.yaml |
default |
spec/05_partial_and_total_validation_rejections |
xsv |
case.yaml |
partial_recovery, total_failure |
spec/06_multi_file_glob_and_subdirs |
xsv |
case.yaml |
default |
spec/07_table_transforms_and_output_schemas |
xsv |
case.yaml |
with_transforms |