From e1f7cacd000719fe52fb4c663ec49e5a54a27590 Mon Sep 17 00:00:00 2001 From: VXNCXNX Date: Sat, 15 Aug 2026 16:58:09 +0000 Subject: [PATCH 1/2] fix(csvjson): handle null coordinates and omit empty bbox Catch TypeError in addition to ValueError when parsing coordinates, guard geometry membership test with feature.get(), and omit bbox when no coordinates are present. Fixes errors on rows with blank/unparseable lat/lon values. --- CHANGELOG.rst | 1 + csvkit/utilities/csvjson.py | 10 +++++++--- examples/test_geo_no_coordinates.csv | 3 +++ examples/test_geo_null.csv | 3 +++ tests/test_utilities/test_csvjson.py | 15 +++++++++++++++ 5 files changed, 29 insertions(+), 3 deletions(-) create mode 100644 examples/test_geo_no_coordinates.csv create mode 100644 examples/test_geo_null.csv diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 5fdf4a1a3..a7c0809f5 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,7 @@ Unreleased - feat: :doc:`/scripts/csvclean` adds a :code:`--remove-empty-columns` option to remove empty columns from standard output. - feat: :doc:`/scripts/in2csv` guesses the ``ndjson`` format for files with :code:`.ndjson`, :code:`.jsonl` and :code:`.jl` extensions. - fix: :code:`-C/--not-columns` now excludes the last column of an open-ended range (e.g. :code:`2-`). +- fix: :doc:`/scripts/csvjson` no longer errors on a row with a blank or unparseable :code:`--lat`/:code:`--lon` value, and instead writes a :code:`null` geometry for that feature. The :code:`bbox` member is omitted if no row has coordinates. 2.2.0 - December 15, 2025 ------------------------- diff --git a/csvkit/utilities/csvjson.py b/csvkit/utilities/csvjson.py index 2cd52b985..b951dfea2 100644 --- a/csvkit/utilities/csvjson.py +++ b/csvkit/utilities/csvjson.py @@ -201,7 +201,8 @@ def generate_feature_collection(self, table): ('features', features), ] - if not self.args.no_bbox: + # a bbox of nulls is not valid GeoJSON, and bbox is optional + if not self.args.no_bbox and bounds.is_set(): items.insert(1, ('bbox', bounds.bbox())) if self.args.crs: @@ -244,7 +245,7 @@ def geometry_for_row(self, row): try: lon = float(row[self.lon_column]) lat = float(row[self.lat_column]) - except ValueError: + except (TypeError, ValueError): lon = None lat = None @@ -261,11 +262,14 @@ def __init__(self): self.max_lon = None self.max_lat = None + def is_set(self): + return self.min_lon is not None and self.min_lat is not None + def bbox(self): return [self.min_lon, self.min_lat, self.max_lon, self.max_lat] def add_feature(self, feature): - if 'geometry' in feature and 'coordinates' in feature['geometry']: + if feature.get('geometry') and 'coordinates' in feature['geometry']: self.update_coordinates(feature['geometry']['coordinates']) def update_lat(self, lat): diff --git a/examples/test_geo_no_coordinates.csv b/examples/test_geo_no_coordinates.csv new file mode 100644 index 000000000..b23e853ec --- /dev/null +++ b/examples/test_geo_no_coordinates.csv @@ -0,0 +1,3 @@ +slug,latitude,longitude +unknown,, +also-unknown,, diff --git a/examples/test_geo_null.csv b/examples/test_geo_null.csv new file mode 100644 index 000000000..1aa826542 --- /dev/null +++ b/examples/test_geo_null.csv @@ -0,0 +1,3 @@ +slug,latitude,longitude +tyler-museum,32.33396,-95.28174 +unknown,, diff --git a/tests/test_utilities/test_csvjson.py b/tests/test_utilities/test_csvjson.py index 00a7b0a45..73b9bf261 100644 --- a/tests/test_utilities/test_csvjson.py +++ b/tests/test_utilities/test_csvjson.py @@ -126,6 +126,21 @@ def test_geojson_point(self): self.assertIsInstance(geometry['coordinates'][0], float) self.assertIsInstance(geometry['coordinates'][1], float) + def test_geojson_missing_coordinates(self): + geojson = json.loads(self.get_output(['--lat', 'latitude', '--lon', 'longitude', + 'examples/test_geo_null.csv'])) + + self.assertEqual(geojson['bbox'], [-95.28174, 32.33396, -95.28174, 32.33396]) + self.assertEqual(len(geojson['features']), 2) + self.assertIsNone(geojson['features'][1]['geometry']) + + def test_geojson_no_coordinates_at_all(self): + geojson = json.loads(self.get_output(['--lat', 'latitude', '--lon', 'longitude', + 'examples/test_geo_no_coordinates.csv'])) + + # every row has a null geometry, so there is no bbox to report + self.assertNotIn('bbox', geojson) + def test_geojson_shape(self): geojson = json.loads(self.get_output(['--lat', 'latitude', '--lon', 'longitude', '--type', 'type', '--geometry', 'geojson', 'examples/test_geojson.csv'])) From 87358b67e4a0e5802a15e791db26332c9c8adb09 Mon Sep 17 00:00:00 2001 From: VXNCXNX Date: Sat, 29 Aug 2026 06:58:01 +0000 Subject: [PATCH 2/2] fix(csvjson): keep a lat/lon coordinate of zero geometry_for_row tested the coordinates for truthiness, so a latitude or longitude of 0 was treated as absent. On master that produced a TypeError once the bbox accumulator reached the None geometry; with the null-geometry handling in this branch it would instead have silently written geometry: null for a valid location. The equator, the prime meridian and null island are real coordinates, so test against None. --- CHANGELOG.rst | 1 + csvkit/utilities/csvjson.py | 2 +- examples/test_geo_zero.csv | 4 ++++ tests/test_utilities/test_csvjson.py | 11 +++++++++++ 4 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 examples/test_geo_zero.csv diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a7c0809f5..a50748205 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,6 +6,7 @@ Unreleased - feat: :doc:`/scripts/in2csv` guesses the ``ndjson`` format for files with :code:`.ndjson`, :code:`.jsonl` and :code:`.jl` extensions. - fix: :code:`-C/--not-columns` now excludes the last column of an open-ended range (e.g. :code:`2-`). - fix: :doc:`/scripts/csvjson` no longer errors on a row with a blank or unparseable :code:`--lat`/:code:`--lon` value, and instead writes a :code:`null` geometry for that feature. The :code:`bbox` member is omitted if no row has coordinates. +- fix: :doc:`/scripts/csvjson` no longer discards a :code:`--lat`/:code:`--lon` coordinate of :code:`0`, such as the equator or the prime meridian. 2.2.0 - December 15, 2025 ------------------------- diff --git a/csvkit/utilities/csvjson.py b/csvkit/utilities/csvjson.py index b951dfea2..17213e318 100644 --- a/csvkit/utilities/csvjson.py +++ b/csvkit/utilities/csvjson.py @@ -249,7 +249,7 @@ def geometry_for_row(self, row): lon = None lat = None - if lon and lat: + if lon is not None and lat is not None: return OrderedDict([ ('type', 'Point'), ('coordinates', [lon, lat]), diff --git a/examples/test_geo_zero.csv b/examples/test_geo_zero.csv new file mode 100644 index 000000000..f766b31de --- /dev/null +++ b/examples/test_geo_zero.csv @@ -0,0 +1,4 @@ +slug,latitude,longitude +null-island,0,0 +greenwich,51.48,0 +equator,0,2.35 diff --git a/tests/test_utilities/test_csvjson.py b/tests/test_utilities/test_csvjson.py index 73b9bf261..583dfd408 100644 --- a/tests/test_utilities/test_csvjson.py +++ b/tests/test_utilities/test_csvjson.py @@ -134,6 +134,17 @@ def test_geojson_missing_coordinates(self): self.assertEqual(len(geojson['features']), 2) self.assertIsNone(geojson['features'][1]['geometry']) + def test_geojson_zero_coordinates(self): + geojson = json.loads(self.get_output(['--lat', 'latitude', '--lon', 'longitude', + 'examples/test_geo_zero.csv'])) + + # a zero coordinate is a real location (null island, Greenwich, the equator) + self.assertEqual(geojson['bbox'], [0.0, 0.0, 2.35, 51.48]) + self.assertEqual(len(geojson['features']), 3) + self.assertEqual(geojson['features'][0]['geometry']['coordinates'], [0.0, 0.0]) + self.assertEqual(geojson['features'][1]['geometry']['coordinates'], [0.0, 51.48]) + self.assertEqual(geojson['features'][2]['geometry']['coordinates'], [2.35, 0.0]) + def test_geojson_no_coordinates_at_all(self): geojson = json.loads(self.get_output(['--lat', 'latitude', '--lon', 'longitude', 'examples/test_geo_no_coordinates.csv']))