diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 5fdf4a1a3..a50748205 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,8 @@ 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. +- 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 2cd52b985..17213e318 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,11 +245,11 @@ 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 - if lon and lat: + if lon is not None and lat is not None: return OrderedDict([ ('type', 'Point'), ('coordinates', [lon, lat]), @@ -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/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 00a7b0a45..583dfd408 100644 --- a/tests/test_utilities/test_csvjson.py +++ b/tests/test_utilities/test_csvjson.py @@ -126,6 +126,32 @@ 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_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'])) + + # 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']))