├── 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 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
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 |
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 |
--------------------------------------------------------------------------------