Validating documents¶
Validation is a streaming pass over the document, driven by the compiled content automata. It answers two different questions, and which one you want decides which method to call:
- Is this document valid? →
validate - What does this document say, as typed values? →
iter_typed
Both do the same work. The second just hands you the values on the way past.
Is it valid?¶
import xsdkit
schemas = xsdkit.SchemaSet.from_file("report.xsd")
xml = open("report.xml").read()
report = schemas.validate(xml)
report.is_valid # True
bool(report) # the same thing, for `if report:`
An invalid document is an answer, not an exception. validate never raises
for one — the only things that raise are a document that is not XML at all, or
a schema that would not build.
broken_xml = """<report xmlns="urn:example" id="r1">
<title>November orders</title>
<issued>2024-13-45</issued>
<item sku="nope"><price currency="CHF">1.00</price></item>
</report>"""
report = schemas.validate(broken_xml)
report.is_valid # False
for d in report.errors:
print(d)
error[XSD2004]: `{urn:example}issued`: `2024-13-45` is not a valid xs:date: 13 is not a month
--> <instance>:4
error[XSD2004]: attribute `sku`: pattern: `nope` does not match ^(?:(?:[A-Z]{2}\-[0-9]{4}))$
--> <instance>:5
error[XSD2004]: attribute `currency`: enumeration: `CHF` is not one of the 3 permitted values
--> <instance>:6
Every error, with a line number, not just the first. report.diagnostics has
the warnings and notes too; report.errors is the errors alone.
A document given as a path is named by that path. One given as text or bytes
is called <instance> unless you pass uri="orders/report.xml".
Reading it into typed values¶
The validator already knows the type of every element and attribute it walks — that is what it is checking against. Asking for the values gives you that work instead of discarding it, as a PSVI: a post-schema-validation infoset.
validate_with runs the same single pass and hands each event to a
callback. A callback rather than an iterator because the events are
produced inside the parse, and suspending that would mean either a thread
or a self-referential struct.
Decimal("19.95"), not "19.95". date(2024, 12, 1), not "2024-12-01".
Reparsing those strings yourself is not just repeated work — it is where the
subtle wrongness lives, because float("19.95") is not the value the schema
said and datetime.strptime does not implement xs:date.
Which types you get¶
| XSD | Python |
|---|---|
xs:string and its derivatives |
str |
xs:boolean |
bool |
xs:int, xs:integer, xs:long, … |
int |
xs:decimal |
decimal.Decimal, in the scale it was written: 4.50 stays 4.50 |
xs:float, xs:double |
float |
xs:hexBinary, xs:base64Binary |
bytes |
xs:dateTime |
datetime.datetime |
xs:date |
datetime.date |
xs:time |
datetime.time |
xs:dayTimeDuration |
datetime.timedelta |
xs:duration, xs:gYear, xs:gMonthDay, … |
str, canonical form |
a value datetime cannot hold exactly |
str, canonical form |
| list types | list of the item type |
xs:duration stays a string on purpose: months and seconds are not
commensurable, so no timedelta can represent P1M faithfully. Guessing 30
days would be a silent, plausible, wrong answer. xs:dayTimeDuration has no
such problem, so it becomes a timedelta.
A value datetime cannot hold exactly keeps its canonical lexical form,
rather than failing to convert or being rounded into one it can hold:
- a year outside 1 to 9999 — XSD's year is unbounded in both directions, and
XSD 1.1 has a year zero — or a
dayTimeDurationof a billion days; - an
xs:datewith a timezone, such as2024-12-01Z, becausedatetime.datehas no room for one; - a time or a duration with digits below the microsecond, such as
PT0.0000001S.
An xs:float arrives as the shortest decimal that reads back as the same
32-bit value, so 0.1 is 0.1 rather than 0.10000000149011612.
The outcome comes at the end¶
iter_typed reads the document as it validates it, on a thread of its own,
so memory stays flat however large the document is. Whether a document is
valid is only known at its end, and that is where the report is:
events = schemas.iter_typed(xml)
for ev in events:
...
events.report.is_valid # once every event has been read
Reading report sooner raises RuntimeError; to know first, call validate.
An iterator dropped part way, after a break say, stops the reading as well,
rather than validating the rest of the document for nobody.
iter_typed composes the way an iterator should — with enumerate,
itertools, generator expressions. For everything at once, read_typed
returns (events, report), with the events as a list.
What is on an event¶
ev.kind # 'start' | 'text' | 'end'
ev.name, ev.local_name # ('urn:example', 'price'), 'price'; None on a text event
ev.declaration # the Element declaration; None under skip, or lax with no match
ev.type # the type in force, after any xsi:type override
ev.type_from_instance # True when xsi:type chose it
ev.nil # xsi:nil="true", where the declaration allows it
ev.value, ev.lexical # typed value and the text it came from
ev.line # where in the document
ev.attributes # AttributeValue, each with its own typed value
Values the schema supplied¶
An attribute with a default or fixed value appears in the PSVI even when
the document never wrote it — and says so.
for ev in schemas.iter_typed(xml):
if ev.kind == "start" and ev.local_name == "item":
print([(a.local_name, a.value, "from schema" if a.from_schema else "in document")
for a in ev.attributes])
# [('sku', 'AB-1042', 'in document'), ('quantity', 3, 'in document')]
# [('sku', 'ZZ-0007', 'in document'), ('quantity', 1, 'from schema')]
The second item never wrote a quantity; the schema's default="1" supplied
it. from_schema is the flag that lets you tell "the document said 1" from
"the document said nothing" — which matters when you are round-tripping, or
when a default means something different from an explicit value.
What is handled¶
xsi:typeoverrides — the prefix resolved against the namespaces in scope, the derivation checked against the declared type, theblockon both, and abstractness.xsi:nil, withnillableandfixedenforced, and a nil element held to having no content at all — no child elements, and no text, whitespace included.xs:QNameandxs:NOTATIONvalues, resolved against the namespaces in scope — the document's for a value, the schema's for anxs:enumerationliteral, which are not the same bindings and need not agree on a prefix.- Substitution groups, closed transitively, and
blockon the head — from the element declaration or from its type — barring a member from standing in for it. Anabstractelement cannot appear at all. - Wildcards, with
strict,laxandskipprocessing — on elements and on attributes. What alaxorstrictwildcard admits is validated against its global declaration and reaches the PSVI typed; what alaxone admits with no declaration is assessed asxs:anyType, so its text, attributes and children are kept and any globally declared element inside it is still checked. - Mixed content,
xs:all, and repeated particles. - Default and fixed values, for attributes and for elements with simple
content — an empty element takes the value its declaration supplies, and
from_schemaon the event says the schema wrote it rather than the document. An element with mixed content does not take one yet. - Character and entity references, resolved into the value:
caféiscafé, notcaf. - Encoding detection — hand
validatethe rawbytesrather than decoding first, and the byte-order mark and XML declaration are read for you.
xs:ID uniqueness and xs:IDREF resolution are enforced: an ID binds to the
element carrying it, no two elements may claim one, and every reference must
match one somewhere in the document — including one that appears later.
xs:ENTITY and xs:ENTITIES are checked against the unparsed entities the
document's DTD declares — the one part of a DTD this reader looks at, because
it is the only part those datatypes need.
Identity constraints are enforced: xs:key, xs:keyref and xs:unique,
over the restricted XPath subset they take — an optional .//, child steps,
and an attribute as a field's last step. Keys compare in the value space, so
07:00:00Z and 02:00:00-05:00 are one key. Nodes under a skip wildcard,
which are never assessed, are not selected.
Not yet: XSD 1.1 assertions and conditional type assignment are stored and not evaluated. A document that violates one of those is currently reported as valid.
Conformance has the measured numbers: 99.0% of the W3C suite's 21,671 documents are judged correctly when read as XSD 1.1, and 99.8% when read as XSD 1.0.
Next¶
- Diagnostics — the shape of what comes back.
- XSD 1.1 — what turning it on changes.