diff --git a/CHANGELOG.md b/CHANGELOG.md index 02e9c4f..ed3fe89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Change log +### 0.3.7 - 2026-04-29 +- Added upfront null/NaN placeholder detection before schema validation, including support for string placeholders such as `"null"` and `"nan"` in feature `properties`. +- Changed `issues` behavior to return all detected per-feature schema issues (not only a single best issue per feature). +- Suppressed noisy `AnyOf` summary messages when more specific field-level errors exist for the same feature. +- Improved human-readable validation messages for enum/type failures with clearer field-level context and actionable remediation text. +- Updated enum formatting to use compact previews for long value lists (pipe-separated values with `and N more` suffix). +- Added new regression coverage for `tests/assets/issue_3297.zip` and updated related unit test expectations for new message formats and nullish precheck flow. + ### 0.3.6 - 2026-04-10 - Fixed https://dev.azure.com/TDEI-UW/TDEI/_workitems/edit/3469 - Added regression coverage for `tests/assets/task_3469.zip` to assert the exact per-feature `issues` payload: `"null" is not one of "down" or "up"` on `FIFA_sidewalks.edges.geojson` feature index `0`. diff --git a/README.md b/README.md index 8ad2d5c..ba98dd9 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ This package validates OSW GeoJSON datasets packaged as a ZIP file. - Extracts the provided ZIP file - Finds supported OSW dataset files inside the extracted directory - Validates each file (`edges`, `lines`, `nodes`, `points`, `polygons`, and `zones`) against the matching schema +- Performs an upfront data-quality check for null-like placeholders in feature properties (for example `null`, `NaN`, `"null"`, `"nan"`) - Runs cross-file integrity checks such as duplicate `_id` detection and edge or zone references back to nodes - Returns a `ValidationResult` object with `is_valid`, `errors`, and `issues` @@ -33,13 +34,24 @@ validator = OSWValidation(zipfile_path='') result = validator.validate() print(result.is_valid) print(result.errors) # returns up to the first 20 high-level errors by default -print(result.issues) # per-file or per-feature issues +print(result.issues) # detailed per-feature issues, capped to first 20 by default result = validator.validate(max_errors=10) print(result.is_valid) print(result.errors) # returns up to the first 10 high-level errors +print(result.issues) # capped by the same max_errors limit ``` +## Error behavior + +- `errors`: high-level validation messages, capped by `max_errors` (default `20`). +- `issues`: detailed per-feature validation issues, also capped by `max_errors`. +- If null-like placeholders are found in feature `properties`, validation fails early before schema checks with actionable messages such as: + - `Invalid value at 'climb': 'null'. Null/NaN placeholders are not allowed; provide a valid value or remove this property.` +- For enum validation, long allowed-value lists are summarized as: + - first 5 values joined by `|` + - followed by `| and N more` when applicable. + You can also override schemas: ```python diff --git a/src/python_osw_validation/__init__.py b/src/python_osw_validation/__init__.py index 5f8512e..e04ea1d 100644 --- a/src/python_osw_validation/__init__.py +++ b/src/python_osw_validation/__init__.py @@ -1,6 +1,7 @@ import os import gc import json +import math import traceback from typing import Dict, Any, Optional, List, Tuple import geopandas as gpd @@ -11,9 +12,9 @@ from .version import __version__ from .helpers import ( _add_additional_properties_hint, + _err_kind, _feature_index_from_error, _pretty_message, - _rank_for, ) SCHEMA_PATH = os.path.join(os.path.dirname(__file__), 'schema') @@ -129,6 +130,29 @@ def _schema_key_from_text(self, text: Optional[str]) -> Optional[str]: return None + def _is_nullish_value(self, value: Any) -> bool: + if value is None: + return True + if isinstance(value, str) and value.strip().lower() in {"null", "nan"}: + return True + return isinstance(value, float) and math.isnan(value) + + def _collect_nullish_property_paths(self, obj: Any, prefix: str = "") -> List[Tuple[str, Any]]: + paths: List[Tuple[str, Any]] = [] + if isinstance(obj, dict): + for key, value in obj.items(): + next_prefix = f"{prefix}.{key}" if prefix else str(key) + paths.extend(self._collect_nullish_property_paths(value, next_prefix)) + return paths + if isinstance(obj, list): + for idx, value in enumerate(obj): + next_prefix = f"{prefix}[{idx}]" if prefix else f"[{idx}]" + paths.extend(self._collect_nullish_property_paths(value, next_prefix)) + return paths + if self._is_nullish_value(obj): + paths.append((prefix or "value", obj)) + return paths + def _contains_disallowed_features_for_02(self, geojson_data: Dict[str, Any]) -> set: """Detect Tree coverage or Custom content in legacy 0.2 datasets. @@ -197,6 +221,12 @@ def pick_schema_for_file(self, file_path: str, geojson_data: Dict[str, Any]) -> # Core validation entrypoint # ---------------------------- def validate(self, max_errors=20) -> ValidationResult: + def _finalize(is_valid: bool, errors: Optional[List[str]] = None) -> ValidationResult: + final_errors = self.errors if errors is None else errors + final_errors = (final_errors or [])[:max_errors] + final_issues = (self.issues or [])[:max_errors] + return ValidationResult(is_valid, final_errors, final_issues) + zip_handler = None OSW_DATASET: Dict[str, Optional[gpd.GeoDataFrame]] = {} validator = None @@ -211,7 +241,7 @@ def validate(self, max_errors=20) -> ValidationResult: filename=self.zipfile_path, feature_index=None ) - return ValidationResult(False, self.errors, self.issues) + return _finalize(False) # Validate the folder structure validator = ExtractedDataValidator(self.extracted_dir) @@ -222,7 +252,7 @@ def validate(self, max_errors=20) -> ValidationResult: filename=upload_name, feature_index=None ) - return ValidationResult(False, self.errors, self.issues) + return _finalize(False) # Per-file schema validation → populate self.issues (fixme-like) for file in validator.files: @@ -232,7 +262,7 @@ def validate(self, max_errors=20) -> ValidationResult: break if self.errors: - return ValidationResult(False, self.errors, self.issues) + return _finalize(False) # Load GeoDataFrames for integrity checks for file in validator.files: @@ -414,9 +444,9 @@ def validate(self, max_errors=20) -> ValidationResult: break if self.errors: - return ValidationResult(False, self.errors, self.issues) + return _finalize(False) else: - return ValidationResult(True, [], self.issues) + return _finalize(True, []) except Exception as e: self.log_errors( @@ -425,7 +455,7 @@ def validate(self, max_errors=20) -> ValidationResult: feature_index=None ) traceback.print_exc() - return ValidationResult(False, self.errors, self.issues) + return _finalize(False) finally: # Cleanup extracted files try: @@ -488,6 +518,37 @@ def validate_osw_errors(self, file_path: str, max_errors: int) -> bool: except OSError: return False + filename = os.path.basename(file_path) + + # Upfront guard: reject null/NaN values in feature properties. + # This runs before schema validation to surface data quality issues first. + features = geojson_data.get("features", []) if isinstance(geojson_data, dict) else [] + found_nullish = False + for idx, feature in enumerate(features): + if not isinstance(feature, dict): + continue + props = feature.get("properties") + if not isinstance(props, dict): + continue + bad_paths = self._collect_nullish_property_paths(props) + for path, bad_value in bad_paths: + if len(self.errors) >= max_errors: + return False + found_nullish = True + rendered = f'"{bad_value}"' if isinstance(bad_value, str) else str(bad_value) + msg = ( + f"Invalid value at '{path}': {rendered}. " + f"Null/NaN placeholders are not allowed; provide a valid value or remove this property." + ) + self.errors.append(f"Validation error: {msg}") + self.issues.append({ + "filename": filename, + "feature_index": idx, + "error_message": [msg], + }) + if found_nullish: + return False + schema_url = geojson_data.get('$schema') if isinstance(schema_url, str) and '0.2/schema.json' in schema_url: reasons = self._contains_disallowed_features_for_02(geojson_data) @@ -518,15 +579,9 @@ def validate_osw_errors(self, file_path: str, max_errors: int) -> bool: schema = self.load_osw_schema(schema_path) validator = jsonschema_rs.Draft7Validator(schema) - filename = os.path.basename(file_path) - - # Per-feature best error accumulator (streaming) - # feature_idx -> (rank_tuple, error_obj) - best_by_feature: Dict[Optional[int], Tuple[tuple, Any]] = {} - feature_order: List[Optional[int]] = [] # preserve first-seen order - # Legacy cap legacy_count = 0 + collected_issues: List[Dict[str, Any]] = [] # --- STREAM over errors; STOP as soon as legacy hits the cap --- for err in validator.iter_errors(geojson_data): @@ -539,26 +594,28 @@ def validate_osw_errors(self, file_path: str, max_errors: int) -> bool: # We've reached the legacy cap; stop work to match original performance break - # Track the best error per feature + # Keep every issue (no per-feature collapsing) fidx = _feature_index_from_error(err) - r = _rank_for(err) - prev = best_by_feature.get(fidx) - if prev is None: - best_by_feature[fidx] = (r, err) - feature_order.append(fidx) - else: - if r < prev[0]: - best_by_feature[fidx] = (r, err) - - # Build per-feature issues (one concise message per feature) in first-seen order - for fidx in feature_order: - _, best_err = best_by_feature[fidx] - pretty = _pretty_message(best_err, schema) - self.issues.append({ + collected_issues.append({ "filename": filename, "feature_index": fidx if fidx is not None else -1, - "error_message": [pretty], + "error_message": [_pretty_message(err, schema)], + "_kind": _err_kind(err), }) + # Drop noisy AnyOf summaries when specific field-level errors exist + # for the same feature. + has_specific_by_feature: Dict[int, bool] = {} + for issue in collected_issues: + fidx = issue["feature_index"] + if issue.get("_kind") != "AnyOf": + has_specific_by_feature[fidx] = True + + for issue in collected_issues: + if issue.get("_kind") == "AnyOf" and has_specific_by_feature.get(issue["feature_index"], False): + continue + issue.pop("_kind", None) + self.issues.append(issue) + # Mirror original boolean behavior: False when we exactly hit the cap return len(self.errors) < max_errors diff --git a/src/python_osw_validation/helpers.py b/src/python_osw_validation/helpers.py index 68cc0f3..4f971b0 100644 --- a/src/python_osw_validation/helpers.py +++ b/src/python_osw_validation/helpers.py @@ -4,6 +4,8 @@ _ADDITIONAL_PROPERTIES_RE = re.compile( r"Additional properties are not allowed \('(?P[^']+)' was unexpected\)" ) +_ENUM_RE = re.compile(r'^(?P.+?) is not one of (?P.+)$') +_TYPE_RE = re.compile(r'^(?P.+?) is not of type (?P.+)$') def _add_additional_properties_hint(msg: str) -> str: @@ -45,6 +47,118 @@ def _clean_enum_message(err) -> str: msg = re.sub(r"\s*or\s+\d+\s+other candidates", "", msg) return msg.split("\n")[0] +def _friendly_enum_message(err, schema=None) -> str: + raw = _clean_enum_message(err) + match = _ENUM_RE.match(raw) + if not match: + return raw + + path = list(getattr(err, "instance_path", []) or []) + field = path[-1] if path and isinstance(path[-1], str) else "value" + got = match.group("got").strip('"') + allowed = match.group("allowed") + values = [] + if schema is not None: + try: + node = schema + for seg in list(getattr(err, "schema_path", []) or []): + node = node[seg] + if isinstance(node, list): + values = [str(v) for v in node] + elif isinstance(node, dict) and isinstance(node.get("enum"), list): + values = [str(v) for v in node["enum"]] + except Exception: + values = [] + if not values: + values = re.findall(r'"([^"]+)"', allowed) + if values: + shown = values[:5] + allowed_text = "|".join(shown) + if len(values) > 5: + allowed_text = f"{allowed_text}| and {len(values) - 5} more" + else: + allowed_text = allowed + return ( + f"Invalid value at '{field}': '{got}'. " + f"Acceptable values can be one of {allowed_text}, provide a valid value and retry again." + ) + +def _friendly_type_message(err, schema) -> Optional[str]: + raw = (getattr(err, "message", "") or "").split("\n")[0] + if not _TYPE_RE.match(raw): + return None + + path = list(getattr(err, "instance_path", []) or []) + field = path[-1] if path and isinstance(path[-1], str) else "value" + type_match = _TYPE_RE.match(raw) + got = type_match.group("got") + expected_type = type_match.group("type").strip('"') + + try: + node = schema + for seg in list(getattr(err, "schema_path", []) or []): + node = node[seg] + + parent = None + schema_path = list(getattr(err, "schema_path", []) or []) + if schema_path: + parent = schema + for seg in schema_path[:-1]: + parent = parent[seg] + + if isinstance(parent, dict) and isinstance(parent.get("enum"), list): + enum_values = list(parent["enum"]) + shown = enum_values[:5] + allowed = "|".join(str(v) for v in shown) + if len(enum_values) > 5: + allowed = f"{allowed}| and {len(enum_values) - 5} more" + cleaned_got = got.strip('"') + return ( + f"Invalid value at '{field}': '{cleaned_got}'. " + f"Acceptable values can be one of {allowed}, provide a valid value and retry again." + ) + except Exception: + pass + + cleaned_got = got.strip('"') + return ( + f"Invalid value at '{field}': '{cleaned_got}' . " + f"Acceptable datatype is {expected_type} ; provide a valid value and retry" + ) + +def _has_enum_context(err, schema) -> bool: + """True when this error is an enum mismatch or type mismatch on an enum-constrained field.""" + if _err_kind(err) == "Enum": + return True + if _err_kind(err) != "Type": + return False + return _friendly_type_message(err, schema) is not None + + +def _instance_path_str(err) -> str: + """Render jsonschema instance path as a readable JSON path.""" + path = list(getattr(err, "instance_path", []) or []) + if not path: + return "" + + parts = [] + for seg in path: + if isinstance(seg, int): + if parts: + parts[-1] = f"{parts[-1]}[{seg}]" + else: + parts.append(f"[{seg}]") + else: + parts.append(str(seg)) + return ".".join(parts) + + +def _with_path(err, msg: str) -> str: + path = _instance_path_str(err) + if not path: + return msg + return f"{msg} (at: {path})" + def _pretty_message(err, schema) -> str: """ @@ -58,7 +172,12 @@ def _pretty_message(err, schema) -> str: kind = _err_kind(err) if kind == "Enum": - return _add_additional_properties_hint(_clean_enum_message(err)) + return _add_additional_properties_hint(_friendly_enum_message(err, schema)) + + if kind == "Type": + friendly_type = _friendly_type_message(err, schema) + if friendly_type: + return _add_additional_properties_hint(friendly_type) if kind == "AnyOf": # Follow schema_path to the anyOf node; union of 'required' keys in branches. @@ -85,24 +204,32 @@ def crawl(node): if required: props = ", ".join(sorted(required)) - return _add_additional_properties_hint(f"must include one of: {props}") + return _with_path(err, _add_additional_properties_hint(f"must include one of: {props}")) except Exception: pass # Default: first line from library message + friendly_enum = _friendly_enum_message(err, schema) + if friendly_enum != _clean_enum_message(err): + return _add_additional_properties_hint(friendly_enum) + + friendly_type = _friendly_type_message(err, schema) + if friendly_type: + return _add_additional_properties_hint(friendly_type) + default_msg = (getattr(err, "message", "") or "").split("\n")[0] - return _add_additional_properties_hint(default_msg) + return _with_path(err, _add_additional_properties_hint(default_msg)) def _rank_for(err) -> tuple: """ Ranking for 'best' error per feature. - Prefer Enum > (Type/Required/Const) > (Pattern/Minimum/Maximum) > others. + Prefer Type/Required/Const > Enum > (Pattern/Minimum/Maximum) > others. """ kind = _err_kind(err) order = ( - 0 if kind == "Enum" else - 1 if kind in {"Type", "Required", "Const"} else + 0 if kind in {"Type", "Required", "Const"} else + 1 if kind == "Enum" else 2 if kind in {"Pattern", "Minimum", "Maximum"} else 3 ) diff --git a/src/python_osw_validation/version.py b/src/python_osw_validation/version.py index 4596d03..d93912e 100644 --- a/src/python_osw_validation/version.py +++ b/src/python_osw_validation/version.py @@ -1 +1 @@ -__version__ = '0.3.6' +__version__ = '0.3.7' diff --git a/tests/assets/issue_3297.zip b/tests/assets/issue_3297.zip new file mode 100644 index 0000000..b1c2328 Binary files /dev/null and b/tests/assets/issue_3297.zip differ diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index 63c010f..9e604b4 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -73,11 +73,15 @@ def test_no_noise_no_change(self): # ----- tests for _pretty_message ---------------------------------------------- class TestPrettyMessage(unittest.TestCase): - def test_enum_compacts_message(self): + def test_enum_formats_human_readable_field_message(self): KindEnum = type("Kind_Enum", (), {}) e = FakeErr(kind=KindEnum(), - message="not in allowed set or 3 other candidates\nignore this") - self.assertEqual(helpers._pretty_message(e, schema={}), "not in allowed set") + instance_path=["features", 0, "properties", "climb"], + message='"null" is not one of "down" or "up" or 3 other candidates\nignore this') + self.assertEqual( + helpers._pretty_message(e, schema={}), + "Invalid value at 'climb': 'null'. Acceptable values can be one of down|up, provide a valid value and retry again.", + ) def test_anyof_unions_required_fields(self): # Build a schema reachable via schema_path with anyOf/allOf nesting @@ -110,6 +114,40 @@ def test_default_first_line_from_message(self): e = FakeErr(kind=None, validator=None, message="first line only\nsecond line ignored") self.assertEqual(helpers._pretty_message(e, schema={}), "first line only") + def test_type_with_enum_parent_uses_enum_style_message(self): + KindType = type("Kind_Type", (), {}) + schema = { + "properties": { + "climb": { + "enum": ["down", "up"], + "type": "string", + } + } + } + e = FakeErr( + kind=KindType(), + instance_path=["features", 0, "properties", "climb"], + schema_path=["properties", "climb", "type"], + message='"null" is not of type "string"', + ) + self.assertEqual( + helpers._pretty_message(e, schema=schema), + "Invalid value at 'climb': 'null'. Acceptable values can be one of down|up, provide a valid value and retry again.", + ) + + def test_type_formats_requires_value_message(self): + KindType = type("Kind_Type", (), {}) + e = FakeErr( + kind=KindType(), + instance_path=["features", 0, "properties", "step_count"], + schema_path=["properties", "step_count", "type"], + message='"null" is not of type "integer"', + ) + self.assertEqual( + helpers._pretty_message(e, schema={"properties": {"step_count": {"type": "integer"}}}), + "Invalid value at 'step_count': 'null' . Acceptable datatype is integer ; provide a valid value and retry", + ) + def test_additional_properties_hint_is_applied(self): msg = "Additional properties are not allowed ('bar' was unexpected)" e = FakeErr(kind=None, validator=None, message=msg) @@ -130,8 +168,8 @@ def test_ordering_by_kind(self): e_pat = FakeErr(kind=KPat(), message="m3") e_other = FakeErr(kind=KOther(), message="m4") - self.assertLess(helpers._rank_for(e_enum), helpers._rank_for(e_req)) - self.assertLess(helpers._rank_for(e_req), helpers._rank_for(e_pat)) + self.assertLess(helpers._rank_for(e_req), helpers._rank_for(e_enum)) + self.assertLess(helpers._rank_for(e_enum), helpers._rank_for(e_pat)) self.assertLess(helpers._rank_for(e_pat), helpers._rank_for(e_other)) def test_tiebreaker_shorter_message_is_better(self): diff --git a/tests/unit_tests/test_osw_validation.py b/tests/unit_tests/test_osw_validation.py index 2cb1b43..bfb85bb 100644 --- a/tests/unit_tests/test_osw_validation.py +++ b/tests/unit_tests/test_osw_validation.py @@ -39,6 +39,7 @@ def setUp(self): self.valid_osw_file = os.path.join(ASSETS_PATH, 'wa.bellevue.zip') self.invalid_v_id_file = os.path.join(ASSETS_PATH, '4151.zip') self.task_3469_file = os.path.join(ASSETS_PATH, 'task_3469.zip') + self.issue_3297_file = os.path.join(ASSETS_PATH, 'issue_3297.zip') self.serialization_file = os.path.join(ASSETS_PATH, 'test_serialization_error.zip') self.schema_file_path = SCHEMA_FILE_PATH self.schema_paths = SCHEMA_PATHS @@ -270,16 +271,36 @@ def test_unmatched_ids_limited_to_20(self): def test_task_3469_issue_payload(self): validation = OSWValidation(zipfile_path=self.task_3469_file) - result = validation.validate() + result = validation.validate(max_errors=500) + self.assertFalse(result.is_valid) + self.assertIsInstance(result.issues, list) + self.assertGreater(len(result.issues), 0) + flattened = " | ".join(issue["error_message"][0] for issue in result.issues) + self.assertIn("Invalid value at 'climb': \"null\".", flattened) + + def test_issues_respect_max_errors_override(self): + validation = OSWValidation(zipfile_path=self.task_3469_file) + default_result = validation.validate() + self.assertFalse(default_result.is_valid) + self.assertEqual(len(default_result.issues), 20) + + validation = OSWValidation(zipfile_path=self.task_3469_file) + override_result = validation.validate(max_errors=500) + self.assertFalse(override_result.is_valid) + self.assertGreater(len(override_result.issues), 20) + + def test_issue_3297_issue_payload(self): + validation = OSWValidation(zipfile_path=self.issue_3297_file) + result = validation.validate(max_errors=100) self.assertFalse(result.is_valid) - self.assertEqual( - result.issues, - [{ - 'filename': 'FIFA_sidewalks.edges.geojson', - 'feature_index': 0, - 'error_message': ['"null" is not one of "down" or "up"'], - }] - ) + self.assertEqual(len(result.issues), 3) + + flattened = " | ".join(issue["error_message"][0] for issue in result.issues) + self.assertIn("Invalid value at 'climb': 'abc'.", flattened) + self.assertIn("Acceptable values can be one of down|up", flattened) + self.assertIn("Invalid value at 'crossing:markings': 'sss'.", flattened) + self.assertIn("Acceptable values can be one of dashes|dots|ladder|ladder:paired|ladder:skewed| and 14 more", flattened) + self.assertIn("Invalid value at 'step_count': 'test' . Acceptable datatype is integer ; provide a valid value and retry", flattened) def test_jsonschema_rs_pin_is_0_33_0(self): requirements_path = os.path.join(SRC_DIR, 'requirements.txt') diff --git a/tests/unit_tests/test_osw_validation_extras.py b/tests/unit_tests/test_osw_validation_extras.py index 5946df8..88aeaba 100644 --- a/tests/unit_tests/test_osw_validation_extras.py +++ b/tests/unit_tests/test_osw_validation_extras.py @@ -2,6 +2,7 @@ import os import tempfile import unittest +import math from unittest.mock import patch, MagicMock import pandas as pd import geopandas as gpd @@ -91,6 +92,38 @@ def test_structure_error_uses_uploaded_filename(self): self.assertEqual(issue["filename"], os.path.basename(upload_path)) self.assertIn("bad structure", issue["error_message"]) + def test_nullish_values_fail_before_schema_validation(self): + validator = OSWValidation(zipfile_path="dummy.zip") + geojson_data = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": { + "step_count": None, + "width": math.nan, + }, + "geometry": {"type": "LineString", "coordinates": [[0, 0], [1, 1]]}, + } + ], + } + + with patch.object(validator, "load_osw_file", return_value=geojson_data), \ + patch.object(validator, "pick_schema_for_file") as pick_schema_mock: + ok = validator.validate_osw_errors("/tmp/FIFA_sidewalks.edges.geojson", max_errors=20) + + self.assertFalse(ok) + pick_schema_mock.assert_not_called() + self.assertEqual(len(validator.issues), 2) + self.assertEqual( + validator.issues[0]["error_message"], + ["Invalid value at 'step_count': None. Null/NaN placeholders are not allowed; provide a valid value or remove this property."], + ) + self.assertEqual( + validator.issues[1]["error_message"], + ["Invalid value at 'width': nan. Null/NaN placeholders are not allowed; provide a valid value or remove this property."], + ) + def test_missing_u_id_reports_error_without_keyerror(self): """Edges missing `_u_id` should report a friendly error instead of raising KeyError.""" fake_files = ["/tmp/nodes.geojson", "/tmp/edges.geojson"]