sqlalchemy-postgres-point 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.
- sqlalchemy_postgres_point-0.1.0/PKG-INFO +116 -0
- sqlalchemy_postgres_point-0.1.0/README.md +104 -0
- sqlalchemy_postgres_point-0.1.0/pyproject.toml +37 -0
- sqlalchemy_postgres_point-0.1.0/sqlalchemy_postgres_point/__init__.py +11 -0
- sqlalchemy_postgres_point-0.1.0/sqlalchemy_postgres_point/point.py +90 -0
- sqlalchemy_postgres_point-0.1.0/sqlalchemy_postgres_point/utils.py +31 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: sqlalchemy-postgres-point
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lightweight SQLAlchemy custom type for PostgreSQL POINT columns with tuple binding and an earth-distance comparator.
|
|
5
|
+
Keywords: sqlalchemy,postgresql,point,geometry,geospatial,gis,database,types,earthdistance
|
|
6
|
+
Author: Michael Bianco
|
|
7
|
+
Author-email: Michael Bianco <mike@mikebian.co>
|
|
8
|
+
Requires-Dist: sqlalchemy>=2.0
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Project-URL: Repository, https://github.com/iloveitaly/sqlalchemy-postgres-point
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
sqlalchemy-postgres-point
|
|
14
|
+
=========================
|
|
15
|
+
|
|
16
|
+
Lightweight, pure-Python SQLAlchemy custom type for PostgreSQL `POINT` columns.
|
|
17
|
+
|
|
18
|
+
Why
|
|
19
|
+
----
|
|
20
|
+
|
|
21
|
+
PostgreSQL has a native `POINT` type (stored internally as a pair of float8 values). SQLAlchemy does not ship a dedicated high-level type wrapper for simple geometric primitives. This package provides a very small `PointType` you can use immediately without pulling in a full spatial stack (e.g. PostGIS + GeoAlchemy2) when all you need is storing and retrieving `(longitude, latitude)` pairs.
|
|
22
|
+
|
|
23
|
+
Features
|
|
24
|
+
--------
|
|
25
|
+
|
|
26
|
+
* Simple `(lng, lat)` tuple binding and result conversion.
|
|
27
|
+
* Safe NULL handling.
|
|
28
|
+
* Literal rendering for DDL / SQL emission.
|
|
29
|
+
* Custom comparator exposing the PostgreSQL earth-distance `<@>` operator (returns a `Float`).
|
|
30
|
+
* `cache_ok = True` for SQLAlchemy 2.x compilation caching.
|
|
31
|
+
|
|
32
|
+
Installation
|
|
33
|
+
------------
|
|
34
|
+
|
|
35
|
+
Using `uv` (recommended):
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
uv add sqlalchemy-postgres-point
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Or with pip:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install sqlalchemy-postgres-point
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Usage
|
|
48
|
+
-----
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from sqlalchemy import Column, Integer
|
|
52
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
53
|
+
from sqlalchemy_postgres_point import PointType
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Base(DeclarativeBase):
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Place(Base):
|
|
61
|
+
__tablename__ = "places"
|
|
62
|
+
id = Column(Integer, primary_key=True)
|
|
63
|
+
# Store as (longitude, latitude)
|
|
64
|
+
location = Column(PointType)
|
|
65
|
+
|
|
66
|
+
# Example query using the custom comparator
|
|
67
|
+
from sqlalchemy import select
|
|
68
|
+
|
|
69
|
+
origin = (0.0, 0.0)
|
|
70
|
+
stmt = select(Place.id, Place.location.earth_distance(origin).label("dist"))
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The comparator translates `Place.location.earth_distance(origin)` into SQL using the `<@>` operator (requires PostgreSQL with the `cube` / `earthdistance` extension for meaningful results; without extensions the operator may not exist—adapt as needed for your environment). This library only *emits* the operator; it does not manage PostgreSQL extensions.
|
|
74
|
+
|
|
75
|
+
Returned Python Values
|
|
76
|
+
----------------------
|
|
77
|
+
|
|
78
|
+
Values are loaded as a 2-tuple of floats `(lng, lat)` or `None` when NULL.
|
|
79
|
+
|
|
80
|
+
Testing
|
|
81
|
+
-------
|
|
82
|
+
|
|
83
|
+
Run the test suite with:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
uv run pytest -q
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Development
|
|
90
|
+
-----------
|
|
91
|
+
|
|
92
|
+
After cloning:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
uv sync # installs runtime + dev deps
|
|
96
|
+
uv run pytest -q
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Project Structure
|
|
100
|
+
-----------------
|
|
101
|
+
|
|
102
|
+
* `sqlalchemy_postgres_point/point.py` – Implementation of `PointType`.
|
|
103
|
+
* `tests/test_point.py` – Unit tests for processors and comparator.
|
|
104
|
+
|
|
105
|
+
Limitations / Notes
|
|
106
|
+
-------------------
|
|
107
|
+
|
|
108
|
+
* No automatic validation of longitude/latitude ranges; add your own if required.
|
|
109
|
+
* Uses simple textual representation `(lng,lat)` accepted by PostgreSQL `POINT` input parser.
|
|
110
|
+
* If you need advanced spatial indexing / SRID support, look at GeoAlchemy2/PostGIS instead.
|
|
111
|
+
|
|
112
|
+
License
|
|
113
|
+
-------
|
|
114
|
+
|
|
115
|
+
MIT (see your project's LICENSE file if added later). Contributions welcome.
|
|
116
|
+
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
sqlalchemy-postgres-point
|
|
2
|
+
=========================
|
|
3
|
+
|
|
4
|
+
Lightweight, pure-Python SQLAlchemy custom type for PostgreSQL `POINT` columns.
|
|
5
|
+
|
|
6
|
+
Why
|
|
7
|
+
----
|
|
8
|
+
|
|
9
|
+
PostgreSQL has a native `POINT` type (stored internally as a pair of float8 values). SQLAlchemy does not ship a dedicated high-level type wrapper for simple geometric primitives. This package provides a very small `PointType` you can use immediately without pulling in a full spatial stack (e.g. PostGIS + GeoAlchemy2) when all you need is storing and retrieving `(longitude, latitude)` pairs.
|
|
10
|
+
|
|
11
|
+
Features
|
|
12
|
+
--------
|
|
13
|
+
|
|
14
|
+
* Simple `(lng, lat)` tuple binding and result conversion.
|
|
15
|
+
* Safe NULL handling.
|
|
16
|
+
* Literal rendering for DDL / SQL emission.
|
|
17
|
+
* Custom comparator exposing the PostgreSQL earth-distance `<@>` operator (returns a `Float`).
|
|
18
|
+
* `cache_ok = True` for SQLAlchemy 2.x compilation caching.
|
|
19
|
+
|
|
20
|
+
Installation
|
|
21
|
+
------------
|
|
22
|
+
|
|
23
|
+
Using `uv` (recommended):
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
uv add sqlalchemy-postgres-point
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Or with pip:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install sqlalchemy-postgres-point
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Usage
|
|
36
|
+
-----
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from sqlalchemy import Column, Integer
|
|
40
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
41
|
+
from sqlalchemy_postgres_point import PointType
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Base(DeclarativeBase):
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Place(Base):
|
|
49
|
+
__tablename__ = "places"
|
|
50
|
+
id = Column(Integer, primary_key=True)
|
|
51
|
+
# Store as (longitude, latitude)
|
|
52
|
+
location = Column(PointType)
|
|
53
|
+
|
|
54
|
+
# Example query using the custom comparator
|
|
55
|
+
from sqlalchemy import select
|
|
56
|
+
|
|
57
|
+
origin = (0.0, 0.0)
|
|
58
|
+
stmt = select(Place.id, Place.location.earth_distance(origin).label("dist"))
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The comparator translates `Place.location.earth_distance(origin)` into SQL using the `<@>` operator (requires PostgreSQL with the `cube` / `earthdistance` extension for meaningful results; without extensions the operator may not exist—adapt as needed for your environment). This library only *emits* the operator; it does not manage PostgreSQL extensions.
|
|
62
|
+
|
|
63
|
+
Returned Python Values
|
|
64
|
+
----------------------
|
|
65
|
+
|
|
66
|
+
Values are loaded as a 2-tuple of floats `(lng, lat)` or `None` when NULL.
|
|
67
|
+
|
|
68
|
+
Testing
|
|
69
|
+
-------
|
|
70
|
+
|
|
71
|
+
Run the test suite with:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
uv run pytest -q
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Development
|
|
78
|
+
-----------
|
|
79
|
+
|
|
80
|
+
After cloning:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
uv sync # installs runtime + dev deps
|
|
84
|
+
uv run pytest -q
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Project Structure
|
|
88
|
+
-----------------
|
|
89
|
+
|
|
90
|
+
* `sqlalchemy_postgres_point/point.py` – Implementation of `PointType`.
|
|
91
|
+
* `tests/test_point.py` – Unit tests for processors and comparator.
|
|
92
|
+
|
|
93
|
+
Limitations / Notes
|
|
94
|
+
-------------------
|
|
95
|
+
|
|
96
|
+
* No automatic validation of longitude/latitude ranges; add your own if required.
|
|
97
|
+
* Uses simple textual representation `(lng,lat)` accepted by PostgreSQL `POINT` input parser.
|
|
98
|
+
* If you need advanced spatial indexing / SRID support, look at GeoAlchemy2/PostGIS instead.
|
|
99
|
+
|
|
100
|
+
License
|
|
101
|
+
-------
|
|
102
|
+
|
|
103
|
+
MIT (see your project's LICENSE file if added later). Contributions welcome.
|
|
104
|
+
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "sqlalchemy-postgres-point"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Lightweight SQLAlchemy custom type for PostgreSQL POINT columns with tuple binding and an earth-distance comparator."
|
|
5
|
+
keywords = [
|
|
6
|
+
"sqlalchemy",
|
|
7
|
+
"postgresql",
|
|
8
|
+
"point",
|
|
9
|
+
"geometry",
|
|
10
|
+
"geospatial",
|
|
11
|
+
"gis",
|
|
12
|
+
"database",
|
|
13
|
+
"types",
|
|
14
|
+
"earthdistance",
|
|
15
|
+
]
|
|
16
|
+
readme = "README.md"
|
|
17
|
+
requires-python = ">=3.10"
|
|
18
|
+
dependencies = ["sqlalchemy>=2.0"]
|
|
19
|
+
authors = [{ name = "Michael Bianco", email = "mike@mikebian.co" }]
|
|
20
|
+
urls = { "Repository" = "https://github.com/iloveitaly/sqlalchemy-postgres-point" }
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
[build-system]
|
|
24
|
+
requires = ["uv_build>=0.8.11,<0.9.0"]
|
|
25
|
+
build-backend = "uv_build"
|
|
26
|
+
|
|
27
|
+
[tool.uv.build-backend]
|
|
28
|
+
# avoids the src/ directory structure
|
|
29
|
+
module-root = ""
|
|
30
|
+
|
|
31
|
+
[dependency-groups]
|
|
32
|
+
dev = [
|
|
33
|
+
"pytest>=8.3.3",
|
|
34
|
+
"sqlmodel>=0.0.21",
|
|
35
|
+
"psycopg[binary]>=3.2.0",
|
|
36
|
+
"activemodel",
|
|
37
|
+
]
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""
|
|
2
|
+
|
|
3
|
+
- Points should be stored as (longitude, latitude)
|
|
4
|
+
|
|
5
|
+
References:
|
|
6
|
+
|
|
7
|
+
- https://stackoverflow.com/questions/37233116/point-type-in-sqlalchemy
|
|
8
|
+
- https://gist.github.com/kwatch/02b1a5a8899b67df2623
|
|
9
|
+
- https://geoalchemy.readthedocs.io/en/0.5/intro.html
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import math
|
|
13
|
+
import re
|
|
14
|
+
from typing import Tuple
|
|
15
|
+
|
|
16
|
+
from sqlalchemy.types import Float, UserDefinedType
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PointType(UserDefinedType):
|
|
20
|
+
cache_ok = True
|
|
21
|
+
|
|
22
|
+
def get_col_spec(self, **kw):
|
|
23
|
+
return "POINT"
|
|
24
|
+
|
|
25
|
+
@staticmethod
|
|
26
|
+
def _validate_point(value: Tuple[float, float]) -> Tuple[float, float]:
|
|
27
|
+
"""Validate and normalize a (lng, lat) tuple.
|
|
28
|
+
|
|
29
|
+
- Ensures it's a 2-length tuple/list
|
|
30
|
+
- Casts to float and checks finiteness (no NaN/inf)
|
|
31
|
+
- Checks ranges: lng in [-180, 180], lat in [-90, 90]
|
|
32
|
+
"""
|
|
33
|
+
try:
|
|
34
|
+
lng, lat = value # type: ignore[misc]
|
|
35
|
+
except Exception as exc: # pragma: no cover - type/len issues
|
|
36
|
+
raise ValueError("Point must be a 2-tuple of (lng, lat)") from exc
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
lng_f = float(lng)
|
|
40
|
+
lat_f = float(lat)
|
|
41
|
+
except Exception as exc:
|
|
42
|
+
raise ValueError("Point coordinates must be numeric") from exc
|
|
43
|
+
|
|
44
|
+
if not (math.isfinite(lng_f) and math.isfinite(lat_f)):
|
|
45
|
+
raise ValueError("Point coordinates must be finite numbers")
|
|
46
|
+
|
|
47
|
+
if not (-180.0 <= lng_f <= 180.0):
|
|
48
|
+
raise ValueError("Longitude must be within [-180, 180]")
|
|
49
|
+
if not (-90.0 <= lat_f <= 90.0):
|
|
50
|
+
raise ValueError("Latitude must be within [-90, 90]")
|
|
51
|
+
|
|
52
|
+
return (lng_f, lat_f)
|
|
53
|
+
|
|
54
|
+
def bind_processor(self, dialect):
|
|
55
|
+
def process(value):
|
|
56
|
+
if value is None:
|
|
57
|
+
return None
|
|
58
|
+
lng, lat = self._validate_point(value)
|
|
59
|
+
return f"({lng},{lat})"
|
|
60
|
+
|
|
61
|
+
return process
|
|
62
|
+
|
|
63
|
+
def literal_processor(self, dialect):
|
|
64
|
+
def process(value):
|
|
65
|
+
if value is None:
|
|
66
|
+
return "NULL"
|
|
67
|
+
lng, lat = self._validate_point(value)
|
|
68
|
+
return f"'({lng},{lat})'"
|
|
69
|
+
|
|
70
|
+
return process
|
|
71
|
+
|
|
72
|
+
def result_processor(self, dialect, coltype):
|
|
73
|
+
def process(value):
|
|
74
|
+
if value is None:
|
|
75
|
+
return None
|
|
76
|
+
match = re.match(r"\(([^)]+),([^)]+)\)", value)
|
|
77
|
+
if match:
|
|
78
|
+
lng = float(match.group(1))
|
|
79
|
+
lat = float(match.group(2))
|
|
80
|
+
# Validate loaded values as well to preserve invariants in Python domain
|
|
81
|
+
lng, lat = self._validate_point((lng, lat))
|
|
82
|
+
return (lng, lat)
|
|
83
|
+
raise ValueError(f"Invalid POINT value: {value}")
|
|
84
|
+
|
|
85
|
+
return process
|
|
86
|
+
|
|
87
|
+
class comparator_factory(UserDefinedType.Comparator):
|
|
88
|
+
def earth_distance(self, other):
|
|
89
|
+
"""Compute earth distance using the <@> operator, returning a Float."""
|
|
90
|
+
return self.op("<@>", return_type=Float())(other)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import math
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def haversine_miles(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
|
|
5
|
+
"""
|
|
6
|
+
Compute the straight-line distance between two locations on Earth, measured in miles.
|
|
7
|
+
|
|
8
|
+
This uses the Haversine formula, which treats the Earth as a sphere and returns the
|
|
9
|
+
great‑circle distance (the shortest path over the planet's surface). It is ideal for
|
|
10
|
+
proximity checks (e.g., “within 10 miles?”) and for sorting by closeness. The result
|
|
11
|
+
is “as‑the‑crow‑flies” distance: it does not reflect driving routes, traffic, terrain,
|
|
12
|
+
or elevation. For most application use cases up to a few hundred miles, the spherical
|
|
13
|
+
assumption provides a sufficiently accurate answer (typically within well under 1%).
|
|
14
|
+
|
|
15
|
+
Inputs are latitude and longitude in degrees for both points, and the output is a
|
|
16
|
+
floating‑point number in miles. The function is symmetric in its two points and does
|
|
17
|
+
not require any external services or API calls.
|
|
18
|
+
"""
|
|
19
|
+
R = 3958.8
|
|
20
|
+
|
|
21
|
+
lat1_rad = math.radians(lat1)
|
|
22
|
+
lat2_rad = math.radians(lat2)
|
|
23
|
+
dlat = math.radians(lat2 - lat1)
|
|
24
|
+
dlng = math.radians(lng2 - lng1)
|
|
25
|
+
|
|
26
|
+
a = (
|
|
27
|
+
math.sin(dlat / 2) ** 2
|
|
28
|
+
+ math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(dlng / 2) ** 2
|
|
29
|
+
)
|
|
30
|
+
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
|
31
|
+
return R * c
|