Vendor the SLD and Symbology Encoding schema files into your repository, point an lxml catalog resolver at them, compile XMLSchema once per process, and validate with assertValid so the full error log survives. A validator that fetches schemas over HTTP at validation time is a validator that fails on the day your build host loses egress.
There is no single SLD schema file. StyledLayerDescriptor.xsd imports the Filter Encoding schema, which imports GML, which imports XLink — and in SLD 1.1.0 the symbolizer definitions live in a separate Symbology Encoding schema that must be resolved alongside it. Each of those imports is written as an absolute http://schemas.opengis.net/... URL, so the default behaviour of every XML toolchain is to fetch them over the network the first time a document is validated.
That is unacceptable in a build pipeline for three reasons. It makes validation non-deterministic, because the remote schema can change. It makes it slow, because a dozen HTTP round trips precede every compilation. And it makes it fragile, because a build host behind a proxy or a temporarily unreachable schema server turns a styling check into an infrastructure failure. The fix is the same one used for ISO 19115 records: vendor the schemas and resolve every import locally.
from __future__ import annotations
import functools
import os
from dataclasses import dataclass
from lxml import etree
# Where the vendored OGC schema tree was unpacked. Mirror the remote
# directory layout exactly so the import URLs map by simple prefix swap.
SCHEMA_ROOT = os.environ.get("OGC_SCHEMA_ROOT", "./schemas/schemas.opengis.net")
REMOTE_PREFIX = "http://schemas.opengis.net/"
SLD_10 = "sld/1.0.0/StyledLayerDescriptor.xsd"
SLD_11 = "se/1.1.0/StyledLayerDescriptor.xsd"
@dataclass(frozen=True)
class Problem:
line: int
column: int
message: str
def __str__(self) -> str:
return f"line {self.line}, col {self.column}: {self.message}"
class LocalResolver(etree.Resolver):
"""Map every schemas.opengis.net URL onto the vendored copy on disk."""
def resolve(self, system_url, public_id, context):
if system_url and system_url.startswith(REMOTE_PREFIX):
local = os.path.join(SCHEMA_ROOT, system_url[len(REMOTE_PREFIX):])
if os.path.exists(local):
return self.resolve_filename(local, context)
raise FileNotFoundError(
f"{system_url} is not vendored — expected {local}")
return None
def _parser() -> etree.XMLParser:
parser = etree.XMLParser(no_network=True, resolve_entities=False)
parser.resolvers.add(LocalResolver())
return parser
@functools.lru_cache(maxsize=4)
def _schema(relative_path: str) -> etree.XMLSchema:
"""Compile a schema once per process — this is the expensive step."""
path = os.path.join(SCHEMA_ROOT, relative_path)
return etree.XMLSchema(etree.parse(path, _parser()))
def detect_version(doc: etree._Element) -> str:
"""Read the declared version, falling back to namespace inspection."""
declared = doc.get("version")
if declared in {"1.0.0", "1.1.0"}:
return declared
return "1.1.0" if "opengis.net/se" in (doc.nsmap.values() or "") else "1.0.0"
def validate(sld_bytes: bytes) -> list[Problem]:
"""Return every schema problem. An empty list means structurally valid."""
doc = etree.fromstring(sld_bytes, _parser())
schema = _schema(SLD_11 if detect_version(doc) == "1.1.0" else SLD_10)
if schema.validate(doc):
return []
return [Problem(e.line, e.column, e.message) for e in schema.error_log]
def assert_single_namespace(sld_bytes: bytes) -> None:
"""Reject a document that mixes SLD 1.0.0 and Symbology Encoding names.
Such a document can validate against neither schema, and — worse —
a renderer resolves symbolizers by qualified name, so the mixed
half is silently ignored rather than reported.
"""
text = sld_bytes.decode("utf-8", "replace")
has_sld_sym = "<PolygonSymbolizer" in text or "<LineSymbolizer" in text
has_se_sym = ":PolygonSymbolizer" in text or ":LineSymbolizer" in text
if has_sld_sym and has_se_sym:
raise ValueError("document mixes sld: and se: symbolizers")
if __name__ == "__main__":
with open("style.sld", "rb") as fh:
payload = fh.read()
assert_single_namespace(payload)
for problem in validate(payload):
print(problem)
The resolver is where hermetic validation is won. LocalResolver intercepts every schema location beginning with the OGC schema host and rewrites it to a path under the vendored tree. Mirroring the remote directory layout exactly means the rewrite is a prefix swap with no lookup table to maintain as the schema set grows. Raising FileNotFoundError on a miss is deliberate: a silent fall-through to the network is precisely the behaviour being eliminated.
no_network=True closes the back door. Even with a resolver installed, lxml will happily fetch anything the resolver returns None for. Setting no_network on the parser turns that into an explicit error, so an import you forgot to vendor surfaces during development rather than during a deploy.
Compilation is cached, validation is not. _schema is wrapped in lru_cache because compiling the full SLD schema graph resolves dozens of imports and takes on the order of a second. Validating a document against an already-compiled schema takes microseconds. Bulk-validating a directory of styles should therefore be one compilation and many validations, which is exactly what the cache gives you without any explicit lifecycle management.
Version detection reads the attribute, then the namespaces. The version attribute on the root element is authoritative when present, but documents produced by GUI tools sometimes omit it. Falling back to looking for the Symbology Encoding namespace among the declared prefixes catches those, and defaulting to 1.0.0 matches what most servers assume.
Run the validator against a deliberately broken document and confirm the error log is specific rather than generic:
python validate_sld.py < broken.sld
line 14, col 0: Element '{http://www.opengis.net/sld}CssParameter': The attribute 'name' is required but missing.
line 22, col 0: Element '{http://www.opengis.net/sld}Rule': This element is not expected. Expected is one of ( {http://www.opengis.net/sld}Name, {http://www.opengis.net/sld}Title ).
Then confirm the hermetic property directly — disable network access for the process and check that validation still succeeds:
import socket
def _no_network(*args, **kwargs):
raise AssertionError("validator attempted a network call")
socket.socket = _no_network # any egress now fails loudly
assert validate(open("style.sld", "rb").read()) == []
Schema validity says nothing about rendering. This is the single most important limitation to hold onto. A document can be perfectly schema-valid, upload without complaint, and produce a completely empty map because its filters reference an attribute that does not exist. Schema validation is a necessary first gate, not a sufficient one — the render assertion in Debugging SLD Rules That Render Nothing is the gate that catches the rest.
Vendor the whole tree, not just the SLD files. The SLD schema imports Filter Encoding, which imports GML, which imports XLink and several others. Vendoring only the top-level file produces a resolver error on the first import, so download the full schemas.opengis.net tree for the versions you use and commit it.
error_log is per-validation and is cleared. Reading schema.error_log after a subsequent validate() call on the same schema object gives you the newer document’s errors. Materialise the log into your own objects — as Problem above does — before validating anything else.
Vendor extensions will not validate. GeoServer’s VendorOption elements are outside the SLD schema by design. If your styles use them, either validate against the vendor’s extended schema or strip the vendor elements before validation and assert on them separately; do not disable validation because of them.
Download the published OGC schema tree from schemas.opengis.net for the versions you use and commit it, preserving the directory layout. Preserving the layout is what allows the resolver to work by prefix substitution rather than by a hand-maintained mapping that has to grow every time an import is added.
Yes, without exception. A schema-invalid style is rejected on upload anyway, so failing early costs nothing and saves a round trip to a server. The interesting judgement call is the opposite one — deciding what to do about a schema-valid style that renders nothing — and that belongs in a render assertion, not in the schema gate.
One validator function, yes — two compiled schemas, cached separately and selected per document, which is exactly what the code above does. What you cannot do is validate a single document against both, because the symbolizer elements live in different namespaces and a document that satisfies one schema is invalid against the other.
Usually because GeoServer applies additional semantic checks beyond the schema — a UserStyle without a name, a symbolizer type that does not match the layer’s geometry, or a reference to a style that does not exist. Its rejection message names the specific check, which is worth reading rather than assuming the schema set is out of date.
Back to SLD Styling and Symbology for OGC Services
Related