bboxkit 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. bboxkit-0.1.0/CHANGELOG.md +35 -0
  2. bboxkit-0.1.0/CITATION.cff +20 -0
  3. bboxkit-0.1.0/LICENSE +21 -0
  4. bboxkit-0.1.0/MANIFEST.in +8 -0
  5. bboxkit-0.1.0/PKG-INFO +270 -0
  6. bboxkit-0.1.0/README.md +233 -0
  7. bboxkit-0.1.0/pyproject.toml +121 -0
  8. bboxkit-0.1.0/setup.cfg +4 -0
  9. bboxkit-0.1.0/src/bboxkit/__init__.py +31 -0
  10. bboxkit-0.1.0/src/bboxkit/__main__.py +6 -0
  11. bboxkit-0.1.0/src/bboxkit/_geo.py +222 -0
  12. bboxkit-0.1.0/src/bboxkit/_version.py +1 -0
  13. bboxkit-0.1.0/src/bboxkit/bbox.py +1029 -0
  14. bboxkit-0.1.0/src/bboxkit/cli.py +311 -0
  15. bboxkit-0.1.0/src/bboxkit/crs.py +380 -0
  16. bboxkit-0.1.0/src/bboxkit/formats.py +86 -0
  17. bboxkit-0.1.0/src/bboxkit/geohash.py +83 -0
  18. bboxkit-0.1.0/src/bboxkit/mgrs.py +136 -0
  19. bboxkit-0.1.0/src/bboxkit/nominatim.py +113 -0
  20. bboxkit-0.1.0/src/bboxkit/parsing.py +722 -0
  21. bboxkit-0.1.0/src/bboxkit/pluscode.py +125 -0
  22. bboxkit-0.1.0/src/bboxkit/py.typed +0 -0
  23. bboxkit-0.1.0/src/bboxkit/tiles.py +148 -0
  24. bboxkit-0.1.0/src/bboxkit.egg-info/PKG-INFO +270 -0
  25. bboxkit-0.1.0/src/bboxkit.egg-info/SOURCES.txt +35 -0
  26. bboxkit-0.1.0/src/bboxkit.egg-info/dependency_links.txt +1 -0
  27. bboxkit-0.1.0/src/bboxkit.egg-info/entry_points.txt +2 -0
  28. bboxkit-0.1.0/src/bboxkit.egg-info/requires.txt +9 -0
  29. bboxkit-0.1.0/src/bboxkit.egg-info/top_level.txt +1 -0
  30. bboxkit-0.1.0/tests/conftest.py +55 -0
  31. bboxkit-0.1.0/tests/test_bbox.py +261 -0
  32. bboxkit-0.1.0/tests/test_cells.py +136 -0
  33. bboxkit-0.1.0/tests/test_cli.py +166 -0
  34. bboxkit-0.1.0/tests/test_formats.py +145 -0
  35. bboxkit-0.1.0/tests/test_nominatim.py +80 -0
  36. bboxkit-0.1.0/tests/test_ops.py +381 -0
  37. bboxkit-0.1.0/tests/test_parsing.py +373 -0
@@ -0,0 +1,35 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project
5
+ uses [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0] - 2026-11-08
10
+ ### Added
11
+ - `BBox(west, south, east, north, crs="EPSG:4326")`: immutable, hashable, picklable value
12
+ type with validation, canonical antimeridian edges, `west > east` for boxes crossing the
13
+ antimeridian, exact `==`/`hash` and tolerance-based `equals()`.
14
+ - `parse()` for W,S,E,N / S,W,N,E text (explicit `order=` or a heuristic with
15
+ `AmbiguousOrderWarning`), bboxfinder.com links, Overpass `(s,w,n,e)` and `[bbox:...]`,
16
+ OSM URLs (`bbox=`, `minlon=...`, `map=zoom/lat/lon` with a viewport size), WKT/EWKT,
17
+ `ENVELOPE`, PostGIS `BOX`/`ST_MakeEnvelope`, GeoJSON (bbox member or computed with the
18
+ RFC 7946 antimeridian rules), GDAL `-te`/`-projwin`/`-spat`, Leaflet and Mapbox bounds,
19
+ XYZ tiles and tile URLs, quadkeys, geohashes, Plus Codes, MGRS squares, Shapely/GeoPandas
20
+ objects, `__geo_interface__`, Nominatim/ArcGIS/STAC/Google/Leaflet dictionaries.
21
+ - Formatters for all of the above (`to_overpass()`, `to_gdal_te()`, `to_wkt()`,
22
+ `to_geojson()`, ...) and a `format()` registry.
23
+ - Antimeridian-aware `contains`, `intersects`, `intersection`, `intersection_parts`,
24
+ `union`/`union_all` (smallest covering longitude range).
25
+ - `buffer()` in metres, exact ellipsoidal `area_km2()`, `center`, `width_m`/`height_m`,
26
+ `grid()`, `split()` by cell size in metres, XYZ `tiles()`, `expand_to_aspect()`, `snap()`
27
+ and `round()`.
28
+ - Built-in reprojection to and from Web Mercator and WGS 84 / UTM (6th-order Krueger series)
29
+ with densified edges; any other CRS through the optional `pyproj` extra.
30
+ - Opt-in Nominatim place lookup (`bboxkit.nominatim`) that follows the OSMF usage policy.
31
+ - `bboxkit` command line: `convert`, `info`, `area`, `tiles`, `split`, `buffer`,
32
+ `reproject`, `geocode` and `formats`.
33
+
34
+ [Unreleased]: https://github.com/YusufEminoglu/bboxkit_sdk/compare/v0.1.0...HEAD
35
+ [0.1.0]: https://github.com/YusufEminoglu/bboxkit_sdk/releases/tag/v0.1.0
@@ -0,0 +1,20 @@
1
+ cff-version: 1.2.0
2
+ message: "If you use this software, please cite it as below."
3
+ authors:
4
+ - family-names: "Eminoğlu"
5
+ given-names: "Yusuf"
6
+ email: "yusufeminoglu@gmail.com"
7
+ orcid: "https://orcid.org/0009-0005-6000-2934"
8
+ title: "bboxkit"
9
+ version: "0.1.0"
10
+ date-released: "2026-11-08"
11
+ license: "MIT"
12
+ repository-code: "https://github.com/YusufEminoglu/bboxkit_sdk"
13
+ url: "https://pypi.org/project/bboxkit/"
14
+ abstract: "Zero-dependency Python toolkit for bounding boxes: parse and format every common bbox convention, antimeridian-aware geometry, exact ellipsoidal area, tiles and reprojection."
15
+ keywords:
16
+ - bounding box
17
+ - GIS
18
+ - antimeridian
19
+ - GeoJSON
20
+ - web mercator
bboxkit-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yusuf Eminoğlu
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.
@@ -0,0 +1,8 @@
1
+ include LICENSE
2
+ include README.md
3
+ include CHANGELOG.md
4
+ include CITATION.cff
5
+ include pyproject.toml
6
+ include src/bboxkit/py.typed
7
+ recursive-include src/bboxkit *.py
8
+ recursive-include tests *.py
bboxkit-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,270 @@
1
+ Metadata-Version: 2.4
2
+ Name: bboxkit
3
+ Version: 0.1.0
4
+ Summary: Zero-dependency bounding boxes: parse and format every bbox convention (WKT, GeoJSON, Overpass, GDAL, tiles, geohash, Plus Codes, MGRS), antimeridian-aware geometry, ellipsoidal area and reprojection.
5
+ Author-email: Yusuf Eminoğlu <yusufeminoglu@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/YusufEminoglu/bboxkit_sdk
8
+ Project-URL: Repository, https://github.com/YusufEminoglu/bboxkit_sdk
9
+ Project-URL: Issues, https://github.com/YusufEminoglu/bboxkit_sdk/issues
10
+ Project-URL: Changelog, https://github.com/YusufEminoglu/bboxkit_sdk/blob/main/CHANGELOG.md
11
+ Keywords: bbox,bounding-box,gis,geojson,wkt,overpass,openstreetmap,gdal,xyz-tiles,quadkey,geohash,plus-codes,mgrs,utm,web-mercator,antimeridian
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Operating System :: OS Independent
21
+ Classifier: Topic :: Scientific/Engineering :: GIS
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Classifier: Intended Audience :: Science/Research
24
+ Classifier: Intended Audience :: Developers
25
+ Classifier: Typing :: Typed
26
+ Requires-Python: >=3.9
27
+ Description-Content-Type: text/markdown
28
+ License-File: LICENSE
29
+ Provides-Extra: pyproj
30
+ Requires-Dist: pyproj>=3.4; extra == "pyproj"
31
+ Provides-Extra: dev
32
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
33
+ Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
34
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
35
+ Requires-Dist: mypy>=1.0.0; extra == "dev"
36
+ Dynamic: license-file
37
+
38
+ # bboxkit
39
+
40
+ **Bounding boxes, without the guesswork.** Parse a bbox from whatever tool handed it to you,
41
+ convert it to whatever the next tool wants, and do correct geometry on it, including boxes
42
+ that cross the antimeridian. Pure Python, zero dependencies, fully typed.
43
+
44
+ ```bash
45
+ pip install bboxkit # zero dependencies
46
+ pip install "bboxkit[pyproj]" # optional: reproject to any CRS
47
+ ```
48
+
49
+ ```python
50
+ >>> from bboxkit import BBox, parse
51
+ >>> box = parse("29.0,40.9,29.2,41.1", order="wsen") # Istanbul
52
+ >>> box.to_overpass()
53
+ '(40.9,29,41.1,29.2)'
54
+ >>> box.to_gdal_te()
55
+ '-te 29 40.9 29.2 41.1'
56
+ >>> round(box.area_km2(), 3) # exact on the WGS 84 ellipsoid
57
+ 373.741
58
+ ```
59
+
60
+ Every tool agrees that a bounding box has four numbers. Almost nothing else is agreed:
61
+ GeoJSON and Shapely write `west,south,east,north`, Overpass and Leaflet write
62
+ `south,west,north,east`, `gdal_translate -projwin` wants `west,north,east,south`, Nominatim
63
+ returns `[south, north, west, east]` as strings, and a box around Fiji has `west > east`.
64
+ bboxkit is a small, dependency-free value type that knows all of these conventions, so other
65
+ packages can depend on it instead of re-implementing (and re-debugging) them.
66
+
67
+ ## Supported formats
68
+
69
+ | Format | Example | Parse | Format |
70
+ | --- | --- | --- | --- |
71
+ | W,S,E,N (GeoJSON, Shapely, OSM API, STAC) | `29,40.9,29.2,41.1` | `parse(s, order="wsen")` | `to_wsen()` |
72
+ | S,W,N,E (lat-first) | `40.9,29,41.1,29.2` | `parse(s, order="swne")` | `to_swne()` |
73
+ | bboxfinder.com | `http://bboxfinder.com/#40.9,29,41.1,29.2` | auto | `to_bboxfinder()` |
74
+ | Overpass QL | `(40.9,29,41.1,29.2)`, `[bbox:40.9,29,41.1,29.2]` | auto | `to_overpass()` |
75
+ | OSM URL | `...?bbox=W,S,E,N`, `?minlon=...`, `#map=14/41.0/29.0` | auto (`viewport=`) | `to_osm_url()` |
76
+ | WKT / EWKT | `POLYGON ((...))`, `SRID=3857;MULTIPOINT(...)`, any geometry | auto | `to_wkt()` |
77
+ | ENVELOPE (CQL, Solr) | `ENVELOPE(29, 29.2, 41.1, 40.9)` | auto | `to_wkt_envelope()` |
78
+ | PostGIS | `ST_MakeEnvelope(29, 40.9, 29.2, 41.1, 4326)`, `BOX(29 40.9,29.2 41.1)` | auto | `to_postgis()` |
79
+ | GeoJSON | `bbox` member, or computed from any geometry / Feature / FeatureCollection | auto | `to_geojson()` |
80
+ | Shapely / GeoPandas | `geom.bounds`, `gdf.total_bounds`, `__geo_interface__` | `parse(obj)` | `to_shapely_bounds()` |
81
+ | GDAL / OGR | `-te 29 40.9 29.2 41.1`, `-projwin 29 41.1 29.2 40.9`, `-spat ...`, `-te_srs` | auto | `to_gdal_te()`, `to_gdal_projwin()` |
82
+ | Leaflet / Google | `[[40.9, 29], [41.1, 29.2]]` | `parse_leaflet()` | `to_leaflet()` |
83
+ | Mapbox / MapLibre | `[[29, 40.9], [29.2, 41.1]]` | `parse_mapbox()` | `to_mapbox()` |
84
+ | XYZ tile | `14/9375/6142`, `https://tile.../14/9375/6142.png` | auto | `to_tile()` |
85
+ | Quadkey (Bing) | `1202102332221212` | auto | `to_quadkey()` |
86
+ | Geohash | `sxk9` | auto | `to_geohash()` |
87
+ | Plus Code (OLC) | `8FVC9G8F+6W` | auto | `to_pluscode()` |
88
+ | MGRS square | `35TPF`, `31U DQ 48251 11932` | auto | `to_mgrs()` |
89
+ | Dictionaries | Nominatim result, ArcGIS extent (`wkid`), STAC collection, Leaflet `_southWest`, Google `southwest`, `{minx, ...}` | `parse(d)` | `to_dict()` |
90
+ | Place name | `"Istanbul"` | `nominatim.geocode_bbox()` (opt-in, network) | |
91
+
92
+ For cell formats (tile, quadkey, geohash, Plus Code, MGRS) parsing gives the cell's box and
93
+ formatting gives the **smallest cell that contains the whole box**.
94
+
95
+ Not included, on purpose: H3 (needs the icosahedral projection, i.e. the `h3` library) and
96
+ the polar UPS part of MGRS.
97
+
98
+ ## One-liners
99
+
100
+ ```python
101
+ from bboxkit import BBox, parse, union_all
102
+
103
+ parse("http://bboxfinder.com/#40.9,29,41.1,29.2") # BBox(29.0, 40.9, 29.2, 41.1)
104
+ parse("[bbox:40.9,29,41.1,29.2]") # Overpass setting
105
+ parse("gdalwarp -t_srs EPSG:32635 -te 500000 4500000 510000 4510000 in.tif out.tif")
106
+ parse("https://www.openstreetmap.org/#map=14/41.0082/28.9784", viewport=(1920, 1080))
107
+ parse("SRID=3857;POLYGON((0 0, 10 0, 10 10, 0 0))") # crs='EPSG:3857'
108
+ parse({"type": "Feature", "geometry": {"type": "LineString", "coordinates": [[179, 0], [-179, 1]]}})
109
+ parse({"boundingbox": ["40.9", "41.1", "29.0", "29.2"]}) # a Nominatim result
110
+ parse(shapely_polygon) # uses .bounds
111
+ parse("14/9375/6142"); parse("sxk9"); parse("8FVC9G8F+6W"); parse("35TPF")
112
+
113
+ box = BBox(29.0, 40.9, 29.2, 41.1)
114
+ box.to_wkt() # 'POLYGON ((29 40.9, 29.2 40.9, 29.2 41.1, 29 41.1, 29 40.9))'
115
+ box.to_geojson() # {'type': 'Polygon', 'coordinates': [...], 'bbox': [...]}
116
+ box.to_leaflet() # [[40.9, 29.0], [41.1, 29.2]]
117
+ box.to_gdal_projwin() # '-projwin 29 41.1 29.2 40.9'
118
+ box.to_osm_url() # 'https://www.openstreetmap.org/#map=11/41.00008/29.10000'
119
+ box.format("postgis") # 'ST_MakeEnvelope(29, 40.9, 29.2, 41.1, 4326)'
120
+ box.to_geohash(), box.to_tile(), box.to_mgrs() # ('sxk', Tile(x=2, y=1, z=2), '35TPF')
121
+
122
+ box.area_km2() # 373.741450485826 (exact for a lat/lon rectangle on WGS 84)
123
+ box.center # (29.1, 41.0)
124
+ box.width_m, box.height_m # (16827.0..., 22210.7...)
125
+ box.buffer(500) # grown by 500 m on every side
126
+ box.buffer(-500) # shrunk by 500 m
127
+ box.contains((29.1, 41.0)); box.intersects(other); box & other; box | other
128
+ box.grid(4, 3) # 12 cells, row by row from the north-west
129
+ box.split(1000) # ~1 km x 1 km cells
130
+ box.tiles(14) # [Tile(x=9511, y=6136, z=14), ...]
131
+ box.tile_count(14) # 130
132
+ box.expand_to_aspect(16 / 9) # fit a 16:9 web map (Mercator), never shrinks
133
+ BBox(29.03, 40.91, 29.17, 41.08).snap(0.1) # BBox(29.0, 40.9, 29.2, 41.1)
134
+ round(box, 2)
135
+ box.to_crs("EPSG:3857") # built in
136
+ box.to_utm() # built in: EPSG:32635 for Istanbul
137
+ box.to_crs("EPSG:3035") # any CRS with the [pyproj] extra
138
+ w, s, e, n = box # unpacks like a tuple
139
+ ```
140
+
141
+ ## The antimeridian
142
+
143
+ For longitude/latitude boxes, `west > east` means the box crosses the 180th meridian, exactly
144
+ as in RFC 7946:
145
+
146
+ ```python
147
+ >>> fiji = BBox(177, -20, -178, -16)
148
+ >>> fiji.crosses_antimeridian, fiji.width, fiji.center
149
+ (True, 5.0, (179.5, -18.0))
150
+ >>> fiji.contains((-179.5, -18))
151
+ True
152
+ >>> fiji.parts()
153
+ (BBox(177.0, -20.0, 180.0, -16.0), BBox(-180.0, -20.0, -178.0, -16.0))
154
+ >>> BBox(170, 0, 175, 1) | BBox(-175, 0, -170, 1)
155
+ BBox(170.0, 0.0, -170.0, 1.0)
156
+ >>> fiji.to_geojson()["type"] # split into two parts, as RFC 7946 recommends
157
+ 'MultiPolygon'
158
+ >>> fiji.to_leaflet() # one contiguous box on a web map
159
+ [[-20.0, 177.0], [-16.0, 182.0]]
160
+ ```
161
+
162
+ - `union()` / `union_all()` and bounding boxes computed from GeoJSON or WKT use the smallest
163
+ longitude range that covers everything (the complement of the largest gap), so the RFC 7946
164
+ Fiji example gives `[177, -20, -178, -16]`, not `[-178, -20, 177, -16]`.
165
+ - A line or ring whose consecutive vertices are more than 180 degrees apart is read as crossing
166
+ the antimeridian (the shorter way round).
167
+ - `intersection()` returns `None`, one box, or raises if the overlap is two separate pieces
168
+ (wide boxes overlapping at both ends); `intersection_parts()` always returns the list.
169
+ - Formats that cannot express a crossing box (`-te`, `-projwin`, `ST_MakeEnvelope`, Shapely
170
+ bounds) raise a `ValueError` that points to `parts()`.
171
+ - In Web Mercator a crossing box keeps contiguous x values (east beyond 20037508.34 m) and
172
+ converts back to a crossing box.
173
+
174
+ ## W,S,E,N or S,W,N,E?
175
+
176
+ Four bare numbers are ambiguous. bboxkit reads them as `west,south,east,north` unless only the
177
+ lat-first reading is valid, and emits an `AmbiguousOrderWarning` whenever both readings are
178
+ possible. Say what you mean to make the warning go away:
179
+
180
+ ```python
181
+ parse("40.9,29,41.1,29.2", order="swne")
182
+ parse("swne:40.9,29,41.1,29.2")
183
+ parse(text, fmt="leaflet")
184
+ ```
185
+
186
+ ## Area, buffers and grids
187
+
188
+ - `area_km2()` uses the closed-form area of a region bounded by two meridians and two parallels
189
+ on the WGS 84 ellipsoid (via the authalic latitude function). It matches a densely densified
190
+ `pyproj.Geod` polygon to ~1e-11. 1 x 1 degree at the equator is 12308.4639 km2, at 60 N
191
+ 6123.1409 km2; the whole globe is 510065621.724 km2.
192
+ - `buffer(m)` uses the local radii of curvature; the longitude margin is taken at the most
193
+ poleward latitude, so every point within `m` metres of the box is inside the result. Boxes
194
+ that reach a pole get the full longitude range.
195
+ - `split(cell_m)` builds a regular lon/lat grid whose cells measure `cell_m` at the box's central
196
+ latitude; `grid(nx, ny)` splits evenly. Both work across the antimeridian.
197
+ - Projected boxes (UTM, any metric CRS) do all of this in CRS units.
198
+
199
+ ## Reprojection
200
+
201
+ `to_crs()` transforms densified edges (21 points per edge by default) and takes the extent.
202
+ WGS 84, Web Mercator (`EPSG:3857` and its aliases 900913, 3785, 102100) and WGS 84 / UTM
203
+ (`EPSG:326xx`, `EPSG:327xx`, ETRS89 `EPSG:258xx`) are built in; UTM uses the 6th-order Krueger
204
+ series (agrees with PROJ to nanometres). Anything else goes to pyproj when it is installed.
205
+ `to_utm()` picks the zone of the centre, including the Norway and Svalbard exceptions.
206
+
207
+ ## Command line
208
+
209
+ ```bash
210
+ $ bboxkit convert "29.0,40.9,29.2,41.1" --to overpass
211
+ bboxkit: warning: 29,40.9,29.2,41.1 is ambiguous; read as west,south,east,north. ...
212
+ (40.9,29,41.1,29.2)
213
+ $ bboxkit convert "29.0,40.9,29.2,41.1" --order wsen --to all
214
+ $ bboxkit convert "http://bboxfinder.com/#40.9,29,41.1,29.2" --to gdal-projwin
215
+ $ bboxkit area "29.0,40.9,29.2,41.1" --order wsen
216
+ 373.741
217
+ $ bboxkit tiles "29.0,40.9,29.2,41.1" --order wsen --zoom 14 --count
218
+ 130
219
+ $ bboxkit tiles "29.0,40.9,29.2,41.1" --order wsen --zoom 14 --format quadkey
220
+ $ bboxkit split "29.0,40.9,29.2,41.1" --order wsen --cell 1km --to geojson > grid.geojson
221
+ $ bboxkit split "29.0,40.9,29.2,41.1" --order wsen --grid 4x3
222
+ $ bboxkit buffer "POINT(29 41)" --distance 2km --to wkt
223
+ $ bboxkit reproject "29.0,40.9,29.2,41.1" --order wsen --to-crs utm
224
+ $ bboxkit info "177,-20,-178,-16" --order wsen
225
+ $ bboxkit geocode "Kadikoy, Istanbul" --user-agent "my-app/1.0 (me@example.org)"
226
+ $ bboxkit formats
227
+ ```
228
+
229
+ Boxes that start with a minus sign (`-122.5,37.7,-122.3,37.8`, `-te 1 2 3 4`) work as they are;
230
+ `-` reads the box from standard input.
231
+
232
+ ## Place names (optional, network)
233
+
234
+ `parse()` never touches the network. Place lookup is a separate, explicit call:
235
+
236
+ ```python
237
+ from bboxkit.nominatim import geocode_bbox
238
+
239
+ geocode_bbox("Kadikoy, Istanbul", user_agent="my-app/1.0 (me@example.org)", timeout=10)
240
+ ```
241
+
242
+ It follows the [Nominatim usage policy](https://operations.osmfoundation.org/policies/nominatim/):
243
+ at most one request per second per process, an identifying User-Agent (please set your own),
244
+ results cached in memory, and a `base_url=` for your own Nominatim instance when you need
245
+ more than occasional lookups. Results are (c) OpenStreetMap contributors, ODbL.
246
+
247
+ ## Design
248
+
249
+ - `BBox` is immutable, hashable, picklable and slotted. `==` and `hash` are exact so boxes can
250
+ be dict keys; `equals(other, tol=1e-9)` compares with a tolerance (longitudes modulo 360).
251
+ - Validation on construction: finite numbers, `south <= north`, latitudes and longitudes in
252
+ range for geographic CRSs, `west <= east` for projected ones. `BBox.wrap()` accepts unwrapped
253
+ longitudes such as `170 .. 190`.
254
+ - CRS strings are normalised (`4326`, `"epsg:4326"`, URNs, OGC URLs, `CRS84` all become
255
+ `"EPSG:4326"`).
256
+ - Cell lookups (`to_geohash()`, `to_pluscode()`, ...) use exact integer arithmetic where the
257
+ format allows it, so round trips are stable.
258
+
259
+ ## Development
260
+
261
+ ```bash
262
+ pip install -e ".[dev,pyproj]"
263
+ pytest --cov=bboxkit
264
+ ruff check . && ruff format --check src tests
265
+ mypy --strict src
266
+ ```
267
+
268
+ ## License
269
+
270
+ MIT, see [LICENSE](LICENSE). Copyright (c) 2026 Yusuf Eminoğlu.
@@ -0,0 +1,233 @@
1
+ # bboxkit
2
+
3
+ **Bounding boxes, without the guesswork.** Parse a bbox from whatever tool handed it to you,
4
+ convert it to whatever the next tool wants, and do correct geometry on it, including boxes
5
+ that cross the antimeridian. Pure Python, zero dependencies, fully typed.
6
+
7
+ ```bash
8
+ pip install bboxkit # zero dependencies
9
+ pip install "bboxkit[pyproj]" # optional: reproject to any CRS
10
+ ```
11
+
12
+ ```python
13
+ >>> from bboxkit import BBox, parse
14
+ >>> box = parse("29.0,40.9,29.2,41.1", order="wsen") # Istanbul
15
+ >>> box.to_overpass()
16
+ '(40.9,29,41.1,29.2)'
17
+ >>> box.to_gdal_te()
18
+ '-te 29 40.9 29.2 41.1'
19
+ >>> round(box.area_km2(), 3) # exact on the WGS 84 ellipsoid
20
+ 373.741
21
+ ```
22
+
23
+ Every tool agrees that a bounding box has four numbers. Almost nothing else is agreed:
24
+ GeoJSON and Shapely write `west,south,east,north`, Overpass and Leaflet write
25
+ `south,west,north,east`, `gdal_translate -projwin` wants `west,north,east,south`, Nominatim
26
+ returns `[south, north, west, east]` as strings, and a box around Fiji has `west > east`.
27
+ bboxkit is a small, dependency-free value type that knows all of these conventions, so other
28
+ packages can depend on it instead of re-implementing (and re-debugging) them.
29
+
30
+ ## Supported formats
31
+
32
+ | Format | Example | Parse | Format |
33
+ | --- | --- | --- | --- |
34
+ | W,S,E,N (GeoJSON, Shapely, OSM API, STAC) | `29,40.9,29.2,41.1` | `parse(s, order="wsen")` | `to_wsen()` |
35
+ | S,W,N,E (lat-first) | `40.9,29,41.1,29.2` | `parse(s, order="swne")` | `to_swne()` |
36
+ | bboxfinder.com | `http://bboxfinder.com/#40.9,29,41.1,29.2` | auto | `to_bboxfinder()` |
37
+ | Overpass QL | `(40.9,29,41.1,29.2)`, `[bbox:40.9,29,41.1,29.2]` | auto | `to_overpass()` |
38
+ | OSM URL | `...?bbox=W,S,E,N`, `?minlon=...`, `#map=14/41.0/29.0` | auto (`viewport=`) | `to_osm_url()` |
39
+ | WKT / EWKT | `POLYGON ((...))`, `SRID=3857;MULTIPOINT(...)`, any geometry | auto | `to_wkt()` |
40
+ | ENVELOPE (CQL, Solr) | `ENVELOPE(29, 29.2, 41.1, 40.9)` | auto | `to_wkt_envelope()` |
41
+ | PostGIS | `ST_MakeEnvelope(29, 40.9, 29.2, 41.1, 4326)`, `BOX(29 40.9,29.2 41.1)` | auto | `to_postgis()` |
42
+ | GeoJSON | `bbox` member, or computed from any geometry / Feature / FeatureCollection | auto | `to_geojson()` |
43
+ | Shapely / GeoPandas | `geom.bounds`, `gdf.total_bounds`, `__geo_interface__` | `parse(obj)` | `to_shapely_bounds()` |
44
+ | GDAL / OGR | `-te 29 40.9 29.2 41.1`, `-projwin 29 41.1 29.2 40.9`, `-spat ...`, `-te_srs` | auto | `to_gdal_te()`, `to_gdal_projwin()` |
45
+ | Leaflet / Google | `[[40.9, 29], [41.1, 29.2]]` | `parse_leaflet()` | `to_leaflet()` |
46
+ | Mapbox / MapLibre | `[[29, 40.9], [29.2, 41.1]]` | `parse_mapbox()` | `to_mapbox()` |
47
+ | XYZ tile | `14/9375/6142`, `https://tile.../14/9375/6142.png` | auto | `to_tile()` |
48
+ | Quadkey (Bing) | `1202102332221212` | auto | `to_quadkey()` |
49
+ | Geohash | `sxk9` | auto | `to_geohash()` |
50
+ | Plus Code (OLC) | `8FVC9G8F+6W` | auto | `to_pluscode()` |
51
+ | MGRS square | `35TPF`, `31U DQ 48251 11932` | auto | `to_mgrs()` |
52
+ | Dictionaries | Nominatim result, ArcGIS extent (`wkid`), STAC collection, Leaflet `_southWest`, Google `southwest`, `{minx, ...}` | `parse(d)` | `to_dict()` |
53
+ | Place name | `"Istanbul"` | `nominatim.geocode_bbox()` (opt-in, network) | |
54
+
55
+ For cell formats (tile, quadkey, geohash, Plus Code, MGRS) parsing gives the cell's box and
56
+ formatting gives the **smallest cell that contains the whole box**.
57
+
58
+ Not included, on purpose: H3 (needs the icosahedral projection, i.e. the `h3` library) and
59
+ the polar UPS part of MGRS.
60
+
61
+ ## One-liners
62
+
63
+ ```python
64
+ from bboxkit import BBox, parse, union_all
65
+
66
+ parse("http://bboxfinder.com/#40.9,29,41.1,29.2") # BBox(29.0, 40.9, 29.2, 41.1)
67
+ parse("[bbox:40.9,29,41.1,29.2]") # Overpass setting
68
+ parse("gdalwarp -t_srs EPSG:32635 -te 500000 4500000 510000 4510000 in.tif out.tif")
69
+ parse("https://www.openstreetmap.org/#map=14/41.0082/28.9784", viewport=(1920, 1080))
70
+ parse("SRID=3857;POLYGON((0 0, 10 0, 10 10, 0 0))") # crs='EPSG:3857'
71
+ parse({"type": "Feature", "geometry": {"type": "LineString", "coordinates": [[179, 0], [-179, 1]]}})
72
+ parse({"boundingbox": ["40.9", "41.1", "29.0", "29.2"]}) # a Nominatim result
73
+ parse(shapely_polygon) # uses .bounds
74
+ parse("14/9375/6142"); parse("sxk9"); parse("8FVC9G8F+6W"); parse("35TPF")
75
+
76
+ box = BBox(29.0, 40.9, 29.2, 41.1)
77
+ box.to_wkt() # 'POLYGON ((29 40.9, 29.2 40.9, 29.2 41.1, 29 41.1, 29 40.9))'
78
+ box.to_geojson() # {'type': 'Polygon', 'coordinates': [...], 'bbox': [...]}
79
+ box.to_leaflet() # [[40.9, 29.0], [41.1, 29.2]]
80
+ box.to_gdal_projwin() # '-projwin 29 41.1 29.2 40.9'
81
+ box.to_osm_url() # 'https://www.openstreetmap.org/#map=11/41.00008/29.10000'
82
+ box.format("postgis") # 'ST_MakeEnvelope(29, 40.9, 29.2, 41.1, 4326)'
83
+ box.to_geohash(), box.to_tile(), box.to_mgrs() # ('sxk', Tile(x=2, y=1, z=2), '35TPF')
84
+
85
+ box.area_km2() # 373.741450485826 (exact for a lat/lon rectangle on WGS 84)
86
+ box.center # (29.1, 41.0)
87
+ box.width_m, box.height_m # (16827.0..., 22210.7...)
88
+ box.buffer(500) # grown by 500 m on every side
89
+ box.buffer(-500) # shrunk by 500 m
90
+ box.contains((29.1, 41.0)); box.intersects(other); box & other; box | other
91
+ box.grid(4, 3) # 12 cells, row by row from the north-west
92
+ box.split(1000) # ~1 km x 1 km cells
93
+ box.tiles(14) # [Tile(x=9511, y=6136, z=14), ...]
94
+ box.tile_count(14) # 130
95
+ box.expand_to_aspect(16 / 9) # fit a 16:9 web map (Mercator), never shrinks
96
+ BBox(29.03, 40.91, 29.17, 41.08).snap(0.1) # BBox(29.0, 40.9, 29.2, 41.1)
97
+ round(box, 2)
98
+ box.to_crs("EPSG:3857") # built in
99
+ box.to_utm() # built in: EPSG:32635 for Istanbul
100
+ box.to_crs("EPSG:3035") # any CRS with the [pyproj] extra
101
+ w, s, e, n = box # unpacks like a tuple
102
+ ```
103
+
104
+ ## The antimeridian
105
+
106
+ For longitude/latitude boxes, `west > east` means the box crosses the 180th meridian, exactly
107
+ as in RFC 7946:
108
+
109
+ ```python
110
+ >>> fiji = BBox(177, -20, -178, -16)
111
+ >>> fiji.crosses_antimeridian, fiji.width, fiji.center
112
+ (True, 5.0, (179.5, -18.0))
113
+ >>> fiji.contains((-179.5, -18))
114
+ True
115
+ >>> fiji.parts()
116
+ (BBox(177.0, -20.0, 180.0, -16.0), BBox(-180.0, -20.0, -178.0, -16.0))
117
+ >>> BBox(170, 0, 175, 1) | BBox(-175, 0, -170, 1)
118
+ BBox(170.0, 0.0, -170.0, 1.0)
119
+ >>> fiji.to_geojson()["type"] # split into two parts, as RFC 7946 recommends
120
+ 'MultiPolygon'
121
+ >>> fiji.to_leaflet() # one contiguous box on a web map
122
+ [[-20.0, 177.0], [-16.0, 182.0]]
123
+ ```
124
+
125
+ - `union()` / `union_all()` and bounding boxes computed from GeoJSON or WKT use the smallest
126
+ longitude range that covers everything (the complement of the largest gap), so the RFC 7946
127
+ Fiji example gives `[177, -20, -178, -16]`, not `[-178, -20, 177, -16]`.
128
+ - A line or ring whose consecutive vertices are more than 180 degrees apart is read as crossing
129
+ the antimeridian (the shorter way round).
130
+ - `intersection()` returns `None`, one box, or raises if the overlap is two separate pieces
131
+ (wide boxes overlapping at both ends); `intersection_parts()` always returns the list.
132
+ - Formats that cannot express a crossing box (`-te`, `-projwin`, `ST_MakeEnvelope`, Shapely
133
+ bounds) raise a `ValueError` that points to `parts()`.
134
+ - In Web Mercator a crossing box keeps contiguous x values (east beyond 20037508.34 m) and
135
+ converts back to a crossing box.
136
+
137
+ ## W,S,E,N or S,W,N,E?
138
+
139
+ Four bare numbers are ambiguous. bboxkit reads them as `west,south,east,north` unless only the
140
+ lat-first reading is valid, and emits an `AmbiguousOrderWarning` whenever both readings are
141
+ possible. Say what you mean to make the warning go away:
142
+
143
+ ```python
144
+ parse("40.9,29,41.1,29.2", order="swne")
145
+ parse("swne:40.9,29,41.1,29.2")
146
+ parse(text, fmt="leaflet")
147
+ ```
148
+
149
+ ## Area, buffers and grids
150
+
151
+ - `area_km2()` uses the closed-form area of a region bounded by two meridians and two parallels
152
+ on the WGS 84 ellipsoid (via the authalic latitude function). It matches a densely densified
153
+ `pyproj.Geod` polygon to ~1e-11. 1 x 1 degree at the equator is 12308.4639 km2, at 60 N
154
+ 6123.1409 km2; the whole globe is 510065621.724 km2.
155
+ - `buffer(m)` uses the local radii of curvature; the longitude margin is taken at the most
156
+ poleward latitude, so every point within `m` metres of the box is inside the result. Boxes
157
+ that reach a pole get the full longitude range.
158
+ - `split(cell_m)` builds a regular lon/lat grid whose cells measure `cell_m` at the box's central
159
+ latitude; `grid(nx, ny)` splits evenly. Both work across the antimeridian.
160
+ - Projected boxes (UTM, any metric CRS) do all of this in CRS units.
161
+
162
+ ## Reprojection
163
+
164
+ `to_crs()` transforms densified edges (21 points per edge by default) and takes the extent.
165
+ WGS 84, Web Mercator (`EPSG:3857` and its aliases 900913, 3785, 102100) and WGS 84 / UTM
166
+ (`EPSG:326xx`, `EPSG:327xx`, ETRS89 `EPSG:258xx`) are built in; UTM uses the 6th-order Krueger
167
+ series (agrees with PROJ to nanometres). Anything else goes to pyproj when it is installed.
168
+ `to_utm()` picks the zone of the centre, including the Norway and Svalbard exceptions.
169
+
170
+ ## Command line
171
+
172
+ ```bash
173
+ $ bboxkit convert "29.0,40.9,29.2,41.1" --to overpass
174
+ bboxkit: warning: 29,40.9,29.2,41.1 is ambiguous; read as west,south,east,north. ...
175
+ (40.9,29,41.1,29.2)
176
+ $ bboxkit convert "29.0,40.9,29.2,41.1" --order wsen --to all
177
+ $ bboxkit convert "http://bboxfinder.com/#40.9,29,41.1,29.2" --to gdal-projwin
178
+ $ bboxkit area "29.0,40.9,29.2,41.1" --order wsen
179
+ 373.741
180
+ $ bboxkit tiles "29.0,40.9,29.2,41.1" --order wsen --zoom 14 --count
181
+ 130
182
+ $ bboxkit tiles "29.0,40.9,29.2,41.1" --order wsen --zoom 14 --format quadkey
183
+ $ bboxkit split "29.0,40.9,29.2,41.1" --order wsen --cell 1km --to geojson > grid.geojson
184
+ $ bboxkit split "29.0,40.9,29.2,41.1" --order wsen --grid 4x3
185
+ $ bboxkit buffer "POINT(29 41)" --distance 2km --to wkt
186
+ $ bboxkit reproject "29.0,40.9,29.2,41.1" --order wsen --to-crs utm
187
+ $ bboxkit info "177,-20,-178,-16" --order wsen
188
+ $ bboxkit geocode "Kadikoy, Istanbul" --user-agent "my-app/1.0 (me@example.org)"
189
+ $ bboxkit formats
190
+ ```
191
+
192
+ Boxes that start with a minus sign (`-122.5,37.7,-122.3,37.8`, `-te 1 2 3 4`) work as they are;
193
+ `-` reads the box from standard input.
194
+
195
+ ## Place names (optional, network)
196
+
197
+ `parse()` never touches the network. Place lookup is a separate, explicit call:
198
+
199
+ ```python
200
+ from bboxkit.nominatim import geocode_bbox
201
+
202
+ geocode_bbox("Kadikoy, Istanbul", user_agent="my-app/1.0 (me@example.org)", timeout=10)
203
+ ```
204
+
205
+ It follows the [Nominatim usage policy](https://operations.osmfoundation.org/policies/nominatim/):
206
+ at most one request per second per process, an identifying User-Agent (please set your own),
207
+ results cached in memory, and a `base_url=` for your own Nominatim instance when you need
208
+ more than occasional lookups. Results are (c) OpenStreetMap contributors, ODbL.
209
+
210
+ ## Design
211
+
212
+ - `BBox` is immutable, hashable, picklable and slotted. `==` and `hash` are exact so boxes can
213
+ be dict keys; `equals(other, tol=1e-9)` compares with a tolerance (longitudes modulo 360).
214
+ - Validation on construction: finite numbers, `south <= north`, latitudes and longitudes in
215
+ range for geographic CRSs, `west <= east` for projected ones. `BBox.wrap()` accepts unwrapped
216
+ longitudes such as `170 .. 190`.
217
+ - CRS strings are normalised (`4326`, `"epsg:4326"`, URNs, OGC URLs, `CRS84` all become
218
+ `"EPSG:4326"`).
219
+ - Cell lookups (`to_geohash()`, `to_pluscode()`, ...) use exact integer arithmetic where the
220
+ format allows it, so round trips are stable.
221
+
222
+ ## Development
223
+
224
+ ```bash
225
+ pip install -e ".[dev,pyproj]"
226
+ pytest --cov=bboxkit
227
+ ruff check . && ruff format --check src tests
228
+ mypy --strict src
229
+ ```
230
+
231
+ ## License
232
+
233
+ MIT, see [LICENSE](LICENSE). Copyright (c) 2026 Yusuf Eminoğlu.