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.
@@ -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,11 @@
1
+ """Top-level package for `sqlalchemy-postgres-point`.
2
+
3
+ Exports:
4
+ PointType: SQLAlchemy custom type representing a PostgreSQL POINT column.
5
+ """
6
+
7
+ from .point import PointType
8
+
9
+ __all__ = [
10
+ "PointType",
11
+ ]
@@ -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