osw-sanitizer is a Python package for sanitizing OpenSidewalks (OSW)
dataset ZIP files. It is designed to be consumed by the TDEI sanitization
service and by other Python workflows that need the same deterministic cleanup
behavior.
Given a dataset ZIP, the sanitizer runs these passes in order and reports every change it made:
- Drops files that do not belong — non-OSW filenames and macOS packaging
metadata (
__MACOSX/,._*resource forks,.DS_Store). - Removes broken tags — JSON
nulland numericNaNproperty values. Look-alike strings ("None","null","nan","n/a","na") and falsy but meaningful values (0,false,"") are kept. - Shortens coordinates to a configurable precision, by rounding (default) or truncating.
- Creates missing nodes for every
_u_id/_v_id/_w_idthat no node declares. - Enforces unique node
_ids — identical repeats dropped, conflicting ones re-ided. - Collapses duplicate nodes sharing coordinates and tags, repointing references at the survivor.
- Verifies the graph is intact, then validates the result with
python-osw-validationand bundles everything intoosw_data.zip.
Geometry splitting is intentionally out of scope: features are never split, regardless of vertex count.
pip install osw-sanitizerFor local development:
python -m pip install -e .
python -m pip install pytest coveragefrom osw_sanitizer import OSWSanitization, SanitizationConfig
config = SanitizationConfig(
coordinate_precision=7,
coordinate_rounding="round", # or "truncate"
)
result = OSWSanitization(
input_path="/path/to/input.zip",
output_dir="/path/to/output",
config=config,
).sanitize()
if result.success:
print(result.updated_dataset_zip) # osw_data.zip, the published bundle
print(result.fixes_json)
else:
print(result.message) # includes any validator issuesOSWSanitization.sanitize_dataset(...) returns the same information as a
dictionary:
from osw_sanitizer import OSWSanitization
result = OSWSanitization.sanitize_dataset(
input_zip_path="/path/to/input.zip",
output_dir="/path/to/output",
)
print(result["success"])
print(result["message"])
print(result["updated_dataset_zip"])
print(result["fixes_json"])SanitizationProcessor is retained as an alias of OSWSanitization.
| Option | Default | Description |
|---|---|---|
coordinate_precision |
7 |
Maximum decimal places retained for coordinate values. |
coordinate_rounding |
"round" |
How a too-long coordinate is shortened: "round" to the nearest value, halves away from zero, or "truncate" toward zero. |
validate_output |
True |
Validate the sanitized dataset and publish osw_data.zip. Set False to sanitize without judging the result against the OSW schema. |
The configuration names match the OSW formatter and validator packages where
applicable. Configuration is passed in code — the package reads no environment
variables and no .env file.
Each option can also be passed directly to the constructor:
OSWSanitization(input_path=..., output_dir=..., coordinate_precision=6)input_path must point to an existing .zip archive. The sanitizer returns an
unsuccessful SanitizationResult without writing any output when:
| Input | Message |
|---|---|
| Missing path | Input dataset path is missing |
| Path does not exist | Input dataset not found at path: <path> |
Not a .zip filename |
Input dataset must be a .zip file: <path> |
.zip filename that is not a zip archive |
Input dataset is not a valid zip archive: <path> |
Supported filenames come from python-osw-validation, so the sanitizer keeps
exactly the files the OSW validator accepts. The dataset keys are
OSW_DATASET_FILES:
edgeslinesnodespointspolygonszones
Supported filename forms are:
<dataset>.geojson<dataset>.OSW.geojson*.<dataset>.geojson*.<dataset>.OSW.geojson
Matching is case-insensitive.
These are omitted from the sanitized output and recorded under removedFiles
in fixes.json:
| File | fixType |
|---|---|
Non-OSW filenames, including unsupported .geojson names and non-geojson files |
unsupported_file_removed |
__MACOSX/ entries, ._* resource forks, .DS_Store |
macos_metadata_removed |
Coordinates already within coordinate_precision are left byte for byte as
they are — never padded with trailing zeros — and are not reported in
precisionUpdates. Only longer fractions are shortened, either way:
| Input | "round" |
"truncate" |
|---|---|---|
-122.123456789 |
-122.1234568 |
-122.1234567 |
47.12345674 |
47.1234567 |
47.1234567 |
47.12345675 |
47.1234568 |
47.1234567 |
-47.12345675 |
-47.1234568 |
-47.1234567 |
Rounding moves a point by at most half a unit of the last digit and has no directional bias; truncation always moves toward zero, so it biases a dataset slightly. Either way a coordinate can shift, which is why an edge endpoint can end up marginally off its node — see Graph Verification.
Every _u_id / _v_id (edges) and _w_id (zones) must name a node. The
sanitizer treats the reference id as authoritative rather than repointing it:
_u_idis the edge's first vertex,_v_idits last.- The n-th
_w_idis the n-th vertex of the zone's outer ring, with the repeated closing vertex dropped first. - A reference no node declares gets a node created with that exact
_id, at the coordinate the reference implies. A dataset can therefore come out with more nodes than it went in with. - References that already resolve are left alone, even when the node sits away from the vertex.
- If the coordinate cannot be determined — a
_w_idcount that does not match the ring, or an empty reference id — nothing is invented and the reference is logged underunresolvedReferences.
Duplicate node _ids are not allowed: a reference has to name exactly one
node. Within the nodes file the first feature to claim an _id keeps it, so
existing references stay pointed at the same node. A later repeat is dropped
when identical, and otherwise reassigned the next free <id>-<n>.
Nodes that share both their coordinates and their tags describe the same place,
so they are collapsed into the first of them. Every _u_id / _v_id / _w_id
pointing at a collapsed node is repointed at the survivor, logged as
collapsedNodes and updatedReferences. Comparison happens after rounding, so
nodes differing only below the precision limit collapse too. Nodes at the same
place with different tags are left alone.
After the fixes are applied, the sanitizer walks every reference once more and
records the outcome under verification in fixes.json:
{
"verification": {
"nodeCount": 12,
"referenceCount": 14,
"graphIntact": true,
"danglingReferences": [],
"misplacedReferences": []
}
}graphIntactis true when every reference resolves to a node that exists.danglingReferencesholds references the sanitizer already reported as unplaceable; they are findings, not failures, and the run still succeeds.misplacedReferencesholds references that resolve to a node sitting away from the vertex they describe. The reference is authoritative, so the node is never moved — but rounding can shift an endpoint off its node by up to one unit of the configured precision, and this is where that shows up.- A dangling reference that was not reported as unplaceable means a preceding pass broke the graph. That fails the run rather than shipping a broken dataset.
A validated run publishes osw_data.zip into output_dir, and that is what
result.updated_dataset_zip points at. It bundles:
- The sanitized dataset ZIP, under the same filename as the input ZIP.
fixes.json, structured details about every applied change.validation_issues.json, the validator's issues as{"issues": [...]}.
All three are also left loose in output_dir, so result.fixes_json points at
fixes.json on disk rather than inside the bundle. The bundle is published
whether or not the dataset validates — only success and message differ.
result.updated_dataset_zip # .../output/osw_data.zip
result.fixes_json # .../output/fixes.jsonWith validate_output=False there is no bundle: nothing has vouched for the
dataset, so updated_dataset_zip is the sanitized dataset ZIP itself and no
validation_issues.json is written.
Once sanitization finishes, the sanitized ZIP is handed to
python-osw-validation, judged at the same coordinate_precision it was
sanitized with.
Either outcome publishes the same three artifacts; what changes is the result
and what validation_issues.json holds:
| Outcome | success |
message |
validation_issues.json |
|---|---|---|---|
| Validates | True |
what was sanitized | {"issues": []} |
| Rejected | False |
the validator's issues | the issues, so a caller can fix the dataset |
A rejected run reports the issues in the message as well:
Sanitized dataset is not a valid OSW dataset.
- edges.geojson (feature 0): "" is shorter than 1 character (at: features[0].properties._u_id)
SanitizedDatasetValidationError keeps the raw issues alongside the rendered
messages, and format_issues(...) renders any issue list the same way.
Note that the validator enforces that an edge endpoint sits exactly on its
node. Rounding can move an endpoint off its node — the sanitizer reports that
under misplacedReferences but does not repair it, so such a dataset
sanitizes cleanly and then fails validation here.
Per-file entries carry only the keys that apply:
| Key | Written when |
|---|---|
removedTags |
a null or NaN tag was dropped |
precisionUpdates |
a coordinate was rounded or truncated |
addedNodeReferences |
a dangling reference caused a node to be created |
unresolvedReferences |
a reference could not be placed |
addedNodes |
nodes were added to the nodes file |
removedNodes |
a repeated _id on an identical node was dropped |
reassignedNodeIds |
a repeated _id on a differing node was re-ided |
collapsedNodes |
duplicate nodes were collapsed |
updatedReferences |
a reference followed a collapsed node |
Alongside them, removedFiles lists dropped files and verification reports
the graph check.
{
"jobId": "",
"files": [
{
"filename": "edges.geojson",
"removedTags": [
{
"featureIndex": 0,
"tag": "width",
"value": null
}
],
"precisionUpdates": [
{
"featureIndex": 0,
"coordinatePath": "coordinates[0]",
"original": "-122.123456789",
"updated": "-122.1234568",
"precision": 7,
"rounding": "round"
}
]
}
],
"removedFiles": [],
"verification": {
"nodeCount": 0,
"referenceCount": 0,
"graphIntact": true,
"danglingReferences": [],
"misplacedReferences": []
}
}A removed NaN is logged as the string "NaN", so fixes.json stays
parseable by strict JSON readers.
Install the package and test dependencies:
python -m pip install -e .
python -m pip install pytest coverageRun the unit tests:
python -m pytestRun the unit tests with coverage enforcement:
coverage run -m pytest
coverage report --fail-under=90The GitHub Actions unit test workflow writes timestamped test and coverage
logs into test_results/ and uploads them to Azure Blob Storage using the
AZURE_STORAGE_CONNECTION_STRING secret.
Package metadata is defined in pyproject.toml. setup.py is retained as a
compatibility shim for legacy packaging workflows.
Sample dataset ZIPs are checked in under tests/assets. The six-file OSW
datasets (edges, lines, nodes, points, polygons, zones) are generated by
tests/dataset_builder.py and carry the OSW 0.3 $schema:
| Dataset | Covers | Sanitize result |
|---|---|---|
passed.zip |
clean dataset, no fixes applied | passes validation |
missing_references.zip |
dangling _u_id / _v_id / _w_id |
passes validation |
precision_and_duplicates.zip |
over-long coordinates and repeated node ids | passes validation |
collapsible_nodes.zip |
duplicate nodes that collapse into one | passes validation |
rounding_modes.zip |
coordinates where round and truncate differ |
passes validation |
null_and_nan_tags.zip |
null / NaN tags and their look-alikes | passes validation |
cleanup.zip |
macOS metadata and unsupported filenames | passes validation |
misplaced_references.zip |
references resolving away from their vertex | fails validation |
unresolvable_references.zip |
references the sanitizer will not guess at | fails validation |
failure.zip |
non-finite coordinates and property values | fails sanitization |
not_a_zip.geojson, corrupt.zip |
invalid inputs for the ZIP-only check | rejected as input |
The last four fail by design. The two that fail validation still publish
osw_data.zip, with the issues in validation_issues.json; pass
validate_output=False to skip the gate entirely.
Regenerate them with:
python tests/dataset_builder.pySingle-purpose zips, hand-maintained and not schema-valid, used to exercise
individual passes with validate_output=False:
precision_and_null_tags.zipzero_length_edge.zipunsupported_files.zipnested_dataset.zip
GitHub Actions includes package publishing workflows:
.github/workflows/deploy_to_test.ymlpublishes to TestPyPI fromdevelop..github/workflows/publish_to_pypi.ymlpublishes to PyPI from semver tags or manual dispatch.
Both workflows build the package from pyproject.toml and use
PYPI_API_TOKEN for authentication.