Skip to content

Commit 75897e3

Browse files
committed
Refactor for asyncpg support
1 parent b43ddd4 commit 75897e3

37 files changed

+448
-285
lines changed

README.md

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,30 @@
11
[![Circle CI](https://img.shields.io/circleci/project/yohanboniface/psycopg-postgis.svg)](https://circleci.com/gh/yohanboniface/psycopg-postgis) [![PyPI](https://img.shields.io/pypi/v/psycopg-postgis.svg)](https://pypi.python.org/pypi/psycopg-postgis) [![PyPI](https://img.shields.io/pypi/pyversions/psycopg-postgis.svg)](https://pypi.python.org/pypi/psycopg-postgis) [![PyPI](https://img.shields.io/pypi/implementation/psycopg-postgis.svg)](https://pypi.python.org/pypi/psycopg-postgis) [![PyPI](https://img.shields.io/pypi/status/psycopg-postgis.svg)](https://pypi.python.org/pypi/psycopg-postgis)
22

3-
# psycopg-postgis
3+
# python-postgis
44

5-
PostGIS helpers for psycopg2.
5+
PostGIS helpers for psycopg2 and asyncpg.
66

77
## Install
88

9-
pip install psycopg-postgis
9+
pip install postgis
1010

1111
If you want a compiled version, first install `cython`:
1212

1313
pip install cython
14-
pip install psycopg-postgis
14+
pip install postgis
1515

1616

1717
## Usage
1818

1919
You need to register the extension:
2020

21-
> import postgis
22-
> postgis.register(mydatabase.get_cursor())
21+
# With psycopg2
22+
> from postgis.psycopg import register
23+
> register(connection)
24+
25+
# With asyncpg
26+
> from postgis.asyncpg import register
27+
> await register(connection)
2328

2429
Then you can pass python geometries instance to psycopg:
2530

@@ -36,10 +41,10 @@ And retrieve data as python geometries instances:
3641
## Example
3742

3843
> import psycopg2
39-
> from postgis import register, LineString
44+
> from postgis import LineString
45+
> from postgis.psycopg import register
4046
> db = psycopg2.connect(dbname="test")
41-
> cursor = db.cursor()
42-
> register(cursor)
47+
> register(db)
4348
> cursor.execute('CREATE TABLE IF NOT EXISTS mytable ("geom" geometry(LineString) NOT NULL)')
4449
> cursor.execute('INSERT INTO mytable (geom) VALUES (%s)', [LineString([(1, 2), (3, 4)], srid=4326)])
4550
> cursor.execute('SELECT geom FROM mytable LIMIT 1')

circle.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
machine:
22
python:
3-
version: 3.4.3
3+
version: 3.6.1
44

55
database:
66
pre:
@@ -13,4 +13,4 @@ test:
1313

1414
dependencies:
1515
pre:
16-
- pip install pytest
16+
- pip install pytest psycopg2 asyncpg cython

postgis/__init__.py

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,22 @@
1-
"Postgis helpers for psycopg2."
2-
from .geometry import Geometry, register
3-
from .point import Point
1+
"Postgis helpers for psycopg2 and asyncpg."
2+
from .geometry import Geometry
3+
from .geometrycollection import GeometryCollection
44
from .linestring import LineString
5-
from .polygon import Polygon
6-
from .multipoint import MultiPoint
75
from .multilinestring import MultiLineString
6+
from .multipoint import MultiPoint
87
from .multipolygon import MultiPolygon
9-
from .geometrycollection import GeometryCollection
10-
from .__meta__ import __version__
8+
from .point import Point
9+
from .polygon import Polygon
10+
from .psycopg import register # Retrocompat.
1111

1212
__all__ = ['Geometry', 'register', 'Point', 'LineString', 'Polygon',
1313
'MultiPoint', 'MultiLineString', 'MultiPolygon',
14-
'GeometryCollection', '__version__']
14+
'GeometryCollection']
15+
16+
try:
17+
import pkg_resources
18+
except ImportError: # pragma: no cover
19+
pass
20+
else:
21+
if __package__:
22+
VERSION = pkg_resources.get_distribution(__package__).version

postgis/__meta__.py

Lines changed: 0 additions & 8 deletions
This file was deleted.

postgis/asyncpg.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from .geometry import Geometry
2+
3+
4+
async def register(connection):
5+
6+
def encoder(value):
7+
if not isinstance(value, Geometry):
8+
raise ValueError('Geometry value must subclass Geometry class')
9+
return value.to_ewkb()
10+
11+
def decoder(value):
12+
return Geometry.from_ewkb(value)
13+
14+
await connection.set_type_codec(
15+
'geography', encoder=encoder, decoder=decoder)
16+
await connection.set_type_codec(
17+
'geometry', encoder=encoder, decoder=decoder)

postgis/ewkb.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import binascii
2+
from io import BytesIO
3+
import struct
4+
5+
6+
class Typed(type):
7+
8+
types = {}
9+
10+
def __new__(mcs, name, bases, attrs, **kwargs):
11+
cls = super().__new__(mcs, name, bases, attrs, **kwargs)
12+
if hasattr(cls, 'TYPE'):
13+
Typed.types[cls.TYPE] = cls
14+
return cls
15+
16+
def __call__(cls, *args, **kwargs):
17+
# Allow to pass an instance as first argument, for blind casting.
18+
if args and isinstance(args[0], cls):
19+
return args[0]
20+
return super().__call__(*args, **kwargs)
21+
22+
23+
class Reader:
24+
25+
__slots__ = ['stream', 'endianness', 'has_z', 'has_m']
26+
27+
def __init__(self, stream):
28+
self.stream = stream
29+
30+
def clone(self):
31+
return type(self)(self.stream)
32+
33+
def read(self):
34+
# https://en.wikipedia.org/wiki/Well-known_text#Well-known_binary
35+
byte_order = self.stream.read(1)
36+
if byte_order == b'\x00':
37+
self.endianness = b'>'
38+
elif byte_order == b'\x01':
39+
self.endianness = b'<'
40+
else:
41+
raise Exception('invalid encoding')
42+
43+
type_ = self.read_int()
44+
self.has_z = bool(type_ & 0x80000000)
45+
self.has_m = bool(type_ & 0x40000000)
46+
srid = self.read_int() if bool(type_ & 0x20000000) else None
47+
type_ &= 0x1fffffff
48+
49+
try:
50+
class_ = Typed.types[type_]
51+
except KeyError:
52+
raise ValueError('unsupported geometry type {0}'.format(type_))
53+
else:
54+
return class_.from_ewkb_body(self, srid)
55+
56+
def read_int(self):
57+
return struct.unpack(self.endianness + b'I', self.stream.read(4))[0]
58+
59+
def read_double(self):
60+
return struct.unpack(self.endianness + b'd', self.stream.read(8))[0]
61+
62+
@classmethod
63+
def from_hex(cls, value):
64+
return cls(BytesIO(binascii.a2b_hex(value))).read()
65+
66+
67+
class Writer:
68+
69+
__slots__ = ['stream']
70+
71+
def __init__(self, geometry, stream=None):
72+
self.stream = stream or BytesIO()
73+
try:
74+
type_ = geometry.TYPE
75+
except AttributeError:
76+
raise ValueError('Unknown geometry {}'.format(geometry.__class__))
77+
78+
# Little endian.
79+
self.stream.write(b'\x01')
80+
self.write_int(
81+
type_ |
82+
(0x80000000 if geometry.has_z else 0) |
83+
(0x40000000 if geometry.has_m else 0) |
84+
(0x20000000 if geometry.has_srid else 0))
85+
if geometry.has_srid:
86+
self.write_int(geometry.srid)
87+
88+
def write_int(self, value):
89+
self.stream.write(struct.pack(b'<I', value))
90+
91+
def write_double(self, value):
92+
self.stream.write(struct.pack(b'<d', value))
93+
94+
def clone(self, geometry):
95+
return type(self)(geometry, self.stream)
96+
97+
@classmethod
98+
def to_hex(cls, value):
99+
writer = cls(value)
100+
value.write_ewkb_body(writer)
101+
return binascii.b2a_hex(writer.stream.getvalue()).upper()

postgis/geometry.py

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
1-
from psycopg2 import extensions as _ext
2-
from .reader import EWKBReader, Typed
1+
try:
2+
# Do not make psycopg2 a requirement.
3+
from psycopg2.extensions import ISQLQuote
4+
except ImportError:
5+
print('psycopg2 not installed')
6+
7+
8+
from .ewkb import Reader, Typed, Writer
39
from .geojson import GeoJSON
410

511

@@ -21,14 +27,25 @@ def has_srid(self):
2127
def from_ewkb(value, cursor=None):
2228
if not value:
2329
return None
24-
return EWKBReader.from_hex(value)
30+
return Reader.from_hex(value)
31+
32+
def to_ewkb(self):
33+
return Writer.to_hex(self).decode()
34+
35+
def write_ewkb(self, writer):
36+
self.write_ewkb_body(writer.clone(self))
37+
38+
def text(self):
39+
return "ST_GeometryFromText('{}', {})".format(self.wkt, self.srid)
2540

41+
# Psycopg2 interface.
2642
def __conform__(self, protocol):
27-
if protocol is _ext.ISQLQuote:
43+
if protocol is ISQLQuote:
2844
return self
2945

3046
def getquoted(self):
31-
return "ST_GeometryFromText('{}', {})".format(self.wkt, self.srid)
47+
return "'{}'".format(self.to_ewkb())
48+
# End Psycopg2 interface.
3249

3350
def __str__(self):
3451
return self.wkt
@@ -55,15 +72,3 @@ def geojson(self):
5572
'type': self.name,
5673
'coordinates': self.coords
5774
})
58-
59-
60-
def register(cursor):
61-
cursor.execute("SELECT NULL::geometry")
62-
oid = cursor.description[0][1]
63-
GEOMETRY = _ext.new_type((oid, ), "GEOMETRY", Geometry.from_ewkb)
64-
_ext.register_type(GEOMETRY)
65-
66-
cursor.execute("SELECT NULL::geography")
67-
oid = cursor.description[0][1]
68-
GEOGRAPHY = _ext.new_type((oid, ), "GEOGRAPHY", Geometry.from_ewkb)
69-
_ext.register_type(GEOGRAPHY)

postgis/geometrycollection.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ def __getitem__(self, item):
3737
def from_ewkb_body(cls, reader, srid=None):
3838
return cls([reader.read() for index in range(reader.read_int())], srid)
3939

40+
def write_ewkb_body(self, writer):
41+
writer.write_int(len(self.geoms))
42+
for geom in self:
43+
geom.write_ewkb(writer)
44+
4045
@property
4146
def wkt_coords(self):
4247
return ', '.join(g.wkt for g in self)

postgis/linestring.py

Lines changed: 7 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,18 @@
1-
from .geometry import Geometry
21
from .point import Point
2+
from .multi import Multi
33

44

5-
class LineString(Geometry):
5+
class LineString(Multi):
66

77
TYPE = 2
8-
9-
def __init__(self, points, srid=None):
10-
self.points = list(points)
11-
if srid:
12-
self.srid = srid
13-
14-
def __iter__(self):
15-
for point in self.points:
16-
if not isinstance(point, Point):
17-
point = Point(*point)
18-
yield point
19-
20-
@property
21-
def has_z(self):
22-
return self.points[0].has_z
23-
24-
@property
25-
def has_m(self):
26-
return self.points[0].has_m
27-
28-
def __getitem__(self, item):
29-
return self.points[item]
8+
SUBCLASS = Point
309

3110
@classmethod
3211
def from_ewkb_body(cls, reader, srid=None):
3312
return cls([Point.from_ewkb_body(reader)
3413
for index in range(reader.read_int())], srid)
3514

36-
@property
37-
def wkt_coords(self):
38-
return ', '.join(p.wkt_coords for p in self)
39-
40-
@property
41-
def coords(self):
42-
return tuple(p.coords for p in self)
15+
def write_ewkb_body(self, writer):
16+
writer.write_int(len(self.geoms))
17+
for geom in self:
18+
geom.write_ewkb_body(writer)

postgis/multi.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,16 @@
33

44
class Multi(Geometry):
55

6+
__slots__ = ['geoms', 'srid']
67
SUBCLASS = None
78

89
def __init__(self, geoms, srid=None):
9-
self.geoms = list(geoms)
10+
self.geoms = [self.SUBCLASS(g, srid=srid) for g in geoms]
1011
if srid:
1112
self.srid = srid
1213

1314
def __iter__(self):
14-
for geom in self.geoms:
15-
if not isinstance(geom, self.SUBCLASS):
16-
geom = self.SUBCLASS(geom)
17-
yield geom
15+
return iter(self.geoms)
1816

1917
@property
2018
def has_z(self):
@@ -35,6 +33,11 @@ def from_ewkb_body(cls, reader, srid=None):
3533
def wkt_coords(self):
3634
return ', '.join('({})'.format(g.wkt_coords) for g in self)
3735

36+
def write_ewkb_body(self, writer):
37+
writer.write_int(len(self.geoms))
38+
for geom in self:
39+
geom.write_ewkb(writer)
40+
3841
@property
3942
def coords(self):
4043
return tuple(g.coords for g in self)

0 commit comments

Comments
 (0)