├── tests ├── __init__.py └── test_logic.py ├── geojson_shave ├── __init__.py └── geojson_shave.py ├── .github └── workflows │ └── tests.yml ├── pyproject.toml ├── LICENSE ├── .gitignore └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Unit test package for geojson_shave.""" 2 | -------------------------------------------------------------------------------- /geojson_shave/__init__.py: -------------------------------------------------------------------------------- 1 | """Top-level package for geojson-shave.""" 2 | 3 | __author__ = """Ben Nour""" 4 | __email__ = "hello@ben-nour.com" 5 | __version__ = "0.2.0" 6 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Set up Python ${{ matrix.python-version }} 17 | uses: actions/setup-python@v4 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | - name: Install dependencies 21 | run: | 22 | pip install -U pip 23 | pip install -U alive-progress humanize coverage 24 | - name: Tests 25 | run: | 26 | coverage run -m tests.test_logic 27 | - name: Upload coverage reports to Codecov 28 | uses: codecov/codecov-action@v4.0.1 29 | with: 30 | token: ${{ secrets.CODECOV_TOKEN }} 31 | slug: ben-n93/geojson-shave 32 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "geojson-shave" 7 | description = "A command-line tool for reducing the size of GeoJSON files." 8 | authors = [ 9 | { name = "Ben Nour", email = "hello@ben-nour.com" } 10 | ] 11 | version = "0.2.0" 12 | dependencies = [ 13 | "alive-progress~=3.1.5", 14 | "humanize~=4.9.0" 15 | ] 16 | classifiers = [ 17 | "License :: OSI Approved :: MIT License", 18 | "Programming Language :: Python :: 3", 19 | "Programming Language :: Python :: 3.8", 20 | "Programming Language :: Python :: 3.9", 21 | "Programming Language :: Python :: 3.10", 22 | "Programming Language :: Python :: 3.11", 23 | "Programming Language :: Python :: 3.12" 24 | ] 25 | keywords = ["geojson"] 26 | license = { file = "LICENSE" } 27 | readme = "README.md" 28 | requires-python = ">=3.8" 29 | 30 | [project.urls] 31 | homepage = "https://github.com/ben-n93/geojson-shave" 32 | repository = "https://github.com/ben-n93/geojson-shave" 33 | 34 | [project.scripts] 35 | geojson-shave = "geojson_shave.geojson_shave:main" 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024, Ben Nour 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | 58 | # Flask stuff: 59 | instance/ 60 | .webassets-cache 61 | 62 | # Scrapy stuff: 63 | .scrapy 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # Jupyter Notebook 72 | .ipynb_checkpoints 73 | 74 | # pyenv 75 | .python-version 76 | 77 | # celery beat schedule file 78 | celerybeat-schedule 79 | 80 | # SageMath parsed files 81 | *.sage.py 82 | 83 | # dotenv 84 | .env 85 | 86 | # virtualenv 87 | .venv 88 | venv/ 89 | ENV/ 90 | 91 | # Spyder project settings 92 | .spyderproject 93 | .spyproject 94 | 95 | # Rope project settings 96 | .ropeproject 97 | 98 | # mkdocs documentation 99 | /site 100 | 101 | # mypy 102 | .mypy_cache/ 103 | 104 | # IDE settings 105 | .vscode/ 106 | .idea/ 107 | 108 | .DS_Store -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

3 | 4 | GeoJSON-shave 5 | 6 |

7 | 8 |

9 | Testing 10 | License 11 | 12 | versions 13 | awesome list badge 14 |

15 | 16 | --- 17 | 18 | geojson-shave reduces the size of GeoJSON files by: 19 | 20 | - Reducing the precision of latitude/longitude coordinates to the specified decimal places. 21 | - Eliminating unnecessary whitespace. 22 | - (Optionally) replacing the properties key's value with null/empty dictionary. 23 | 24 | This tool assumes that your GeoJSON file conforms to the [RFC 7946](https://datatracker.ietf.org/doc/html/rfc7946). 25 | 26 | Please be aware that when you use fewer decimal places you can lose some accuracy. _"The fifth decimal place is worth up to 1.1 m: it distinguish trees from each other"_ - read more [here](https://gis.stackexchange.com/questions/8650/measuring-accuracy-of-latitude-and-longitude). 27 | 28 | ## Installation 29 | ``` 30 | $ pip install geojson-shave 31 | ``` 32 | 33 | ## Usage 34 | 35 |

36 | 37 | GeoJSON-shave-demo 38 | 39 |

40 | 41 | Simply pass the file path of your GeoJSON file and it will truncuate the coordinates to 5 decimal places, outputing to the current working directory: 42 | 43 | ``` 44 | $ geojson-shave roads.geoson 45 | ``` 46 | 47 | Alternatively you can specify the number of decimal points you want the coordinates truncuated to: 48 | 49 | ``` 50 | $ geojson-shave roads.geojson -d 3 51 | ``` 52 | 53 | You can also specify if you only want certain Geometry object types in the file to be processed: 54 | 55 | ``` 56 | $ geojson-shave roads.geojson -g LineString Polygon 57 | ``` 58 | 59 | Note that the -g option doesn't apply to objects nested within Geometry Collection. 60 | 61 | And to reduce the file size even further you can nullify the property value of Feature objects: 62 | 63 | ``` 64 | $ geojson-shave roads.geojson -p 65 | ``` 66 | 67 | Or select a positive list of properties to keep: 68 | 69 | ``` 70 | $ geojson-shave roads.geojson -kp id,name,level 71 | ``` 72 | 73 | Output to a directory other than the current working directory: 74 | 75 | ``` 76 | $ geojson-shave roads.geojson -o ../data/output.geojson 77 | ``` 78 | -------------------------------------------------------------------------------- /geojson_shave/geojson_shave.py: -------------------------------------------------------------------------------- 1 | """A command-line tool that reduces the size of GeoJSON files. 2 | """ 3 | 4 | import argparse 5 | from contextlib import suppress 6 | import json 7 | import pathlib 8 | 9 | from alive_progress import alive_bar 10 | import humanize 11 | 12 | GEOMETRY_OBJECTS = { 13 | "Point", 14 | "MultiPoint", 15 | "LineString", 16 | "MultiLineString", 17 | "Polygon", 18 | "MultiPolygon", 19 | "GeometryCollection", 20 | } 21 | 22 | 23 | def get_parser(): 24 | """Create the command-line interface.""" 25 | parser = argparse.ArgumentParser( 26 | description="""Reduces the size of a GeoJSON file by lowering the 27 | decimal point precision of the file's latitude/language 28 | coordinates.""", 29 | epilog=""" 30 | EXAMPLES 31 | -------- 32 | Truncuate a GeoJSON's file to 3 decimal points: 33 | geojson_shave roads.geojson -d 3 34 | 35 | Only truncuate the coordinates of LineString and Polygon objects: 36 | geojson_shave roads.geojson -g LineString Polygon 37 | 38 | Replace the properties value with a null value: 39 | geojson_shave roads.geojson -p 40 | """, 41 | formatter_class=argparse.RawTextHelpFormatter, 42 | ) 43 | 44 | parser.add_argument( 45 | "input", 46 | type=argparse.FileType("r"), 47 | help="Input GeoJSON file to pass to the tool.", 48 | ) 49 | 50 | parser.add_argument( 51 | "-o", 52 | "--output", 53 | type=argparse.FileType("w"), 54 | help="""Name and path of the output GeoJSON file. Default path is the 55 | current working directory.""", 56 | default=pathlib.Path.cwd() / "output.geojson", 57 | required=False, 58 | ) 59 | 60 | parser.add_argument( 61 | "-d", 62 | "--decimal_points", 63 | type=int, 64 | help="Number of decimal points to keep when \ 65 | truncating coordinates. Default is 5.", 66 | required=False, 67 | default=5, 68 | ) 69 | 70 | parser.add_argument( 71 | "-p", 72 | "--properties", 73 | help="Overwrite the properties with a null value.", 74 | required=False, 75 | action="store_true", 76 | ) 77 | 78 | parser.add_argument( 79 | "-kp", 80 | "--keep_properties", 81 | help="Comma-separated list of property keys. Delete all others.", 82 | required=False, 83 | type=lambda s: s.split(',') if s else [] 84 | ) 85 | 86 | 87 | parser.add_argument( 88 | "-g", 89 | "--geometry_object", 90 | type=str, 91 | help="""The types of Geometry Object to be processed. 92 | Default is all objects.""", 93 | required=False, 94 | default=GEOMETRY_OBJECTS, 95 | choices=GEOMETRY_OBJECTS, 96 | metavar="GEOMETRY_OBJECT", 97 | nargs="+", 98 | ) 99 | 100 | args = parser.parse_args() 101 | return args 102 | 103 | 104 | def create_coordinates(coordinates, precision): 105 | """Create truncuated coordinates.""" 106 | new_coordinates = [] 107 | for item in coordinates: 108 | if isinstance(item, list): 109 | new_coordinates.append(create_coordinates(item, precision)) 110 | else: 111 | item = round(item, precision) 112 | new_coordinates.append(float(item)) 113 | return new_coordinates 114 | 115 | 116 | def process_geometry_collection(geometry_collection, precision): 117 | """Parse and truncuate the coordinates of each geometry 118 | object nested within a geometry collection.""" 119 | new_geometry_collection = {"type": "GeometryCollection"} 120 | processed_geometry_objects = [] 121 | for geometry_object in geometry_collection["geometries"]: 122 | object_type = geometry_object["type"] 123 | new_coordinates = create_coordinates(geometry_object["coordinates"], precision) 124 | processed_geometry_objects.append( 125 | {"type": object_type, "coordinates": new_coordinates} 126 | ) 127 | 128 | new_geometry_collection["geometries"] = processed_geometry_objects 129 | return new_geometry_collection 130 | 131 | 132 | def process_features(geojson, precision, geometry_to_include, keep_properties): 133 | """Process Feature objects, truncuating coordinates and/or replacing 134 | the properties member with a blank value.""" 135 | # Create new GeoJSON object. 136 | if (total_features := geojson.get("features")) is None: 137 | if geojson.get("type") == "Feature": 138 | output_geojson = {"type": "Feature"} 139 | length = 1 140 | else: 141 | raise ValueError("Error: there are no Feature objects in this file.") 142 | else: 143 | output_geojson = {"type": "FeatureCollection", "features": []} 144 | length = len(total_features) 145 | 146 | # Process Feature objects. 147 | with alive_bar(length) as progress_bar: 148 | progress_bar.title("Processing the input file:") 149 | if geojson["type"] == "FeatureCollection": 150 | for index, feature in enumerate(geojson["features"]): 151 | output_geojson["features"].append(feature) 152 | if keep_properties is not None: 153 | if not keep_properties: 154 | output_geojson["features"][index]["properties"] = {} 155 | else: 156 | for key in output_geojson["features"][index]["properties"].copy(): 157 | if key not in keep_properties: 158 | del output_geojson["features"][index]["properties"][key] 159 | with suppress( 160 | TypeError 161 | ): # Feature's "geometry" member has a null value. 162 | if (geo_type := feature["geometry"]["type"]) in geometry_to_include: 163 | if geo_type == "GeometryCollection": 164 | output_geojson["features"][index]["geometry"] = ( 165 | process_geometry_collection( 166 | feature["geometry"], precision 167 | ) 168 | ) 169 | else: 170 | new_coordinates = create_coordinates( 171 | feature["geometry"]["coordinates"], precision 172 | ) 173 | output_geojson["features"][index]["geometry"][ 174 | "coordinates" 175 | ] = new_coordinates 176 | progress_bar() 177 | 178 | else: # Only one Feature. 179 | if geojson["geometry"]["type"] in geometry_to_include: 180 | new_coordinates = create_coordinates( 181 | geojson["geometry"]["coordinates"], precision 182 | ) 183 | output_geojson["geometry"] = { 184 | "type": geojson["geometry"]["type"], 185 | "coordinates": new_coordinates, 186 | } 187 | if keep_properties is not None: 188 | if not keep_properties: 189 | output_geojson["properties"] = {} 190 | else: 191 | for key in output_geojson["features"][index]["properties"].copy(): 192 | if key not in keep_properties: 193 | del output_geojson["features"][index]["properties"][key] 194 | progress_bar() 195 | 196 | # Including any non-standard (RFC) top-level keys in the output file. 197 | for key in geojson.keys(): 198 | if key not in ("type", "features", "geometry"): 199 | output_geojson[key] = geojson[key] 200 | return output_geojson 201 | 202 | 203 | def main(): 204 | """Launch the command-line tool.""" 205 | args = get_parser() 206 | 207 | if args.decimal_points < 0: 208 | raise ValueError( 209 | """Please only pass a positive number to the decimal argument.""" 210 | ) 211 | 212 | if args.properties is True: 213 | args.keep_properties = [] 214 | 215 | # Process input file. 216 | with open(args.input.name, "r") as input_file: 217 | try: 218 | input_geojson = json.load(input_file) 219 | except json.decoder.JSONDecodeError as e: 220 | raise ValueError("Error: please provide a valid GeoJSON file.") from e 221 | output_geojson = process_features( 222 | input_geojson, args.decimal_points, args.geometry_object, args.keep_properties 223 | ) 224 | 225 | # Write to output file. 226 | with open(args.output.name, "w", encoding="utf-8") as output_file: 227 | print("Writing to output file...") 228 | json.dump(output_geojson, output_file, separators=(",", ":")) 229 | 230 | # Exit message to user. 231 | size_before = pathlib.Path(args.input.name).stat().st_size 232 | size_after = pathlib.Path(args.output.name).stat().st_size 233 | difference = round(((size_before - size_after) / size_before) * 100) 234 | print(f"Input file size: {humanize.naturalsize(size_before)}.") 235 | print(f"Output file size: {humanize.naturalsize(size_after)}.") 236 | print(f"File size reduction: {difference}%") 237 | 238 | 239 | if __name__ == "__main__": 240 | main() 241 | -------------------------------------------------------------------------------- /tests/test_logic.py: -------------------------------------------------------------------------------- 1 | """Unit tests for geojson_shave.py""" 2 | 3 | import unittest 4 | from unittest import mock 5 | 6 | from geojson_shave.geojson_shave import ( 7 | create_coordinates, 8 | process_geometry_collection, 9 | process_features, 10 | GEOMETRY_OBJECTS, 11 | main, 12 | ) 13 | 14 | 15 | class TestMain(unittest.TestCase): 16 | """Tests for the main function.""" 17 | 18 | @mock.patch("geojson_shave.geojson_shave.get_parser") 19 | def test_decimal_points_less_than_zero(self, cli): 20 | """Test that passing a negative number to the 21 | decimal_points option raises a ValueError. 22 | """ 23 | cli.return_value = mock.Mock(decimal_points=-1) 24 | with self.assertRaises(ValueError): 25 | main() 26 | 27 | 28 | class TestCreateCoordinates(unittest.TestCase): 29 | """Tests for the create_coordinates function. 30 | 31 | Note that both LineStrings' and MultiPoints' coordinates both consist 32 | of an array of Points, hence only the LineString being tested. 33 | """ 34 | 35 | def setUp(self): 36 | self.point = { 37 | "type": "Point", 38 | "coordinates": [-100.123456, 200.123456], 39 | "properties": {"name": "Test Point"}, 40 | } 41 | 42 | self.linestring = { 43 | "type": "LineString", 44 | "coordinates": [ 45 | [100.123456, 0.123456], 46 | [101.123456, 1.123456], 47 | [102.123456, 2.123456], 48 | ], 49 | } 50 | 51 | self.multilinestring = { 52 | "type": "MultiLineString", 53 | "coordinates": [ 54 | [[-100.123456, 0.123456], [-101.123456, 1.123456]], 55 | [[102.123456, 2.123456], [103.123456, 3.123456]], 56 | ], 57 | } 58 | 59 | self.polygon = { 60 | "type": "Polygon", 61 | "coordinates": [ 62 | [ 63 | [100.123456, 0.123456], 64 | [101.123456, 0.123456], 65 | [101.123456, 1.123456], 66 | [100.123456, 1.123456], 67 | [100.123456, 0.123456], 68 | ] 69 | ], 70 | } 71 | 72 | self.multipolygon = { 73 | "type": "MultiPolygon", 74 | "coordinates": [ 75 | [ 76 | [ 77 | [102.123456, 2.123456], 78 | [103.123456, 2.123456], 79 | [103.123456, 3.123456], 80 | [102.123456, 3.123456], 81 | [102.123456, 2.123456], 82 | ] 83 | ], 84 | [ 85 | [ 86 | [100.123456, 0.123456], 87 | [101.123456, 0.123456], 88 | [101.123456, 1.123456], 89 | [100.123456, 1.123456], 90 | [100.123456, 0.123456], 91 | ], 92 | [ 93 | [100.123456, 0.123456], 94 | [100.123456, 0.123456], 95 | [100.123456, 0.123456], 96 | [100.123456, 0.123456], 97 | [100.123456, 0.123456], 98 | ], 99 | ], 100 | ], 101 | } 102 | 103 | # Point. 104 | def test_truncuating_point_coordinates(self): 105 | """Test that Point coordinates are truncuated succesfully.""" 106 | expected_return_value = [-100.123, 200.123] 107 | precision = 3 108 | geometry_coordinates = self.point["coordinates"] 109 | self.assertEqual( 110 | create_coordinates(geometry_coordinates, precision), expected_return_value 111 | ) 112 | 113 | def test_point_same_precision(self): 114 | """Test that Point coordinates are not truncuated when 115 | the coordinate decimal points matches what's passed 116 | to the precision parameter. 117 | """ 118 | expected_return_value = self.point["coordinates"] 119 | precision = 6 120 | geometry_coordinates = self.point["coordinates"] 121 | self.assertEqual( 122 | create_coordinates(geometry_coordinates, precision), expected_return_value 123 | ) 124 | 125 | def test_point_precision_larger_than_places(self): 126 | """Test that when the precision argument is a value larger 127 | than the number of coordinate decimnal points, the same coordinate values 128 | are returned. 129 | """ 130 | expected_return_value = self.point["coordinates"] 131 | precision = 50 132 | geometry_coordinates = self.point["coordinates"] 133 | self.assertEqual( 134 | create_coordinates(geometry_coordinates, precision), expected_return_value 135 | ) 136 | 137 | # LineString. 138 | def test_truncuating_linestring_coordinates(self): 139 | """Test that LineString coordinates are truncuated succesfully.""" 140 | expected_return_value = [ 141 | [100.123, 0.123], 142 | [101.123, 1.123], 143 | [102.123, 2.123], 144 | ] 145 | precision = 3 146 | geometry_coordinates = self.linestring["coordinates"] 147 | self.assertEqual( 148 | create_coordinates(geometry_coordinates, precision), expected_return_value 149 | ) 150 | 151 | def test_linestring_same_precision(self): 152 | """Test that LineString coordinates are not truncuated when 153 | the coordinate decimal points matches what's passed 154 | to the precision parameter. 155 | """ 156 | expected_return_value = self.linestring["coordinates"] 157 | precision = 6 158 | geometry_coordinates = self.linestring["coordinates"] 159 | self.assertEqual( 160 | create_coordinates(geometry_coordinates, precision), expected_return_value 161 | ) 162 | 163 | def test_linestring_precision_larger_than_places(self): 164 | """Test that when the precision argument is a value larger 165 | than the number of coordinate decimnal points, the same coordinate values 166 | are returned. 167 | """ 168 | expected_return_value = self.linestring["coordinates"] 169 | precision = 50 170 | geometry_coordinates = self.linestring["coordinates"] 171 | self.assertEqual( 172 | create_coordinates(geometry_coordinates, precision), expected_return_value 173 | ) 174 | 175 | # MultiLine strings. 176 | def test_truncuating_multilinestring_coordinates(self): 177 | """Test that MultiLineString coordinates are truncuated succesfully.""" 178 | expected_return_value = [ 179 | [[-100.123, 0.123], [-101.123, 1.123]], 180 | [[102.123, 2.123], [103.123, 3.123]], 181 | ] 182 | precision = 3 183 | geometry_coordinates = self.multilinestring["coordinates"] 184 | self.assertEqual( 185 | create_coordinates(geometry_coordinates, precision), expected_return_value 186 | ) 187 | 188 | def test_multilinestring_precision_larger_than_places(self): 189 | """Test that when the precision argument is a value larger 190 | than the number of coordinate decimnal points, the same coordinate values 191 | are returned. 192 | """ 193 | expected_return_value = self.multilinestring["coordinates"] 194 | precision = 50 195 | geometry_coordinates = self.multilinestring["coordinates"] 196 | self.assertEqual( 197 | create_coordinates(geometry_coordinates, precision), expected_return_value 198 | ) 199 | 200 | def test_multilinestring_same_precision(self): 201 | """Test that LineString coordinates are not truncuated when 202 | the coordinate decimal points matches what's passed 203 | to the precision parameter. 204 | """ 205 | expected_return_value = self.multipolygon["coordinates"] 206 | precision = 6 207 | geometry_coordinates = self.multipolygon["coordinates"] 208 | self.assertEqual( 209 | create_coordinates(geometry_coordinates, precision), expected_return_value 210 | ) 211 | 212 | # Polygon. 213 | def test_truncuating_polygon_coordinates(self): 214 | """Test that Polygon coordinates are truncuated succesfully.""" 215 | expected_return_value = [ 216 | [ 217 | [100.123, 0.123], 218 | [101.123, 0.123], 219 | [101.123, 1.123], 220 | [100.123, 1.123], 221 | [100.123, 0.123], 222 | ] 223 | ] 224 | precision = 3 225 | geometry_coordinates = self.polygon["coordinates"] 226 | self.assertEqual( 227 | create_coordinates(geometry_coordinates, precision), expected_return_value 228 | ) 229 | 230 | def test_polygon_precision_larger_than_places(self): 231 | """Test that when the precision argument is a value larger 232 | than the number of coordinate decimnal points, the same coordinate values 233 | are returned. 234 | """ 235 | expected_return_value = [ 236 | [ 237 | [100.123456, 0.123456], 238 | [101.123456, 0.123456], 239 | [101.123456, 1.123456], 240 | [100.123456, 1.123456], 241 | [100.123456, 0.123456], 242 | ] 243 | ] 244 | precision = 50 245 | geometry_coordinates = self.polygon["coordinates"] 246 | self.assertEqual( 247 | create_coordinates(geometry_coordinates, precision), expected_return_value 248 | ) 249 | 250 | def test_polygon_same_precision(self): 251 | """Test that LineString coordinates are not truncuated when 252 | the coordinate decimal points matches what's passed 253 | to the precision parameter. 254 | """ 255 | expected_return_value = [ 256 | [ 257 | [100.123456, 0.123456], 258 | [101.123456, 0.123456], 259 | [101.123456, 1.123456], 260 | [100.123456, 1.123456], 261 | [100.123456, 0.123456], 262 | ] 263 | ] 264 | precision = 6 265 | geometry_coordinates = self.polygon["coordinates"] 266 | self.assertEqual( 267 | create_coordinates(geometry_coordinates, precision), expected_return_value 268 | ) 269 | 270 | # MultiPolygon. 271 | def test_truncuating_multipolygon_coordinates(self): 272 | """Test that MultiPolygon coordinates are truncuated succesfully.""" 273 | expected_return_value = [ 274 | [ 275 | [ 276 | [102.123, 2.123], 277 | [103.123, 2.123], 278 | [103.123, 3.123], 279 | [102.123, 3.123], 280 | [102.123, 2.123], 281 | ] 282 | ], 283 | [ 284 | [ 285 | [100.123, 0.123], 286 | [101.123, 0.123], 287 | [101.123, 1.123], 288 | [100.123, 1.123], 289 | [100.123, 0.123], 290 | ], 291 | [ 292 | [100.123, 0.123], 293 | [100.123, 0.123], 294 | [100.123, 0.123], 295 | [100.123, 0.123], 296 | [100.123, 0.123], 297 | ], 298 | ], 299 | ] 300 | precision = 3 301 | geometry_coordinates = self.multipolygon["coordinates"] 302 | self.assertEqual( 303 | create_coordinates(geometry_coordinates, precision), expected_return_value 304 | ) 305 | 306 | def test_multipolygon_precision_larger_than_places(self): 307 | """Test that when the precision argument is a value larger 308 | than the number of coordinate decimnal points, the same coordinate values 309 | are returned. 310 | """ 311 | expected_return_value = self.multipolygon["coordinates"] 312 | precision = 50 313 | geometry_coordinates = self.multipolygon["coordinates"] 314 | self.assertEqual( 315 | create_coordinates(geometry_coordinates, precision), expected_return_value 316 | ) 317 | 318 | def test_multipolygon_same_precision(self): 319 | """Test that LineString coordinates are not truncuated when 320 | the coordinate decimal points matches what's passed 321 | to the precision parameter. 322 | """ 323 | expected_return_value = self.multipolygon["coordinates"] 324 | precision = 6 325 | geometry_coordinates = self.multipolygon["coordinates"] 326 | self.assertEqual( 327 | create_coordinates(geometry_coordinates, precision), expected_return_value 328 | ) 329 | 330 | 331 | class TestProcessGeometryCollection(unittest.TestCase): 332 | """Tests for the process_geometry_collection function.""" 333 | 334 | def setUp(self): 335 | self.geometry_collection = { 336 | "type": "GeometryCollection", 337 | "geometries": [ 338 | {"type": "Point", "coordinates": [0.123456, 0.123456]}, 339 | { 340 | "type": "LineString", 341 | "coordinates": [ 342 | [1.123456, 1.123456], 343 | [2.123456, 3.123456], 344 | [4.123456, 5.123456], 345 | ], 346 | }, 347 | { 348 | "type": "Polygon", 349 | "coordinates": [ 350 | [ 351 | [6.123456, 6.123456], 352 | [8.123456, 8.123456], 353 | [10.123456, 10.123456], 354 | [6.123456, 6.123456], 355 | ] 356 | ], 357 | }, 358 | { 359 | "type": "MultiPoint", 360 | "coordinates": [ 361 | [12.123456, 12.123456], 362 | [14.123456, 14.123456], 363 | [16.123456, 16.123456], 364 | ], 365 | }, 366 | ], 367 | } 368 | 369 | def test_truncuating_coordinates(self): 370 | """Tests that the coordinates of each nested Geometry object 371 | is truncuated correctly.""" 372 | 373 | expected_return_value = { 374 | "type": "GeometryCollection", 375 | "geometries": [ 376 | {"type": "Point", "coordinates": [0.123, 0.123]}, 377 | { 378 | "type": "LineString", 379 | "coordinates": [[1.123, 1.123], [2.123, 3.123], [4.123, 5.123]], 380 | }, 381 | { 382 | "type": "Polygon", 383 | "coordinates": [ 384 | [ 385 | [6.123, 6.123], 386 | [8.123, 8.123], 387 | [10.123, 10.123], 388 | [6.123, 6.123], 389 | ] 390 | ], 391 | }, 392 | { 393 | "type": "MultiPoint", 394 | "coordinates": [ 395 | [12.123, 12.123], 396 | [14.123, 14.123], 397 | [16.123, 16.123], 398 | ], 399 | }, 400 | ], 401 | } 402 | 403 | geo_collection = self.geometry_collection 404 | precision = 3 405 | self.assertEqual( 406 | process_geometry_collection(geo_collection, precision), 407 | expected_return_value, 408 | ) 409 | 410 | 411 | class TestProcessFeatures(unittest.TestCase): 412 | """Tests for the process_features function.""" 413 | 414 | def setUp(self): 415 | self.feature_collection = { 416 | "type": "FeatureCollection", 417 | "features": [ 418 | { 419 | "type": "Feature", 420 | "geometry": {"type": "Point", "coordinates": [0.123456, 0.123456]}, 421 | "properties": {"id": 1, "name": "Feature 1"}, 422 | }, 423 | { 424 | "type": "Feature", 425 | "geometry": { 426 | "type": "Polygon", 427 | "coordinates": [ 428 | [ 429 | [5.123456, 5.123456], 430 | [15.123456, 5.123456], 431 | [15.123456, 15.123456], 432 | [5.123456, 15.123456], 433 | [5.123456, 5.123456], 434 | ] 435 | ], 436 | }, 437 | "properties": {"name": "Feature 2"}, 438 | }, 439 | ], 440 | } 441 | self.blank_feature_collection = {} 442 | 443 | self.feature = { 444 | "type": "Feature", 445 | "geometry": {"type": "Point", "coordinates": [100.123456, -0.123456]}, 446 | "properties": {"id": 1, "name": "Example Point"}, 447 | } 448 | 449 | def test_feature_collection_truncuation(self): 450 | """Test that the coordinates of each nested Geometry object is 451 | truncuated.""" 452 | geometry_to_include = GEOMETRY_OBJECTS 453 | precision = 3 454 | expected_return_value = { 455 | "type": "FeatureCollection", 456 | "features": [ 457 | { 458 | "type": "Feature", 459 | "geometry": {"type": "Point", "coordinates": [0.123, 0.123]}, 460 | "properties": {"id": 1, "name": "Feature 1"}, 461 | }, 462 | { 463 | "type": "Feature", 464 | "geometry": { 465 | "type": "Polygon", 466 | "coordinates": [ 467 | [ 468 | [5.123, 5.123], 469 | [15.123, 5.123], 470 | [15.123, 15.123], 471 | [5.123, 15.123], 472 | [5.123, 5.123], 473 | ] 474 | ], 475 | }, 476 | "properties": {"name": "Feature 2"}, 477 | }, 478 | ], 479 | } 480 | 481 | self.assertEqual( 482 | process_features( 483 | self.feature_collection, precision, geometry_to_include, None 484 | ), 485 | expected_return_value, 486 | ) 487 | 488 | def test_feature_truncuation(self): 489 | """Test that the Feature is processed (coordinates truncuated). 490 | Note the distinction between Feature and FeatureCollection. 491 | """ 492 | geometry_to_include = GEOMETRY_OBJECTS 493 | precision = 3 494 | expected_return_value = { 495 | "type": "Feature", 496 | "geometry": {"type": "Point", "coordinates": [100.123, -0.123]}, 497 | "properties": {"id": 1, "name": "Example Point"}, 498 | } 499 | 500 | self.assertEqual( 501 | process_features(self.feature, precision, geometry_to_include, None), 502 | expected_return_value, 503 | ) 504 | 505 | def test_empty_gson_file(self): 506 | """Test that an exception is raised when an empty 507 | GeoJSON file is passed.""" 508 | with self.assertRaises(ValueError): 509 | process_features(self.blank_feature_collection, 3, ["Point"], None) 510 | 511 | def test_properties_nullified(self): 512 | """Test that the properties key returns a null/empty dictionary.""" 513 | precision = 3 514 | expected_return_value = { 515 | "type": "FeatureCollection", 516 | "features": [ 517 | { 518 | "type": "Feature", 519 | "geometry": {"type": "Point", "coordinates": [0.123, 0.123]}, 520 | "properties": {}, 521 | }, 522 | { 523 | "type": "Feature", 524 | "geometry": { 525 | "type": "Polygon", 526 | "coordinates": [ 527 | [ 528 | [5.123, 5.123], 529 | [15.123, 5.123], 530 | [15.123, 15.123], 531 | [5.123, 15.123], 532 | [5.123, 5.123], 533 | ] 534 | ], 535 | }, 536 | "properties": {}, 537 | }, 538 | ], 539 | } 540 | 541 | self.assertEqual( 542 | process_features( 543 | self.feature_collection, precision, GEOMETRY_OBJECTS, [] 544 | ), 545 | expected_return_value, 546 | ) 547 | 548 | def test_keep_properties(self): 549 | """Test that the properties key returns a null/empty dictionary.""" 550 | precision = 3 551 | expected_return_value = { 552 | "type": "FeatureCollection", 553 | "features": [ 554 | { 555 | "type": "Feature", 556 | "geometry": {"type": "Point", "coordinates": [0.123, 0.123]}, 557 | "properties": {"id": 1}, 558 | }, 559 | { 560 | "type": "Feature", 561 | "geometry": { 562 | "type": "Polygon", 563 | "coordinates": [ 564 | [ 565 | [5.123, 5.123], 566 | [15.123, 5.123], 567 | [15.123, 15.123], 568 | [5.123, 15.123], 569 | [5.123, 5.123], 570 | ] 571 | ], 572 | }, 573 | "properties": {}, 574 | }, 575 | ], 576 | } 577 | 578 | self.assertEqual( 579 | process_features( 580 | self.feature_collection, precision, GEOMETRY_OBJECTS, ['id', 'nonexist', ''] 581 | ), 582 | expected_return_value, 583 | ) 584 | 585 | def test_geometry_to_include_parameter(self): 586 | """Test that only the passed geometry objects are truncuated.""" 587 | geometry_to_include = ["Polygon"] 588 | precision = 3 589 | expected_return_value = { 590 | "type": "FeatureCollection", 591 | "features": [ 592 | { 593 | "type": "Feature", 594 | "geometry": {"type": "Point", "coordinates": [0.123456, 0.123456]}, 595 | "properties": {"id": 1, "name": "Feature 1"}, 596 | }, 597 | { 598 | "type": "Feature", 599 | "geometry": { 600 | "type": "Polygon", 601 | "coordinates": [ 602 | [ 603 | [5.123, 5.123], 604 | [15.123, 5.123], 605 | [15.123, 15.123], 606 | [5.123, 15.123], 607 | [5.123, 5.123], 608 | ] 609 | ], 610 | }, 611 | "properties": {"name": "Feature 2"}, 612 | }, 613 | ], 614 | } 615 | 616 | self.assertEqual( 617 | process_features( 618 | self.feature_collection, precision, geometry_to_include, None 619 | ), 620 | expected_return_value, 621 | ) 622 | 623 | 624 | if __name__ == "__main__": 625 | unittest.main(buffer=True) 626 | --------------------------------------------------------------------------------