Skip to content

Commit d85a5e9

Browse files
committed
refactor: lint all, add extra pre-commit hooks, allow env var db initialisation
1 parent 4d7810c commit d85a5e9

File tree

8 files changed

+243
-152
lines changed

8 files changed

+243
-152
lines changed

.pre-commit-config.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,15 @@ repos:
2626
- id: commitizen
2727
stages: [commit-msg]
2828

29+
# Autoformat: general file fixes / time savers
30+
- repo: https://github.com/pre-commit/pre-commit-hooks
31+
rev: v5.0.0
32+
hooks:
33+
- id: trailing-whitespace
34+
- id: check-added-large-files
35+
args: [--maxkb=1000]
36+
- id: mixed-line-ending
37+
2938
# Lint / autoformat: Python code
3039
- repo: https://github.com/astral-sh/ruff-pre-commit
3140
# Ruff version.

README.md

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,19 +72,54 @@ and has an acceptable performance penalty (see [benchmarks](#benchmarks)).
7272
> - Reduced load on free services such as Nominatim (particularly when running
7373
> in automated tests frequently).
7474
75-
## Priorities
75+
### Priorities
7676

7777
- Lightweight package size.
7878
- Minimal memory footprint.
7979
- Reasonably good performance.
8080

81-
## How This Package Works
81+
### How This Package Works
8282

8383
- geonames.org data.
8484
- Voronoi polygons based on geopoints.
8585
- Gzipped data bundled with package.
8686
- Query the Voronois.
8787

88+
## Usage
89+
90+
### Install
91+
92+
Distributed as a pip package on PyPi:
93+
94+
```bash
95+
pip install pg-nearest-city
96+
# or use your dependency manager of choice
97+
```
98+
99+
### Run The Code
100+
101+
Async
102+
103+
```python
104+
105+
```
106+
107+
### Optional (Configure With Env Vars)
108+
109+
- If your app upstream already has a psycopg connection, this can be
110+
passed through.
111+
- If you require a new database connection, the connection parameters
112+
can be defined as DbConfig object variables, or alternatively
113+
as variables from your system environment:
114+
115+
```dotenv
116+
PGNEAREST_DB_NAME=cities
117+
PGNEAREST_DB_USER=cities
118+
PGNEAREST_DB_PASSWORD=somepassword
119+
PGNEAREST_DB_HOST=localhost
120+
PGNEAREST_DB_PORT=5432
121+
```
122+
88123
## Benchmarks
89124

90125
- todo

pg_nearest_city/async_nearest_city.py

Lines changed: 62 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,32 @@
1+
"""Main logic."""
2+
3+
import gzip
14
import importlib.resources
2-
from pg_nearest_city import base_nearest_city
5+
import logging
6+
from contextlib import asynccontextmanager
7+
from typing import Optional
8+
39
import psycopg
4-
import gzip
510
from psycopg import AsyncCursor
611

7-
from typing import Optional, Union
8-
from pg_nearest_city.base_nearest_city import BaseNearestCity
9-
from pg_nearest_city.base_nearest_city import Location
10-
from pg_nearest_city.base_nearest_city import InitializationStatus
11-
from contextlib import asynccontextmanager
12-
13-
import logging
14-
from typing import Optional
12+
from pg_nearest_city import base_nearest_city
13+
from pg_nearest_city.base_nearest_city import (
14+
BaseNearestCity,
15+
InitializationStatus,
16+
Location,
17+
)
1518

1619
logger = logging.getLogger("pg_nearest_city")
1720

21+
1822
class AsyncNearestCity:
23+
"""Reverse geocoding to the nearest city over 1000 population."""
24+
1925
@classmethod
2026
@asynccontextmanager
2127
async def connect(cls, db: psycopg.AsyncConnection | base_nearest_city.DbConfig):
22-
"""Create a managed NearestCity instance with automatic initialization and cleanup.
23-
28+
"""Managed NearestCity instance with automatic initialization and cleanup.
29+
2430
Args:
2531
db: Either a DbConfig for a new connection or an existing psycopg Connection
2632
"""
@@ -34,20 +40,24 @@ async def connect(cls, db: psycopg.AsyncConnection | base_nearest_city.DbConfig)
3440
conn = await psycopg.AsyncConnection.connect(db.get_connection_string())
3541

3642
geocoder = cls(conn)
37-
43+
3844
try:
3945
await geocoder.initialize()
4046
yield geocoder
4147
finally:
4248
if not is_external_connection:
4349
await conn.close()
4450

51+
def __init__(
52+
self,
53+
connection: psycopg.AsyncConnection,
54+
logger: Optional[logging.Logger] = None,
55+
):
56+
"""Initialize reverse geocoder with an existing AsyncConnection.
4557
46-
def __init__(self, connection: psycopg.AsyncConnection, logger: Optional[logging.Logger] = None):
47-
"""Initialize reverse geocoder with an existing AsyncConnection
48-
4958
Args:
5059
db: An existing psycopg AsyncConnection
60+
connection: psycopg.AsyncConnection
5161
logger: Optional custom logger. If not provided, uses package logger
5262
"""
5363
# Allow users to provide their own logger while having a sensible default
@@ -69,51 +79,52 @@ async def initialize(self) -> None:
6979
async with self.connection.cursor() as cur:
7080
self._logger.info("Starting database initialization check")
7181
status = await self._check_initialization_status(cur)
72-
82+
7383
if status.is_fully_initialized:
7484
self._logger.info("Database already properly initialized")
7585
return
76-
86+
7787
if status.has_table and not status.is_fully_initialized:
7888
missing = status.get_missing_components()
7989
self._logger.warning(
80-
"Database needs repair. Missing components: %s",
81-
", ".join(missing)
90+
"Database needs repair. Missing components: %s",
91+
", ".join(missing),
8292
)
8393
self._logger.info("Reinitializing from scratch")
8494
await cur.execute("DROP TABLE IF EXISTS pg_nearest_city_geocoding;")
85-
95+
8696
self._logger.info("Creating geocoding table")
8797
await self._create_geocoding_table(cur)
88-
98+
8999
self._logger.info("Importing city data")
90100
await self._import_cities(cur)
91-
101+
92102
self._logger.info("Processing Voronoi polygons")
93103
await self._import_voronoi_polygons(cur)
94-
104+
95105
self._logger.info("Creating spatial index")
96106
await self._create_spatial_index(cur)
97-
107+
98108
await self.connection.commit()
99-
109+
100110
self._logger.debug("Verifying final initialization state")
101111
final_status = await self._check_initialization_status(cur)
102112
if not final_status.is_fully_initialized:
103113
missing = final_status.get_missing_components()
104114
self._logger.error(
105115
"Initialization failed final validation. Missing: %s",
106-
", ".join(missing)
116+
", ".join(missing),
107117
)
108118
raise RuntimeError(
109-
f"Initialization failed final validation. Missing components: {', '.join(missing)}"
119+
"Initialization failed final validation. "
120+
f"Missing components: {', '.join(missing)}"
110121
)
111-
122+
112123
self._logger.info("Initialization complete and verified")
113-
124+
114125
except Exception as e:
115126
self._logger.error("Database initialization failed: %s", str(e))
116-
raise RuntimeError(f"Database initialization failed: {str(e)}")
127+
raise RuntimeError(f"Database initialization failed: {str(e)}") from e
117128

118129
async def query(self, lat: float, lon: float) -> Optional[Location]:
119130
"""Find the nearest city to the given coordinates using Voronoi regions.
@@ -129,7 +140,6 @@ async def query(self, lat: float, lon: float) -> Optional[Location]:
129140
ValueError: If coordinates are out of valid ranges
130141
RuntimeError: If database query fails
131142
"""
132-
133143
# Validate coordinate ranges
134144
BaseNearestCity.validate_coordinates(lon, lat)
135145

@@ -151,26 +161,27 @@ async def query(self, lat: float, lon: float) -> Optional[Location]:
151161
)
152162
except Exception as e:
153163
self._logger.error(f"Reverse geocoding failed: {str(e)}")
154-
raise RuntimeError(f"Reverse geocoding failed: {str(e)}")
164+
raise RuntimeError(f"Reverse geocoding failed: {str(e)}") from e
155165

156-
157-
async def _check_initialization_status(self, cur: psycopg.AsyncCursor) -> InitializationStatus:
166+
async def _check_initialization_status(
167+
self, cur: psycopg.AsyncCursor
168+
) -> InitializationStatus:
158169
"""Check the status and integrity of the geocoding database.
159170
160-
Performs essential validation checks to ensure the database is properly initialized
161-
and contains valid data.
171+
Performs essential validation checks to ensure the database is
172+
properly initialized and contains valid data.
162173
"""
163174
status = InitializationStatus()
164-
175+
165176
# Check table existence
166-
await cur.execute(BaseNearestCity._get_table_existance_query())
177+
await cur.execute(BaseNearestCity._get_tableexistence_query())
167178
table_exists = await cur.fetchone()
168179
status.has_table = bool(table_exists and table_exists[0])
169-
180+
170181
# If table doesn't exist, we can't check other properties
171182
if not status.has_table:
172183
return status
173-
184+
174185
# Check table structure
175186
await cur.execute(BaseNearestCity._get_table_structure_query())
176187
columns = {col: dtype for col, dtype in await cur.fetchall()}
@@ -186,20 +197,20 @@ async def _check_initialization_status(self, cur: psycopg.AsyncCursor) -> Initia
186197
# If table doesn't have valid structure, we can't check other properties
187198
if not status.has_valid_structure:
188199
return status
189-
200+
190201
# Check data completeness
191202
await cur.execute(BaseNearestCity._get_data_completeness_query())
192203
counts = await cur.fetchone()
193204
total_cities, cities_with_voronoi = counts
194-
205+
195206
status.has_data = total_cities > 0
196207
status.has_complete_voronoi = cities_with_voronoi == total_cities
197-
208+
198209
# Check spatial index
199210
await cur.execute(BaseNearestCity._get_spatial_index_check_query())
200211
has_index = await cur.fetchone()
201212
status.has_spatial_index = bool(has_index and has_index[0])
202-
213+
203214
return status
204215

205216
async def _import_cities(self, cur: AsyncCursor):
@@ -218,21 +229,22 @@ async def _import_cities(self, cur: AsyncCursor):
218229
self._logger.info(f"Imported {copied_bytes:,} bytes of city data")
219230

220231
async def _create_geocoding_table(self, cur: AsyncCursor):
221-
"""Create the main table"""
232+
"""Create the main table."""
222233
await cur.execute("""
223234
CREATE TABLE pg_nearest_city_geocoding (
224235
city varchar,
225236
country varchar,
226237
lat decimal,
227238
lon decimal,
228-
geom geometry(Point,4326) GENERATED ALWAYS AS (ST_SetSRID(ST_MakePoint(lon, lat), 4326)) STORED,
239+
geom geometry(Point,4326)
240+
GENERATED ALWAYS AS (ST_SetSRID(ST_MakePoint(lon, lat), 4326))
241+
STORED,
229242
voronoi geometry(Polygon,4326)
230243
);
231244
""")
232245

233246
async def _import_voronoi_polygons(self, cur: AsyncCursor):
234247
"""Import and integrate Voronoi polygons into the main table."""
235-
236248
if not self.voronoi_file.exists():
237249
raise FileNotFoundError(f"Voronoi file not found: {self.voronoi_file}")
238250

@@ -268,7 +280,7 @@ async def _import_voronoi_polygons(self, cur: AsyncCursor):
268280
async def _create_spatial_index(self, cur: AsyncCursor):
269281
"""Create a spatial index on the Voronoi polygons for efficient queries."""
270282
await cur.execute("""
271-
CREATE INDEX geocoding_voronoi_idx
272-
ON pg_nearest_city_geocoding
283+
CREATE INDEX geocoding_voronoi_idx
284+
ON pg_nearest_city_geocoding
273285
USING GIST (voronoi);
274286
""")

0 commit comments

Comments
 (0)