├── .github
└── workflows
│ └── pythonpackage.yml
├── .gitignore
├── .readthedocs.yaml
├── CHANGELOG.md
├── LICENSE
├── MANIFEST.in
├── Makefile
├── README.md
├── doc
├── Makefile
├── release.md
└── source
│ ├── conf.py
│ ├── examples.rst
│ ├── index.rst
│ ├── installation.rst
│ ├── plpygis.rst
│ ├── plpython.rst
│ ├── subclasses.rst
│ └── usage.rst
├── plpygis
├── __init__.py
├── _version.py
├── exceptions.py
├── geometry.py
├── hex.py
└── wkt.py
├── pyproject.toml
├── pytest.ini
├── requirements-test.txt
├── requirements.txt
└── test
├── multipoint.shp
└── test_geometry.py
/.github/workflows/pythonpackage.yml:
--------------------------------------------------------------------------------
1 | name: tests
2 |
3 | on: [push]
4 |
5 | jobs:
6 | build:
7 | runs-on: ubuntu-latest
8 | strategy:
9 | matrix:
10 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
11 |
12 | steps:
13 | - uses: actions/checkout@v4
14 | - name: Set up Python ${{ matrix.python-version }}
15 | uses: actions/setup-python@v5
16 | with:
17 | python-version: ${{ matrix.python-version }}
18 | - name: Install dependencies
19 | run: |
20 | python -m pip install --upgrade pip
21 | pip install -r requirements-test.txt
22 | - name: Test with pytest
23 | run: |
24 | python -m pytest
25 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | env/
12 | build/
13 | develop-eggs/
14 | dist/
15 | downloads/
16 | eggs/
17 | .eggs/
18 | lib/
19 | lib64/
20 | parts/
21 | sdist/
22 | var/
23 | wheels/
24 | *.egg-info/
25 | .installed.cfg
26 | *.egg
27 |
28 | # PyInstaller
29 | # Usually these files are written by a python script from a template
30 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
31 | *.manifest
32 | *.spec
33 |
34 | # Installer logs
35 | pip-log.txt
36 | pip-delete-this-directory.txt
37 |
38 | # Unit test / coverage reports
39 | htmlcov/
40 | .tox/
41 | .coverage
42 | .coverage.*
43 | .cache
44 | nosetests.xml
45 | coverage.xml
46 | *.cover
47 | .hypothesis/
48 |
49 | # Translations
50 | *.mo
51 | *.pot
52 |
53 | # Django stuff:
54 | *.log
55 | local_settings.py
56 |
57 | # Flask stuff:
58 | instance/
59 | .webassets-cache
60 |
61 | # Scrapy stuff:
62 | .scrapy
63 |
64 | # Sphinx documentation
65 | docs/_build/
66 |
67 | # PyBuilder
68 | target/
69 |
70 | # Jupyter Notebook
71 | .ipynb_checkpoints
72 |
73 | # pyenv
74 | .python-version
75 |
76 | # celery beat schedule file
77 | celerybeat-schedule
78 |
79 | # SageMath parsed files
80 | *.sage.py
81 |
82 | # dotenv
83 | .env
84 |
85 | # virtualenv
86 | .venv
87 | venv/
88 | ENV/
89 |
90 | # Spyder project settings
91 | .spyderproject
92 | .spyproject
93 |
94 | # Rope project settings
95 | .ropeproject
96 |
97 | # mkdocs documentation
98 | /site
99 |
100 | # mypy
101 | .mypy_cache/
102 |
103 | .pytest_cache
104 |
--------------------------------------------------------------------------------
/.readthedocs.yaml:
--------------------------------------------------------------------------------
1 | # Read the Docs configuration file for Sphinx projects
2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
3 |
4 | # Required
5 | version: 2
6 |
7 | # Set the OS, Python version and other tools you might need
8 | build:
9 | os: ubuntu-22.04
10 | tools:
11 | python: "3.12"
12 | # You can also specify other tool versions:
13 | # nodejs: "20"
14 | # rust: "1.70"
15 | # golang: "1.20"
16 |
17 | # Build documentation in the "docs/" directory with Sphinx
18 | sphinx:
19 | configuration: doc/source/conf.py
20 |
21 | # Optionally build your docs in additional formats such as PDF and ePub
22 | # formats:
23 | # - pdf
24 | # - epub
25 |
26 | # Optional but recommended, declare the Python requirements required
27 | # to build your documentation
28 | # See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html
29 | # python:
30 | # install:
31 | # - requirements: docs/requirements.txt
32 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## [0.5.5] - 2024-09-08
4 |
5 | ### Changed
6 |
7 | * WKTs again default to 6 decimal places of precision but this is now configurable
8 | * Check the number of coordinates in LineString and Polygon when reading from (E)WKT
9 |
10 | ### Fixed
11 |
12 | * `geojson` no longer returns `m` values instead of `z` values for LineStrings and Polygons
13 | * Eliminated possibility of scientific notation in (E)WKT output
14 |
15 | ## [0.5.4] - 2024-08-29
16 |
17 | ### Changed
18 |
19 | * `WktError` now correctly inherits from `PlpygisError`
20 |
21 | ### Added
22 |
23 | * Allow overriding SRID when reading from a WKT/EWKT
24 |
25 | ## [0.5.3] - 2024-08-18
26 |
27 | ### Fixed
28 |
29 | * Bug in WKTs when there is an integer ending in 0
30 |
31 | ## [0.5.2] - 2024-08-09
32 |
33 | ### Changed
34 |
35 | * WKTs now have full precision rather than just 6 decimal places
36 |
37 | ### Fixed
38 |
39 | * Bug in conversion to Shapely when there is an SRID
40 |
41 | ## [0.5.1] - 2024-08-01
42 |
43 | ### Fixed
44 |
45 | * Invalid WKB in certain circumstances
46 |
47 | ## [0.5.0] - 2024-07-31
48 |
49 | ### Changed
50 |
51 | * `wkb` now always returns a WKB and not a EWKB
52 | * `__copy__()` now performs a shallow copy for multigeometries
53 | * `geometries` is immutable (make changes to members using overloaded operators to ensure type checking)
54 |
55 | ### Added
56 |
57 | * Overloaded `len` and `[]` for multigeometries
58 | * Overloaded `+` and `+=` operators for geometries
59 | * `pop()` for multigeometries
60 | * `__deepcopy__()` for geometries
61 | * `from_wkt()` to read Well-known Text
62 | * `ewkb` to explicitly request an SRID
63 | * `wkt` and `ewkt` properties to write Well-known Text
64 |
65 | ## [0.4.2] - 2024-07-21
66 |
67 | ### Fixed
68 |
69 | * Documentation
70 | * CI works with Numpy 2.x
71 |
72 | ## [0.4.1] - 2024-04-30
73 |
74 | ### Changed
75 |
76 | * Raise `WkbError` on malformed WKBs.
77 |
78 | ### Added
79 |
80 | * New exception: `CollectionError`.
81 |
82 | ## [0.4.0] - 2024-04-22
83 |
84 | ### Added
85 |
86 | * All the `Geometry` classes now have a `coordinates` property, and this is also used in the generation of GeoJSONs.
87 | * The `Geometry` classes also now have a `__copy__()` method.
88 | * Two new exceptions were added `CoordinateError` and `GeojsonError`.
89 |
90 | ### Fixed
91 |
92 | * Two unused parameters (`dimz` and `dimm`) were removed from `GeometryCollection`.
93 |
94 | ## [0.3.0] - 2024-04-08
95 |
96 | ### Fixed
97 |
98 | * It was possible for invalid EWKBs to be generated for multigeometries with SRIDs. This fix does introduce a change to what `plpygis` accepts as valid parameters to any of the multigeometry types. However, it will only reject cases which would have produced bad EWKBs and anything that worked previously will still work now. This fixes https://github.com/bosth/plpygis/issues/10.
99 |
100 | ## [0.2.2] - 2024-03-08
101 |
102 | ### Changed
103 |
104 | * plpygis now works with Shapely 2.x and drops support for 1.x.
105 |
106 | ### Fixed
107 |
108 | * The license was updated to conform to the SPDX standard (https://github.com/bosth/plpygis/issues/9).
109 |
110 | ## [0.2.1] - 2023-03-11
111 |
112 | ### Fixed
113 |
114 | * `.pyc` files were being included in packages published to PyPI (https://github.com/bosth/plpygis/issues/8). [[vincentsarago](https://github.com/vincentsarago)]
115 |
116 | ## [0.2.0] - 2020-02-21
117 |
118 | ### Added
119 |
120 | * plpygis now supports binary WKBs as described in (https://github.com/bosth/plpygis/issues/1). [[lovasoa](https://github.com/lovasoa)]
121 |
122 | ### Fixed
123 |
124 | * A bug in handling little-endian WKBs was fixed (https://github.com/bosth/plpygis/issues/2). [[lovasoa](https://github.com/lovasoa)]
125 |
126 | ### Removed
127 |
128 | * The dependency on nose for testing was removed.
129 |
130 | ## [0.1.0] - 2018-02-14
131 | ## [0.0.3] - 2018-01-21
132 | ## [0.0.2] - 2017-08-06
133 | ## [0.0.1] - 2017-07-30
134 |
135 | [0.5.5]: https://github.com/bosth/plpygis/compare/v0.5.4...v0.5.5
136 | [0.5.4]: https://github.com/bosth/plpygis/compare/v0.5.3...v0.5.4
137 | [0.5.3]: https://github.com/bosth/plpygis/compare/v0.5.2...v0.5.3
138 | [0.5.2]: https://github.com/bosth/plpygis/compare/v0.5.1...v0.5.2
139 | [0.5.1]: https://github.com/bosth/plpygis/compare/v0.5.0...v0.5.1
140 | [0.5.0]: https://github.com/bosth/plpygis/compare/v0.4.2...v0.5.0
141 | [0.4.2]: https://github.com/bosth/plpygis/compare/v0.4.1...v0.4.2
142 | [0.4.1]: https://github.com/bosth/plpygis/compare/v0.4.0...v0.4.1
143 | [0.4.0]: https://github.com/bosth/plpygis/compare/v0.3.0...v0.4.0
144 | [0.3.0]: https://github.com/bosth/plpygis/compare/v0.2.2...v0.3.0
145 | [0.2.2]: https://github.com/bosth/plpygis/compare/v0.2.1...v0.2.2
146 | [0.2.1]: https://github.com/bosth/plpygis/compare/v0.2.0...v0.2.1
147 | [0.2.0]: https://github.com/bosth/plpygis/compare/v0.1.0...v0.2.0
148 | [0.1.0]: https://github.com/bosth/plpygis/compare/v0.0.3...v0.1.0
149 | [0.0.3]: https://github.com/bosth/plpygis/compare/v0.0.2...v0.0.3
150 | [0.0.2]: https://github.com/bosth/plpygis/compare/v0.0.1...v0.0.2
151 | [0.0.1]: https://github.com/bosth/plpygis/releases/tag/v0.0.1
152 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | GNU GENERAL PUBLIC LICENSE
2 | Version 3, 29 June 2007
3 |
4 | Copyright (C) 2007 Free Software Foundation, Inc.
5 | Everyone is permitted to copy and distribute verbatim copies
6 | of this license document, but changing it is not allowed.
7 |
8 | Preamble
9 |
10 | The GNU General Public License is a free, copyleft license for
11 | software and other kinds of works.
12 |
13 | The licenses for most software and other practical works are designed
14 | to take away your freedom to share and change the works. By contrast,
15 | the GNU General Public License is intended to guarantee your freedom to
16 | share and change all versions of a program--to make sure it remains free
17 | software for all its users. We, the Free Software Foundation, use the
18 | GNU General Public License for most of our software; it applies also to
19 | any other work released this way by its authors. You can apply it to
20 | your programs, too.
21 |
22 | When we speak of free software, we are referring to freedom, not
23 | price. Our General Public Licenses are designed to make sure that you
24 | have the freedom to distribute copies of free software (and charge for
25 | them if you wish), that you receive source code or can get it if you
26 | want it, that you can change the software or use pieces of it in new
27 | free programs, and that you know you can do these things.
28 |
29 | To protect your rights, we need to prevent others from denying you
30 | these rights or asking you to surrender the rights. Therefore, you have
31 | certain responsibilities if you distribute copies of the software, or if
32 | you modify it: responsibilities to respect the freedom of others.
33 |
34 | For example, if you distribute copies of such a program, whether
35 | gratis or for a fee, you must pass on to the recipients the same
36 | freedoms that you received. You must make sure that they, too, receive
37 | or can get the source code. And you must show them these terms so they
38 | know their rights.
39 |
40 | Developers that use the GNU GPL protect your rights with two steps:
41 | (1) assert copyright on the software, and (2) offer you this License
42 | giving you legal permission to copy, distribute and/or modify it.
43 |
44 | For the developers' and authors' protection, the GPL clearly explains
45 | that there is no warranty for this free software. For both users' and
46 | authors' sake, the GPL requires that modified versions be marked as
47 | changed, so that their problems will not be attributed erroneously to
48 | authors of previous versions.
49 |
50 | Some devices are designed to deny users access to install or run
51 | modified versions of the software inside them, although the manufacturer
52 | can do so. This is fundamentally incompatible with the aim of
53 | protecting users' freedom to change the software. The systematic
54 | pattern of such abuse occurs in the area of products for individuals to
55 | use, which is precisely where it is most unacceptable. Therefore, we
56 | have designed this version of the GPL to prohibit the practice for those
57 | products. If such problems arise substantially in other domains, we
58 | stand ready to extend this provision to those domains in future versions
59 | of the GPL, as needed to protect the freedom of users.
60 |
61 | Finally, every program is threatened constantly by software patents.
62 | States should not allow patents to restrict development and use of
63 | software on general-purpose computers, but in those that do, we wish to
64 | avoid the special danger that patents applied to a free program could
65 | make it effectively proprietary. To prevent this, the GPL assures that
66 | patents cannot be used to render the program non-free.
67 |
68 | The precise terms and conditions for copying, distribution and
69 | modification follow.
70 |
71 | TERMS AND CONDITIONS
72 |
73 | 0. Definitions.
74 |
75 | "This License" refers to version 3 of the GNU General Public License.
76 |
77 | "Copyright" also means copyright-like laws that apply to other kinds of
78 | works, such as semiconductor masks.
79 |
80 | "The Program" refers to any copyrightable work licensed under this
81 | License. Each licensee is addressed as "you". "Licensees" and
82 | "recipients" may be individuals or organizations.
83 |
84 | To "modify" a work means to copy from or adapt all or part of the work
85 | in a fashion requiring copyright permission, other than the making of an
86 | exact copy. The resulting work is called a "modified version" of the
87 | earlier work or a work "based on" the earlier work.
88 |
89 | A "covered work" means either the unmodified Program or a work based
90 | on the Program.
91 |
92 | To "propagate" a work means to do anything with it that, without
93 | permission, would make you directly or secondarily liable for
94 | infringement under applicable copyright law, except executing it on a
95 | computer or modifying a private copy. Propagation includes copying,
96 | distribution (with or without modification), making available to the
97 | public, and in some countries other activities as well.
98 |
99 | To "convey" a work means any kind of propagation that enables other
100 | parties to make or receive copies. Mere interaction with a user through
101 | a computer network, with no transfer of a copy, is not conveying.
102 |
103 | An interactive user interface displays "Appropriate Legal Notices"
104 | to the extent that it includes a convenient and prominently visible
105 | feature that (1) displays an appropriate copyright notice, and (2)
106 | tells the user that there is no warranty for the work (except to the
107 | extent that warranties are provided), that licensees may convey the
108 | work under this License, and how to view a copy of this License. If
109 | the interface presents a list of user commands or options, such as a
110 | menu, a prominent item in the list meets this criterion.
111 |
112 | 1. Source Code.
113 |
114 | The "source code" for a work means the preferred form of the work
115 | for making modifications to it. "Object code" means any non-source
116 | form of a work.
117 |
118 | A "Standard Interface" means an interface that either is an official
119 | standard defined by a recognized standards body, or, in the case of
120 | interfaces specified for a particular programming language, one that
121 | is widely used among developers working in that language.
122 |
123 | The "System Libraries" of an executable work include anything, other
124 | than the work as a whole, that (a) is included in the normal form of
125 | packaging a Major Component, but which is not part of that Major
126 | Component, and (b) serves only to enable use of the work with that
127 | Major Component, or to implement a Standard Interface for which an
128 | implementation is available to the public in source code form. A
129 | "Major Component", in this context, means a major essential component
130 | (kernel, window system, and so on) of the specific operating system
131 | (if any) on which the executable work runs, or a compiler used to
132 | produce the work, or an object code interpreter used to run it.
133 |
134 | The "Corresponding Source" for a work in object code form means all
135 | the source code needed to generate, install, and (for an executable
136 | work) run the object code and to modify the work, including scripts to
137 | control those activities. However, it does not include the work's
138 | System Libraries, or general-purpose tools or generally available free
139 | programs which are used unmodified in performing those activities but
140 | which are not part of the work. For example, Corresponding Source
141 | includes interface definition files associated with source files for
142 | the work, and the source code for shared libraries and dynamically
143 | linked subprograms that the work is specifically designed to require,
144 | such as by intimate data communication or control flow between those
145 | subprograms and other parts of the work.
146 |
147 | The Corresponding Source need not include anything that users
148 | can regenerate automatically from other parts of the Corresponding
149 | Source.
150 |
151 | The Corresponding Source for a work in source code form is that
152 | same work.
153 |
154 | 2. Basic Permissions.
155 |
156 | All rights granted under this License are granted for the term of
157 | copyright on the Program, and are irrevocable provided the stated
158 | conditions are met. This License explicitly affirms your unlimited
159 | permission to run the unmodified Program. The output from running a
160 | covered work is covered by this License only if the output, given its
161 | content, constitutes a covered work. This License acknowledges your
162 | rights of fair use or other equivalent, as provided by copyright law.
163 |
164 | You may make, run and propagate covered works that you do not
165 | convey, without conditions so long as your license otherwise remains
166 | in force. You may convey covered works to others for the sole purpose
167 | of having them make modifications exclusively for you, or provide you
168 | with facilities for running those works, provided that you comply with
169 | the terms of this License in conveying all material for which you do
170 | not control copyright. Those thus making or running the covered works
171 | for you must do so exclusively on your behalf, under your direction
172 | and control, on terms that prohibit them from making any copies of
173 | your copyrighted material outside their relationship with you.
174 |
175 | Conveying under any other circumstances is permitted solely under
176 | the conditions stated below. Sublicensing is not allowed; section 10
177 | makes it unnecessary.
178 |
179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
180 |
181 | No covered work shall be deemed part of an effective technological
182 | measure under any applicable law fulfilling obligations under article
183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or
184 | similar laws prohibiting or restricting circumvention of such
185 | measures.
186 |
187 | When you convey a covered work, you waive any legal power to forbid
188 | circumvention of technological measures to the extent such circumvention
189 | is effected by exercising rights under this License with respect to
190 | the covered work, and you disclaim any intention to limit operation or
191 | modification of the work as a means of enforcing, against the work's
192 | users, your or third parties' legal rights to forbid circumvention of
193 | technological measures.
194 |
195 | 4. Conveying Verbatim Copies.
196 |
197 | You may convey verbatim copies of the Program's source code as you
198 | receive it, in any medium, provided that you conspicuously and
199 | appropriately publish on each copy an appropriate copyright notice;
200 | keep intact all notices stating that this License and any
201 | non-permissive terms added in accord with section 7 apply to the code;
202 | keep intact all notices of the absence of any warranty; and give all
203 | recipients a copy of this License along with the Program.
204 |
205 | You may charge any price or no price for each copy that you convey,
206 | and you may offer support or warranty protection for a fee.
207 |
208 | 5. Conveying Modified Source Versions.
209 |
210 | You may convey a work based on the Program, or the modifications to
211 | produce it from the Program, in the form of source code under the
212 | terms of section 4, provided that you also meet all of these conditions:
213 |
214 | a) The work must carry prominent notices stating that you modified
215 | it, and giving a relevant date.
216 |
217 | b) The work must carry prominent notices stating that it is
218 | released under this License and any conditions added under section
219 | 7. This requirement modifies the requirement in section 4 to
220 | "keep intact all notices".
221 |
222 | c) You must license the entire work, as a whole, under this
223 | License to anyone who comes into possession of a copy. This
224 | License will therefore apply, along with any applicable section 7
225 | additional terms, to the whole of the work, and all its parts,
226 | regardless of how they are packaged. This License gives no
227 | permission to license the work in any other way, but it does not
228 | invalidate such permission if you have separately received it.
229 |
230 | d) If the work has interactive user interfaces, each must display
231 | Appropriate Legal Notices; however, if the Program has interactive
232 | interfaces that do not display Appropriate Legal Notices, your
233 | work need not make them do so.
234 |
235 | A compilation of a covered work with other separate and independent
236 | works, which are not by their nature extensions of the covered work,
237 | and which are not combined with it such as to form a larger program,
238 | in or on a volume of a storage or distribution medium, is called an
239 | "aggregate" if the compilation and its resulting copyright are not
240 | used to limit the access or legal rights of the compilation's users
241 | beyond what the individual works permit. Inclusion of a covered work
242 | in an aggregate does not cause this License to apply to the other
243 | parts of the aggregate.
244 |
245 | 6. Conveying Non-Source Forms.
246 |
247 | You may convey a covered work in object code form under the terms
248 | of sections 4 and 5, provided that you also convey the
249 | machine-readable Corresponding Source under the terms of this License,
250 | in one of these ways:
251 |
252 | a) Convey the object code in, or embodied in, a physical product
253 | (including a physical distribution medium), accompanied by the
254 | Corresponding Source fixed on a durable physical medium
255 | customarily used for software interchange.
256 |
257 | b) Convey the object code in, or embodied in, a physical product
258 | (including a physical distribution medium), accompanied by a
259 | written offer, valid for at least three years and valid for as
260 | long as you offer spare parts or customer support for that product
261 | model, to give anyone who possesses the object code either (1) a
262 | copy of the Corresponding Source for all the software in the
263 | product that is covered by this License, on a durable physical
264 | medium customarily used for software interchange, for a price no
265 | more than your reasonable cost of physically performing this
266 | conveying of source, or (2) access to copy the
267 | Corresponding Source from a network server at no charge.
268 |
269 | c) Convey individual copies of the object code with a copy of the
270 | written offer to provide the Corresponding Source. This
271 | alternative is allowed only occasionally and noncommercially, and
272 | only if you received the object code with such an offer, in accord
273 | with subsection 6b.
274 |
275 | d) Convey the object code by offering access from a designated
276 | place (gratis or for a charge), and offer equivalent access to the
277 | Corresponding Source in the same way through the same place at no
278 | further charge. You need not require recipients to copy the
279 | Corresponding Source along with the object code. If the place to
280 | copy the object code is a network server, the Corresponding Source
281 | may be on a different server (operated by you or a third party)
282 | that supports equivalent copying facilities, provided you maintain
283 | clear directions next to the object code saying where to find the
284 | Corresponding Source. Regardless of what server hosts the
285 | Corresponding Source, you remain obligated to ensure that it is
286 | available for as long as needed to satisfy these requirements.
287 |
288 | e) Convey the object code using peer-to-peer transmission, provided
289 | you inform other peers where the object code and Corresponding
290 | Source of the work are being offered to the general public at no
291 | charge under subsection 6d.
292 |
293 | A separable portion of the object code, whose source code is excluded
294 | from the Corresponding Source as a System Library, need not be
295 | included in conveying the object code work.
296 |
297 | A "User Product" is either (1) a "consumer product", which means any
298 | tangible personal property which is normally used for personal, family,
299 | or household purposes, or (2) anything designed or sold for incorporation
300 | into a dwelling. In determining whether a product is a consumer product,
301 | doubtful cases shall be resolved in favor of coverage. For a particular
302 | product received by a particular user, "normally used" refers to a
303 | typical or common use of that class of product, regardless of the status
304 | of the particular user or of the way in which the particular user
305 | actually uses, or expects or is expected to use, the product. A product
306 | is a consumer product regardless of whether the product has substantial
307 | commercial, industrial or non-consumer uses, unless such uses represent
308 | the only significant mode of use of the product.
309 |
310 | "Installation Information" for a User Product means any methods,
311 | procedures, authorization keys, or other information required to install
312 | and execute modified versions of a covered work in that User Product from
313 | a modified version of its Corresponding Source. The information must
314 | suffice to ensure that the continued functioning of the modified object
315 | code is in no case prevented or interfered with solely because
316 | modification has been made.
317 |
318 | If you convey an object code work under this section in, or with, or
319 | specifically for use in, a User Product, and the conveying occurs as
320 | part of a transaction in which the right of possession and use of the
321 | User Product is transferred to the recipient in perpetuity or for a
322 | fixed term (regardless of how the transaction is characterized), the
323 | Corresponding Source conveyed under this section must be accompanied
324 | by the Installation Information. But this requirement does not apply
325 | if neither you nor any third party retains the ability to install
326 | modified object code on the User Product (for example, the work has
327 | been installed in ROM).
328 |
329 | The requirement to provide Installation Information does not include a
330 | requirement to continue to provide support service, warranty, or updates
331 | for a work that has been modified or installed by the recipient, or for
332 | the User Product in which it has been modified or installed. Access to a
333 | network may be denied when the modification itself materially and
334 | adversely affects the operation of the network or violates the rules and
335 | protocols for communication across the network.
336 |
337 | Corresponding Source conveyed, and Installation Information provided,
338 | in accord with this section must be in a format that is publicly
339 | documented (and with an implementation available to the public in
340 | source code form), and must require no special password or key for
341 | unpacking, reading or copying.
342 |
343 | 7. Additional Terms.
344 |
345 | "Additional permissions" are terms that supplement the terms of this
346 | License by making exceptions from one or more of its conditions.
347 | Additional permissions that are applicable to the entire Program shall
348 | be treated as though they were included in this License, to the extent
349 | that they are valid under applicable law. If additional permissions
350 | apply only to part of the Program, that part may be used separately
351 | under those permissions, but the entire Program remains governed by
352 | this License without regard to the additional permissions.
353 |
354 | When you convey a copy of a covered work, you may at your option
355 | remove any additional permissions from that copy, or from any part of
356 | it. (Additional permissions may be written to require their own
357 | removal in certain cases when you modify the work.) You may place
358 | additional permissions on material, added by you to a covered work,
359 | for which you have or can give appropriate copyright permission.
360 |
361 | Notwithstanding any other provision of this License, for material you
362 | add to a covered work, you may (if authorized by the copyright holders of
363 | that material) supplement the terms of this License with terms:
364 |
365 | a) Disclaiming warranty or limiting liability differently from the
366 | terms of sections 15 and 16 of this License; or
367 |
368 | b) Requiring preservation of specified reasonable legal notices or
369 | author attributions in that material or in the Appropriate Legal
370 | Notices displayed by works containing it; or
371 |
372 | c) Prohibiting misrepresentation of the origin of that material, or
373 | requiring that modified versions of such material be marked in
374 | reasonable ways as different from the original version; or
375 |
376 | d) Limiting the use for publicity purposes of names of licensors or
377 | authors of the material; or
378 |
379 | e) Declining to grant rights under trademark law for use of some
380 | trade names, trademarks, or service marks; or
381 |
382 | f) Requiring indemnification of licensors and authors of that
383 | material by anyone who conveys the material (or modified versions of
384 | it) with contractual assumptions of liability to the recipient, for
385 | any liability that these contractual assumptions directly impose on
386 | those licensors and authors.
387 |
388 | All other non-permissive additional terms are considered "further
389 | restrictions" within the meaning of section 10. If the Program as you
390 | received it, or any part of it, contains a notice stating that it is
391 | governed by this License along with a term that is a further
392 | restriction, you may remove that term. If a license document contains
393 | a further restriction but permits relicensing or conveying under this
394 | License, you may add to a covered work material governed by the terms
395 | of that license document, provided that the further restriction does
396 | not survive such relicensing or conveying.
397 |
398 | If you add terms to a covered work in accord with this section, you
399 | must place, in the relevant source files, a statement of the
400 | additional terms that apply to those files, or a notice indicating
401 | where to find the applicable terms.
402 |
403 | Additional terms, permissive or non-permissive, may be stated in the
404 | form of a separately written license, or stated as exceptions;
405 | the above requirements apply either way.
406 |
407 | 8. Termination.
408 |
409 | You may not propagate or modify a covered work except as expressly
410 | provided under this License. Any attempt otherwise to propagate or
411 | modify it is void, and will automatically terminate your rights under
412 | this License (including any patent licenses granted under the third
413 | paragraph of section 11).
414 |
415 | However, if you cease all violation of this License, then your
416 | license from a particular copyright holder is reinstated (a)
417 | provisionally, unless and until the copyright holder explicitly and
418 | finally terminates your license, and (b) permanently, if the copyright
419 | holder fails to notify you of the violation by some reasonable means
420 | prior to 60 days after the cessation.
421 |
422 | Moreover, your license from a particular copyright holder is
423 | reinstated permanently if the copyright holder notifies you of the
424 | violation by some reasonable means, this is the first time you have
425 | received notice of violation of this License (for any work) from that
426 | copyright holder, and you cure the violation prior to 30 days after
427 | your receipt of the notice.
428 |
429 | Termination of your rights under this section does not terminate the
430 | licenses of parties who have received copies or rights from you under
431 | this License. If your rights have been terminated and not permanently
432 | reinstated, you do not qualify to receive new licenses for the same
433 | material under section 10.
434 |
435 | 9. Acceptance Not Required for Having Copies.
436 |
437 | You are not required to accept this License in order to receive or
438 | run a copy of the Program. Ancillary propagation of a covered work
439 | occurring solely as a consequence of using peer-to-peer transmission
440 | to receive a copy likewise does not require acceptance. However,
441 | nothing other than this License grants you permission to propagate or
442 | modify any covered work. These actions infringe copyright if you do
443 | not accept this License. Therefore, by modifying or propagating a
444 | covered work, you indicate your acceptance of this License to do so.
445 |
446 | 10. Automatic Licensing of Downstream Recipients.
447 |
448 | Each time you convey a covered work, the recipient automatically
449 | receives a license from the original licensors, to run, modify and
450 | propagate that work, subject to this License. You are not responsible
451 | for enforcing compliance by third parties with this License.
452 |
453 | An "entity transaction" is a transaction transferring control of an
454 | organization, or substantially all assets of one, or subdividing an
455 | organization, or merging organizations. If propagation of a covered
456 | work results from an entity transaction, each party to that
457 | transaction who receives a copy of the work also receives whatever
458 | licenses to the work the party's predecessor in interest had or could
459 | give under the previous paragraph, plus a right to possession of the
460 | Corresponding Source of the work from the predecessor in interest, if
461 | the predecessor has it or can get it with reasonable efforts.
462 |
463 | You may not impose any further restrictions on the exercise of the
464 | rights granted or affirmed under this License. For example, you may
465 | not impose a license fee, royalty, or other charge for exercise of
466 | rights granted under this License, and you may not initiate litigation
467 | (including a cross-claim or counterclaim in a lawsuit) alleging that
468 | any patent claim is infringed by making, using, selling, offering for
469 | sale, or importing the Program or any portion of it.
470 |
471 | 11. Patents.
472 |
473 | A "contributor" is a copyright holder who authorizes use under this
474 | License of the Program or a work on which the Program is based. The
475 | work thus licensed is called the contributor's "contributor version".
476 |
477 | A contributor's "essential patent claims" are all patent claims
478 | owned or controlled by the contributor, whether already acquired or
479 | hereafter acquired, that would be infringed by some manner, permitted
480 | by this License, of making, using, or selling its contributor version,
481 | but do not include claims that would be infringed only as a
482 | consequence of further modification of the contributor version. For
483 | purposes of this definition, "control" includes the right to grant
484 | patent sublicenses in a manner consistent with the requirements of
485 | this License.
486 |
487 | Each contributor grants you a non-exclusive, worldwide, royalty-free
488 | patent license under the contributor's essential patent claims, to
489 | make, use, sell, offer for sale, import and otherwise run, modify and
490 | propagate the contents of its contributor version.
491 |
492 | In the following three paragraphs, a "patent license" is any express
493 | agreement or commitment, however denominated, not to enforce a patent
494 | (such as an express permission to practice a patent or covenant not to
495 | sue for patent infringement). To "grant" such a patent license to a
496 | party means to make such an agreement or commitment not to enforce a
497 | patent against the party.
498 |
499 | If you convey a covered work, knowingly relying on a patent license,
500 | and the Corresponding Source of the work is not available for anyone
501 | to copy, free of charge and under the terms of this License, through a
502 | publicly available network server or other readily accessible means,
503 | then you must either (1) cause the Corresponding Source to be so
504 | available, or (2) arrange to deprive yourself of the benefit of the
505 | patent license for this particular work, or (3) arrange, in a manner
506 | consistent with the requirements of this License, to extend the patent
507 | license to downstream recipients. "Knowingly relying" means you have
508 | actual knowledge that, but for the patent license, your conveying the
509 | covered work in a country, or your recipient's use of the covered work
510 | in a country, would infringe one or more identifiable patents in that
511 | country that you have reason to believe are valid.
512 |
513 | If, pursuant to or in connection with a single transaction or
514 | arrangement, you convey, or propagate by procuring conveyance of, a
515 | covered work, and grant a patent license to some of the parties
516 | receiving the covered work authorizing them to use, propagate, modify
517 | or convey a specific copy of the covered work, then the patent license
518 | you grant is automatically extended to all recipients of the covered
519 | work and works based on it.
520 |
521 | A patent license is "discriminatory" if it does not include within
522 | the scope of its coverage, prohibits the exercise of, or is
523 | conditioned on the non-exercise of one or more of the rights that are
524 | specifically granted under this License. You may not convey a covered
525 | work if you are a party to an arrangement with a third party that is
526 | in the business of distributing software, under which you make payment
527 | to the third party based on the extent of your activity of conveying
528 | the work, and under which the third party grants, to any of the
529 | parties who would receive the covered work from you, a discriminatory
530 | patent license (a) in connection with copies of the covered work
531 | conveyed by you (or copies made from those copies), or (b) primarily
532 | for and in connection with specific products or compilations that
533 | contain the covered work, unless you entered into that arrangement,
534 | or that patent license was granted, prior to 28 March 2007.
535 |
536 | Nothing in this License shall be construed as excluding or limiting
537 | any implied license or other defenses to infringement that may
538 | otherwise be available to you under applicable patent law.
539 |
540 | 12. No Surrender of Others' Freedom.
541 |
542 | If conditions are imposed on you (whether by court order, agreement or
543 | otherwise) that contradict the conditions of this License, they do not
544 | excuse you from the conditions of this License. If you cannot convey a
545 | covered work so as to satisfy simultaneously your obligations under this
546 | License and any other pertinent obligations, then as a consequence you may
547 | not convey it at all. For example, if you agree to terms that obligate you
548 | to collect a royalty for further conveying from those to whom you convey
549 | the Program, the only way you could satisfy both those terms and this
550 | License would be to refrain entirely from conveying the Program.
551 |
552 | 13. Use with the GNU Affero General Public License.
553 |
554 | Notwithstanding any other provision of this License, you have
555 | permission to link or combine any covered work with a work licensed
556 | under version 3 of the GNU Affero General Public License into a single
557 | combined work, and to convey the resulting work. The terms of this
558 | License will continue to apply to the part which is the covered work,
559 | but the special requirements of the GNU Affero General Public License,
560 | section 13, concerning interaction through a network will apply to the
561 | combination as such.
562 |
563 | 14. Revised Versions of this License.
564 |
565 | The Free Software Foundation may publish revised and/or new versions of
566 | the GNU General Public License from time to time. Such new versions will
567 | be similar in spirit to the present version, but may differ in detail to
568 | address new problems or concerns.
569 |
570 | Each version is given a distinguishing version number. If the
571 | Program specifies that a certain numbered version of the GNU General
572 | Public License "or any later version" applies to it, you have the
573 | option of following the terms and conditions either of that numbered
574 | version or of any later version published by the Free Software
575 | Foundation. If the Program does not specify a version number of the
576 | GNU General Public License, you may choose any version ever published
577 | by the Free Software Foundation.
578 |
579 | If the Program specifies that a proxy can decide which future
580 | versions of the GNU General Public License can be used, that proxy's
581 | public statement of acceptance of a version permanently authorizes you
582 | to choose that version for the Program.
583 |
584 | Later license versions may give you additional or different
585 | permissions. However, no additional obligations are imposed on any
586 | author or copyright holder as a result of your choosing to follow a
587 | later version.
588 |
589 | 15. Disclaimer of Warranty.
590 |
591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
599 |
600 | 16. Limitation of Liability.
601 |
602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
610 | SUCH DAMAGES.
611 |
612 | 17. Interpretation of Sections 15 and 16.
613 |
614 | If the disclaimer of warranty and limitation of liability provided
615 | above cannot be given local legal effect according to their terms,
616 | reviewing courts shall apply local law that most closely approximates
617 | an absolute waiver of all civil liability in connection with the
618 | Program, unless a warranty or assumption of liability accompanies a
619 | copy of the Program in return for a fee.
620 |
621 | END OF TERMS AND CONDITIONS
622 |
623 | How to Apply These Terms to Your New Programs
624 |
625 | If you develop a new program, and you want it to be of the greatest
626 | possible use to the public, the best way to achieve this is to make it
627 | free software which everyone can redistribute and change under these terms.
628 |
629 | To do so, attach the following notices to the program. It is safest
630 | to attach them to the start of each source file to most effectively
631 | state the exclusion of warranty; and each file should have at least
632 | the "copyright" line and a pointer to where the full notice is found.
633 |
634 | {one line to give the program's name and a brief idea of what it does.}
635 | Copyright (C) {year} {name of author}
636 |
637 | This program is free software: you can redistribute it and/or modify
638 | it under the terms of the GNU General Public License as published by
639 | the Free Software Foundation, either version 3 of the License, or
640 | (at your option) any later version.
641 |
642 | This program is distributed in the hope that it will be useful,
643 | but WITHOUT ANY WARRANTY; without even the implied warranty of
644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
645 | GNU General Public License for more details.
646 |
647 | You should have received a copy of the GNU General Public License
648 | along with this program. If not, see .
649 |
650 | Also add information on how to contact you by electronic and paper mail.
651 |
652 | If the program does terminal interaction, make it output a short
653 | notice like this when it starts in an interactive mode:
654 |
655 | {project} Copyright (C) {year} {fullname}
656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
657 | This is free software, and you are welcome to redistribute it
658 | under certain conditions; type `show c' for details.
659 |
660 | The hypothetical commands `show w' and `show c' should show the appropriate
661 | parts of the General Public License. Of course, your program's commands
662 | might be different; for a GUI interface, you would use an "about box".
663 |
664 | You should also get your employer (if you work as a programmer) or school,
665 | if any, to sign a "copyright disclaimer" for the program, if necessary.
666 | For more information on this, and how to apply and follow the GNU GPL, see
667 | .
668 |
669 | The GNU General Public License does not permit incorporating your program
670 | into proprietary programs. If your program is a subroutine library, you
671 | may consider it more useful to permit linking proprietary applications with
672 | the library. If this is what you want to do, use the GNU Lesser General
673 | Public License instead of this License. But first, please read
674 | .
675 |
676 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include CHANGELOG.md
2 | include LICENSE
3 | include MANIFEST.in
4 | include README.md
5 | include test/*shp
6 |
7 | global-exclude *.py[cod]
8 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | .PHONY: test
2 |
3 | build: plpygis/*.py
4 | python -m build
5 |
6 | test:
7 | pytest
8 |
9 | cov:
10 | pytest --cov=plpygis --cov-report term-missing
11 |
12 | clean:
13 | find . -name "*.pyc" -print0 | xargs -0 rm -rf
14 | find . -name "__pycache__" -print0 | xargs -0 rm -rf
15 | rm -rf dist plpygis.egg-info
16 | rm -rf build doc/build
17 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # plpygis
2 |
3 | `plpygis` is a pure Python module with no dependencies that can convert geometries between [Well-known binary](https://en.wikipedia.org/wiki/Well-known_binary) (WKB/EWKB), [Well-known Text](https://en.wikipedia.org/wiki/Well-known_text_representation_of_geometry) (WKT/EWKT) and GeoJSON representations. `plpygis` is mainly intended for use in PostgreSQL [PL/Python](https://www.postgresql.org/docs/current/plpython.html) functions to augment [PostGIS](https://postgis.net/)'s native capabilities.
4 |
5 | ## Basic usage
6 |
7 | `plpygis` implements several subclasses of the `Geometry` class, such as `Point`, `LineString`, `MultiPolygon` and so on:
8 |
9 | ```python
10 | >>> from plpygis import Point
11 | >>> p = Point((-124.005, 49.005), srid=4326)
12 | >>> print(p.ewkb)
13 | 0101000020e6100000b81e85eb51005fc0713d0ad7a3804840
14 | >>> print(p.geojson)
15 | {'type': 'Point', 'coordinates': [-124.005, 49.005]}
16 | >>> p.z = 1
17 | >>> print(p.wkt)
18 | POINT Z (-124.005 49.005 1)
19 | ```
20 |
21 | ## Usage with PostGIS
22 |
23 | `plpygis` is designed to provide an easy way to implement PL/Python functions that accept `geometry` arguments or return `geometry` results. The following example will take a PostGIS `geometry(Point)` and use an external service to create a `geometry(PointZ)`.
24 |
25 | ```pgsql
26 | CREATE OR REPLACE FUNCTION add_elevation(geom geometry(POINT))
27 | RETURNS geometry(POINTZ)
28 | AS $$
29 | from plpygis import Geometry
30 | from requests import get
31 | p = Geometry(geom)
32 |
33 | response = get(f'https://api.open-meteo.com/v1/elevation?longitude={p.x}&latitude={p.y}')
34 | if response.status_code == 200:
35 | content = response.json()
36 | p.z = content['elevation'][0]
37 | return p
38 | else:
39 | return None
40 | $$ LANGUAGE plpython3u;
41 | ```
42 |
43 | The `Geometry()` constructor will convert a PostGIS `geometry` that has been passed as a parameter to the PL/Python function into one of its `plpygis` subclasses. A `Geometry` that is returned from the PL/Python function will automatically be converted back to a PostGIS `geometry`.
44 |
45 | The function above can be called as part of an SQL query:
46 |
47 | ```pgsql
48 | SELECT
49 | name,
50 | add_elevation(geom)
51 | FROM
52 | city;
53 | ```
54 |
55 | ## Documentation
56 |
57 | Full `plpygis` documentation is available at .
58 |
59 | [](https://github.com/bosth/plpygis/actions?query=workflow%3A%22tests%22) [](http://plpygis.readthedocs.io/en/latest/?badge=latest)
60 |
--------------------------------------------------------------------------------
/doc/Makefile:
--------------------------------------------------------------------------------
1 | # Minimal makefile for Sphinx documentation
2 | #
3 |
4 | # You can set these variables from the command line.
5 | SPHINXOPTS =
6 | SPHINXBUILD = sphinx-build
7 | SPHINXPROJ = plpygis
8 | SOURCEDIR = source
9 | BUILDDIR = build
10 |
11 | # Put it first so that "make" without argument is like "make help".
12 | help:
13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
14 |
15 | .PHONY: help Makefile
16 |
17 | # Catch-all target: route all unknown targets to Sphinx using the new
18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
19 | %: Makefile
20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
21 |
--------------------------------------------------------------------------------
/doc/release.md:
--------------------------------------------------------------------------------
1 | # Release process
2 |
3 | * Update `plpygis/_version.py`
4 | * Update `CHANGELOG.md`
5 | - including footnotes
6 | - update date to release line
7 | * Run `git push` and check CI for success
8 | * Run `git tag vX.X.X`
9 | * Run `git push origin vX.X.X`
10 | * Run `python -m build`
11 | * Run `twine upload dist/plpygis-X.X.X*`
12 | * On https://github.com/bosth/plpygis/tags, create a release from the new tag
13 | - Use vX.X.X tag
14 | - Use vX.X.X as the title
15 | - Add a description of changes
16 | - Click "Generate release notes"
17 |
--------------------------------------------------------------------------------
/doc/source/conf.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | #
3 | # plpygis documentation build configuration file, created by
4 | # sphinx-quickstart on Wed Jul 5 19:28:02 2017.
5 | #
6 | # This file is execfile()d with the current directory set to its
7 | # containing dir.
8 | #
9 | # Note that not all possible configuration values are present in this
10 | # autogenerated file.
11 | #
12 | # All configuration values have a default; values that are commented out
13 | # serve to show the default.
14 |
15 | # If extensions (or modules to document with autodoc) are in another directory,
16 | # add these directories to sys.path here. If the directory is relative to the
17 | # documentation root, use os.path.abspath to make it absolute, like shown here.
18 | #
19 | import os
20 | import sys
21 |
22 | sys.path.insert(0, os.path.abspath("../.."))
23 | import plpygis
24 |
25 | # -- General configuration ------------------------------------------------
26 |
27 | # If your documentation needs a minimal Sphinx version, state it here.
28 | #
29 | # needs_sphinx = '1.0'
30 |
31 | # Add any Sphinx extension module names here, as strings. They can be
32 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
33 | # ones.
34 | extensions = [
35 | "sphinx.ext.autodoc",
36 | "sphinx.ext.doctest",
37 | "sphinx.ext.todo",
38 | "sphinx.ext.coverage",
39 | "sphinx.ext.githubpages",
40 | ]
41 |
42 | autoclass_content = "both"
43 |
44 | # Add any paths that contain templates here, relative to this directory.
45 | templates_path = ["_templates"]
46 |
47 | # The suffix(es) of source filenames.
48 | # You can specify multiple suffix as a list of string:
49 | #
50 | # source_suffix = ['.rst', '.md']
51 | source_suffix = ".rst"
52 |
53 | # The master toctree document.
54 | master_doc = "index"
55 |
56 | # General information about the project.
57 | project = "plpygis"
58 | copyright = "2024, Benjamin Trigona-Harany"
59 | author = "Benjamin Trigona-Harany"
60 |
61 | # The version info for the project you're documenting, acts as replacement for
62 | # |version| and |release|, also used in various other places throughout the
63 | # built documents.
64 | #
65 | # The short X.Y version.
66 | version = plpygis.__version__
67 | # The full version, including alpha/beta/rc tags.
68 | release = version
69 |
70 | # The language for content autogenerated by Sphinx. Refer to documentation
71 | # for a list of supported languages.
72 | #
73 | # This is also used if you do content translation via gettext catalogs.
74 | # Usually you set "language" from the command line for these cases.
75 | language = "en"
76 |
77 | # List of patterns, relative to source directory, that match files and
78 | # directories to ignore when looking for source files.
79 | # This patterns also effect to html_static_path and html_extra_path
80 | exclude_patterns = []
81 |
82 | # The name of the Pygments (syntax highlighting) style to use.
83 | pygments_style = "sphinx"
84 |
85 | # If true, `todo` and `todoList` produce output, else they produce nothing.
86 | todo_include_todos = True
87 |
88 |
89 | # -- Options for HTML output ----------------------------------------------
90 |
91 | # The theme to use for HTML and HTML Help pages. See the documentation for
92 | # a list of builtin themes.
93 | #
94 | html_theme = "alabaster"
95 |
96 | # Theme options are theme-specific and customize the look and feel of a theme
97 | # further. For a list of options available for each theme, see the
98 | # documentation.
99 | #
100 | # html_theme_options = {}
101 |
102 | # Add any paths that contain custom static files (such as style sheets) here,
103 | # relative to this directory. They are copied after the builtin static files,
104 | # so a file named "default.css" will overwrite the builtin "default.css".
105 | html_static_path = []
106 |
107 |
108 | # -- Options for HTMLHelp output ------------------------------------------
109 |
110 | # Output file base name for HTML help builder.
111 | htmlhelp_basename = "plpygisdoc"
112 |
113 |
114 | # -- Options for LaTeX output ---------------------------------------------
115 |
116 | latex_elements = {
117 | # The paper size ('letterpaper' or 'a4paper').
118 | #
119 | # 'papersize': 'letterpaper',
120 | # The font size ('10pt', '11pt' or '12pt').
121 | #
122 | # 'pointsize': '10pt',
123 | # Additional stuff for the LaTeX preamble.
124 | #
125 | # 'preamble': '',
126 | # Latex figure (float) alignment
127 | #
128 | # 'figure_align': 'htbp',
129 | }
130 |
131 | # Grouping the document tree into LaTeX files. List of tuples
132 | # (source start file, target name, title,
133 | # author, documentclass [howto, manual, or own class]).
134 | latex_documents = [
135 | (
136 | master_doc,
137 | "plpygis.tex",
138 | "plpygis Documentation",
139 | "Benjamin Trigona-Harany",
140 | "manual",
141 | ),
142 | ]
143 |
144 |
145 | # -- Options for manual page output ---------------------------------------
146 |
147 | # One entry per manual page. List of tuples
148 | # (source start file, name, description, authors, manual section).
149 | man_pages = [(master_doc, "plpygis", "plpygis Documentation", [author], 1)]
150 |
151 |
152 | # -- Options for Texinfo output -------------------------------------------
153 |
154 | # Grouping the document tree into Texinfo files. List of tuples
155 | # (source start file, target name, title, author,
156 | # dir menu entry, description, category)
157 | texinfo_documents = [
158 | (
159 | master_doc,
160 | "plpygis",
161 | "plpygis Documentation",
162 | author,
163 | "plpygis",
164 | "One line description of project.",
165 | "Miscellaneous",
166 | ),
167 | ]
168 |
169 | autodoc_member_order = "bysource"
170 |
--------------------------------------------------------------------------------
/doc/source/examples.rst:
--------------------------------------------------------------------------------
1 | Examples
2 | ========
3 |
4 | Conversion
5 | ----------
6 |
7 | Some functions that analyze or manipulate geometries are possible in SQL but are easier to model in a procedural language. The following example will use `Shapely `_ to find the largest component polygon of a multipolygon.
8 |
9 | .. code-block:: postgres
10 |
11 | CREATE OR REPLACE FUNCTION largest_poly(geom geometry)
12 | RETURNS geometry
13 | AS $$
14 | from plpygis import Geometry
15 | polygons = Geometry(geom)
16 | if polygons.type == 'Polygon':
17 | return polygons
18 | elif polygons.type == 'MultiPolygon':
19 | largest = max(polygons.shapely, key=lambda polygon: polygon.area)
20 | return Geometry.from_shapely(largest)
21 | else:
22 | return None
23 | $$ LANGUAGE plpython3u;
24 |
25 | A pure PL/pgSQL function will have significantly better performance:
26 |
27 | .. code-block:: postgres
28 |
29 | CREATE OR REPLACE FUNCTION largest_poly_fast(polygons geometry)
30 | RETURNS geometry
31 | AS $$
32 | WITH geoms AS (
33 | SELECT (ST_Dump(polygons)).geom AS geom
34 | )
35 | SELECT geom
36 | FROM geoms
37 | ORDER BY ST_Area(geom) DESC LIMIT 1;
38 | $$ LANGUAGE sql;
39 |
40 | External services
41 | -----------------
42 |
43 | Another application of ``plpygis`` is accessing external services or commands directly from PostgreSQL.
44 |
45 | .. code-block:: postgres
46 |
47 | CREATE OR REPLACE FUNCTION geocode(geom geometry)
48 | RETURNS text
49 | AS $$
50 | from geopy import Nominatim
51 | from plpygis import Geometry
52 | shape = Geometry(geom).shapely
53 | centroid = shape.centroid
54 | lon = centroid.x
55 | lat = centroid.y
56 |
57 | nominatim = Nominatim()
58 | location = nominatim.reverse((lat, lon))
59 | return location.address
60 | $$ LANGUAGE plpython3u;
61 |
62 | .. code-block:: psql
63 |
64 | db=# SELECT name, geocode(geom) FROM countries LIMIT 5;
65 | name | geocode
66 | -------------------------+-----------------------------------------------------------
67 | Angola | Ringoma, Bié, Angola
68 | Anguilla | Eric Reid Road, The Valley, Anguilla
69 | Albania | Bradashesh, Elbasan, Qarku i Elbasanit, 3001, Shqipëria
70 | American Samoa | Aunu u, Sa'Ole County, Eastern District, American Samoa
71 | Andorra | Bordes de Rigoder, les Bons, Encamp, AD200, Andorra
72 | (5 rows)
73 |
74 | Rendering output
75 | ----------------
76 |
77 | The `gj2ascii `_ project allows geometries to be easily rendered with a PL/Python function.
78 |
79 | .. code-block:: postgres
80 |
81 | CREATE FUNCTION show(geom geometry)
82 | RETURNS text
83 | AS $$
84 | from gj2ascii import render
85 | from plpygis import Geometry
86 | g = Geometry(geom)
87 | return render(g)
88 | $$ LANGUAGE plpython3u
89 |
90 | .. code-block:: psql
91 |
92 | db=# SELECT show(geom) FROM countries WHERE name = 'Malta';
93 | show
94 | -------------------------------------------------------------
95 | + + + + + +
96 | + + + + + + + + + +
97 | + + + + + + + + + + + +
98 | + + + + + + + + + + +
99 | + + + + + +
100 | +
101 | + +
102 | + + +
103 | + + + + + + + +
104 | + + + + + + + + + +
105 | + + + + + + + + + + + +
106 | + + + + + + + + + + + + + +
107 | + + + + + + + + + + + + + + +
108 | + + + + + + + + + + + + + + +
109 | + + + + + + + + + + + + + + + + +
110 | + + + + + + + + + + + + + + + + +
111 | + + + + + + + + + + + + + + + ++
112 | + + + + + + + + + + + + + + ++
113 | + + + + + + + + + + + + + ++
114 | + + + + + + + + + + +
115 | + + + + +
116 | (1 row)
117 |
118 | Spatial aggregate function
119 | --------------------------
120 |
121 | Normally, the function ``show`` as defined above would print the geometries of individual rows, one each per line.
122 |
123 | .. code-block:: psql
124 |
125 | db=# SELECT show(geom) FROM countries WHERE continent = 'Africa';
126 |
127 | An aggregate version of ``show`` would take all the geometries and print them as a single map.
128 |
129 | .. code-block:: psql
130 |
131 | db=# SELECT showall(geom) FROM countries WHERE continent = 'Africa';
132 | showall
133 | -------------------------------------------------------------
134 | 1 1 1 Q +
135 | = = 1 1 1 1 Q +
136 | = = = 1 1 1 1 1 ; ; ; ; ; - - - +
137 | = = 1 1 1 1 1 1 1 ; ; ; ; ; ; - - - +
138 | = @ @ 1 1 1 1 1 1 1 ; ; ; ; ; ; - - - +
139 | G @ @ > > 1 1 1 1 1 C ; ; ; ; ; - - - - +
140 | @ @ @ @ @ > > 1 1 1 C C C O O ; ; H H H H +
141 | @ @ @ @ > > > > C C C C O O O H H H H H H +
142 | F F @ @ @ > > > C C C C O O O O H H H H H . +
143 | F F > > > $ $ C C D C D ( O O H H H H H H 2 2 . +
144 | 0 0 0 > $ 4 # D D D D ( O O & H H H I 2 2 2 2 K L+
145 | J 0 ' ' 4 P D D D D ( O & & & I I I 2 2 2 2 2 2 +
146 | : ' ' 4 D ( ( ( & & & & & I I I 2 2 2 2 L +
147 | ( ( * ) ) ) ) ) S 8 8 8 L L +
148 | M 3 * * ) ) ) ) ) S 8 8 8 L +
149 | 3 * ) ) ) ) ) E R R 8 8 +
150 | ) ) ) ) ) ) R R R R +
151 | ! ! ) ) ) ) ) R R R +
152 | ! ! ! ! ) ) U U R R +
153 | ! ! ! ! U U ) U ? ? ? +
154 | ! ! ! U U U U ? A ? ? 9 9+
155 | B B B ! ! B U V V ? 9 9 +
156 | B B B % % % V V 9 9 +
157 | B B % % % T ? ? 9 9 +
158 | B B % % T T ? 9 +
159 | B B T T T T T +
160 | T T T T T +
161 | T T T T +
162 | +
163 | +
164 | (1 row)
165 |
166 | The aggregate function is defined with the following properties:
167 |
168 | .. code-block:: postgres
169 |
170 | CREATE AGGREGATE showall(geometry) (
171 | STYPE=geometry[],
172 | INITCOND='{}',
173 | SFUNC=array_append,
174 | FINALFUNC=_final_geom_show
175 | );
176 |
177 | The ``STYPE`` of ``geometry[]`` indicates that after each individual ``geometry`` has been processed, there will be a PostgreSQL list of individual ``geometry`` objects as a result. ``INITCOND`` is used to ensure that list starts empty and can be added to incrementally by the native PostgreSQL function ``array_append``.
178 |
179 | The function ``_final_geom_show`` will take the ``STYPE`` as the single parameter:
180 |
181 | .. code-block:: postgres
182 |
183 | CREATE OR REPLACE FUNCTION _final_geom_show(geoms geometry[])
184 | RETURNS text
185 | AS $$
186 | from gj2ascii import render_multiple
187 | from plpygis import Geometry
188 | from itertools import cycle
189 | # assign an ascii character sequentially to each geometry
190 | chars = [chr(i) for i in range(33,126)]
191 | geojsons = [Geometry(g) for g in geoms]
192 | layers = zip(geojsons, chars)
193 | return render_multiple(layers, width)
194 | $$ LANGUAGE plpython3u
195 |
196 | PL/Python automatically maps lists to Python arrays, so ``plpygis`` is only responsible for converting each elment of the list (in the example, above this is done using list comprehension: ``[Geometry(g) for g in geoms]``).
197 |
--------------------------------------------------------------------------------
/doc/source/index.rst:
--------------------------------------------------------------------------------
1 | .. plpygis documentation master file, created by
2 | sphinx-quickstart on Wed Jul 5 19:28:02 2017.
3 | You can adapt this file completely to your liking, but it should at least
4 | contain the root `toctree` directive.
5 |
6 | ###################################
7 | Welcome to plpygis's documentation!
8 | ###################################
9 |
10 | ``plpygis`` is a pure Python module that can convert geometries to and from the PostGIS ``geometry`` type, WKB, EWKB, WKT, EWKT, GeoJSON and Shapely formats in addition to implementing the ``__geo_interface__`` standard. ``plpygis`` is intended for use in PL/Python functions but it can also be used in general-purpose Python code that requires support for geospatial types.
11 |
12 | .. toctree::
13 | :maxdepth: 2
14 | :caption: Contents:
15 |
16 | installation
17 | usage
18 | subclasses
19 | plpython
20 | examples
21 | plpygis
22 |
--------------------------------------------------------------------------------
/doc/source/installation.rst:
--------------------------------------------------------------------------------
1 | Installation
2 | ============
3 |
4 | Requirements
5 | ------------
6 |
7 | ``plpygis`` has no dependencies beyond an installation of Python 3 (Python 2 should also work). Additionally, ``plpygis`` can use `Shapely `_ (version 2.0 or greater) if available. Without it, conversion to and from Shapely geometries will be impossible.
8 |
9 | Python Package Index
10 | --------------------
11 |
12 | ``plpygis`` may be installed from PyPI.
13 |
14 | .. code-block:: console
15 |
16 | $ pip install plpygis
17 |
18 | It is recommended that ``plpygis`` is installed into the system installation of Python or it may not be available in PL/Python functions.
19 |
20 | Source
21 | ------
22 |
23 | The package sources are available at https://github.com/bosth/plpygis. Building and installing ``plpygis`` from source can be done with `setuptools `_:
24 |
25 | .. code-block:: console
26 |
27 | $ python -m build
28 |
29 | Tests
30 | ~~~~~
31 |
32 | Tests require `pytest `_.
33 |
34 | .. code-block:: console
35 |
36 | $ pytest
37 |
38 | Documentation
39 | ~~~~~~~~~~~~~
40 |
41 | Building the documentation from source requires `Sphinx `_. For a list of supported documentation formats, see the options in the ``doc`` subdirectory:
42 |
43 | .. code-block:: console
44 |
45 | $ cd doc
46 | $ make
47 |
--------------------------------------------------------------------------------
/doc/source/plpygis.rst:
--------------------------------------------------------------------------------
1 | plpygis package
2 | ===============
3 |
4 | .. automodule:: plpygis.geometry
5 | :members:
6 | :undoc-members:
7 | :show-inheritance:
8 | :inherited-members:
9 |
10 | .. automodule:: plpygis.exceptions
11 | :members:
12 | :undoc-members:
13 | :show-inheritance:
14 |
--------------------------------------------------------------------------------
/doc/source/plpython.rst:
--------------------------------------------------------------------------------
1 | PL/Python
2 | =========
3 |
4 | The PostgreSQL documentation has a complete reference on authoring `PL/Python `_ functions.
5 |
6 | This section will cover uses of PL/Python with ``plpygis``.
7 |
8 | Enabling PL/Python
9 | ------------------
10 |
11 | Prior to using PL/Python, it must be loaded in the current database:
12 |
13 | .. code-block:: psql
14 |
15 | # CREATE LANGUAGE plpython3u;
16 |
17 | .. warning::
18 |
19 | PL/Python is an "untrusted" language, meaning that Python code will have unrestricted access to the system at the same level as the database administrator.
20 |
21 | Python 2 and Python 3
22 | ~~~~~~~~~~~~~~~~~~~~~
23 |
24 | ``plpygis`` is compatible with both Python 2 and Python 3. For Python 3, the language is ``plpython3u`` and for Python 2 it is ``plpython2u`` (the generic ``plpythonu`` currently refers to Python 2 in PostgreSQL but this may change in the future).
25 |
26 | Function declarations
27 | ---------------------
28 |
29 | PL/Python function declarations follow the following template:
30 |
31 | .. code-block:: postgres
32 |
33 | CREATE FUNCTION funcname (argument-list)
34 | RETURNS return-type
35 | AS $$
36 | # PL/Python function body
37 | $$ LANGUAGE plpython3u;
38 |
39 | Named arguments are provided as a comma-separated list, with the argument name preceding the argument type:
40 |
41 | .. code-block:: postgres
42 |
43 | CREATE OR REPLACE FUNCTION make_point(x FLOAT, y FLOAT)
44 | RETURNS geometry
45 | AS $$
46 | # PL/Python function body
47 | $$ LANGUAGE plpython3u;
48 |
49 | .. warning::
50 |
51 | Variables passed as arguments should *never* be assigned to in a PL/Python function.
52 |
53 | Type mappings
54 | -------------
55 |
56 | The mapping between types in PL/Python and is PostgreSQL is covered in the `Data Values `_ section of the documentation; it is the role of ``plpygis`` to assist in mapping between PL/Python and PostGIS types.
57 |
58 | PostGIS types
59 | ~~~~~~~~~~~~~
60 |
61 | When authoring a Postgres function that takes a PostGIS geometry as an input parameter or returns a geometry as output, :class:`Geometry ` objects will provide the automatic conversion between types.
62 |
63 | .. code-block:: postgres
64 |
65 | CREATE OR REPLACE FUNCTION make_point(x FLOAT, y FLOAT)
66 | RETURNS geometry
67 | AS $$
68 | from plpygis import Point
69 | p = Point((x, y))
70 | return p
71 | $$ LANGUAGE plpython3u;
72 |
73 | Input parameter
74 | ^^^^^^^^^^^^^^^
75 |
76 | A PostGIS geometry passed as the argument to :meth:`Geometry() ` will initialize the instance.
77 |
78 | .. code-block:: postgres
79 |
80 | CREATE OR REPLACE FUNCTION find_hemisphere(geom geometry)
81 | RETURNS TEXT
82 | AS $$
83 | from plpygis import Geometry
84 | point = Geometry(geom)
85 | if point.type != "Point":
86 | return None
87 | gj = point.geojson
88 | lon = gj["coordinates"][0]
89 | lat = gj["coordinates"][1]
90 |
91 | if lon < 0:
92 | return "West"
93 | elif lon > 0:
94 | return "East"
95 | else:
96 | return "Meridian"
97 | $$ LANGUAGE plpython3u;
98 |
99 | .. code-block:: psql
100 |
101 | db=# SELECT name, find_hemisphere(ST_Centroid(geom)) FROM countries LIMIT 10;
102 | name | find_hemisphere
103 | -------------------------+-----------------
104 | Aruba | West
105 | Afghanistan | East
106 | Angola | East
107 | Anguilla | West
108 | Albania | East
109 | American Samoa | West
110 | Andorra | East
111 | Argentina | West
112 | Armenia | East
113 | Bulgaria | East
114 | (10 rows)
115 |
116 | Return value
117 | ^^^^^^^^^^^^
118 |
119 | A :class:`Geometry ` can be returned directly from a PL/Python function.
120 |
121 | .. code-block:: postgres
122 |
123 | CREATE OR REPLACE FUNCTION make_point(x FLOAT, y FLOAT)
124 | RETURNS geometry
125 | AS $$
126 | from plpygis import Point
127 | return Point((x, y))
128 | $$ LANGUAGE plpython3u;
129 |
130 | .. code-block:: psql
131 |
132 | db=# SELECT make_point(-52, 0);
133 | make_point
134 | --------------------------------------------
135 | 01010000000000000000004AC00000000000000000
136 | (1 row)
137 |
138 | This custom ``make_point(x, y)`` functions identically to PostGIS's native `ST_MakePoint(x, y) `_.
139 |
140 | .. code-block:: psql
141 |
142 | db=# SELECT ST_MakePoint(-52, 0);
143 | st_makepoint
144 | --------------------------------------------
145 | 01010000000000000000004AC00000000000000000
146 | (1 row)
147 |
148 | ``geometry`` and ``geography``
149 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
150 |
151 | Both PostGIS ``geometry`` and ``geography`` types may be used as arguments or return types. ``plpygis`` does not support ``box2d``, ``box3d``, ``raster`` or any topology types.
152 |
153 | ``geometry`` and ``geography`` arguments will be treated identically by ``plpygis``, as they share an common WKB format.
154 |
155 | However, a PL/Python function that has a return value of ``geography`` must not have an SRID of any value except 4326. It will also be treated differently by certain PostGIS functions.
156 |
157 | Imagine two PL/Python functions that both create a polygon with lower-left coordinates at ``(0, 0)`` and upper-right coordinates at ``(50, 50)``. If ``box_geom`` has a return type of ``geometry`` and ``box_geog`` has a return type of ``geography``, area calculations will be evaluated as follows:
158 |
159 | .. code-block:: psql
160 |
161 | db=# SELECT ST_Area(box_geom());
162 | st_area
163 | ------------------
164 | 2500
165 | (1 row)
166 |
167 | db=# SELECT ST_Area(box_geog());
168 | st_area
169 | ------------------
170 | 27805712533424.3
171 | (1 row)
172 |
173 | Arrays and sets
174 | ---------------
175 |
176 | In addition to returning single values, ``plpygis`` functions may return a list of geometries that can be either interpreted as a PostgreSQL `array `_ or `set `_.
177 |
178 | .. code-block:: psql
179 |
180 | db=# CREATE OR REPLACE FUNCTION make_points(x FLOAT, y FLOAT)
181 | RETURNS SETOF geometry
182 | AS $$
183 | from plpygis import Geometry
184 | from shapely.geometry import Point
185 | p1 = Point(x, y)
186 | p2 = Point(y, x)
187 | return [Geometry.shape(p1), Geometry.shape(p2)]
188 | $$ LANGUAGE plpython3u;
189 |
190 | db=# SELECT ST_AsText(make_points(10,20));
191 | st_astext
192 | --------------
193 | POINT(10 20)
194 | POINT(20 10)
195 |
196 | Python's ``yield`` keyword may also be used to return elements in a set rather than returning them as elements in a list.
197 |
198 | Shared data
199 | -----------
200 |
201 | Each PL/Python function has access to a shared dictionary ``SD`` that can be used to store data between function calls.
202 |
203 | As with other data, ``plpygis.Geometry`` instances may be stored in the ``SD`` dictionary for future reference in later function calls.
204 |
205 | ``plpy``
206 | --------
207 |
208 | The ``plpy`` module provides access to helper functions, notably around logging to PostgreSQL's standard log files.
209 |
210 | See `Utility Functions `_ in the PostgreSQL documentation.
211 |
212 | Aggregate functions
213 | -------------------
214 |
215 | PostGIS includes several spatial aggregate functions that accept a set of geometries as input parameters. An aggregate function definition requires different syntax from a normal PL/Python function:
216 |
217 | .. code-block:: postgres
218 |
219 | CREATE AGGREGATE agg_fn (
220 | SFUNC = _state_function,
221 | STYPE = geometry,
222 | BASETYPE = geometry, -- optional
223 | FINALFUNC = wrapup_func, -- optional
224 | INITCOND = 'POINT(0 0)' -- optional
225 | );
226 |
227 | An aggregate will accept individual inputs of the type defined by ``BASETYPE`` and incrementally producing a single type defined by ``STYPE``. If many geometries will be collapsed down to a single geometry, then both ``BASETYPE`` and ``STYPE`` will be ``geometry``. If many geometries will produce more than one geometry, then the types will be ``geometry`` and ``geometry[]`` respectively.
228 |
229 | An example aggregate function would be ``point_cluster``, which takes `n` input geometries and outputs `m` geometries, where `m < n`.
230 |
231 | .. code-block:: postgres
232 |
233 | CREATE AGGREGATE point_cluster (
234 | SFUNC = _point_cluster,
235 | BASETYPE = geometry,
236 | STYPE = geometry[],
237 | INITCOND = '{}'
238 | );
239 |
240 | The function indicated by ``SFUNC`` must accept the ``STYPE`` as the first parameter and ``BASETYPE`` as the second parameter, returning another instance of ``STYPE``. If ``INITCOND`` is provided, this will be the value of the first argument passed to the first call of ``SFUNC``. If it is omitted, the value will be initially set to ``None``.
241 |
242 | .. code-block:: postgres
243 |
244 | CREATE FUNCTION _point_cluster(geoms geometry[], newgeom geometry)
245 | RETURNS geometry[]
246 | AS $$
247 | # incremental clustering algorithm here
248 | $$ LANGUAGE plpython3u;
249 |
250 | Alternatively, the ``SFUNC`` can simply collect all the individual geometries into a list and then rely on a single ``FINALFUNC`` to create a new list of geometries that represents the clustered points.
251 |
252 | .. code-block:: postgres
253 |
254 | CREATE AGGREGATE point_cluster (
255 | SFUNC = array_append,
256 | BASETYPE = geometry,
257 | STYPE = geometry[],
258 | INITCOND = '{}',
259 | FINALFUNC = _point_cluster
260 | );
261 |
262 | The parameter of the ``FINALFUNC`` will be a single ``geometry[]``, representing the collection of individual points.
263 |
264 | .. code-block:: postgres
265 |
266 | CREATE FUNCTION _point_cluster(geoms geometry[])
267 | RETURNS geometry[]
268 | AS $$
269 | # clustering algorithm here
270 | $$ LANGUAGE plpython3u;
271 |
--------------------------------------------------------------------------------
/doc/source/subclasses.rst:
--------------------------------------------------------------------------------
1 | ``Geometry`` subclasses
2 | =======================
3 |
4 | Creation
5 | --------
6 |
7 | New instances of the three base shapes (points, lines and polygons) may be created by passing in coordinates:
8 |
9 | .. code-block:: python
10 |
11 | >>> point = Point((0, 0))
12 | >>> line = LineString([(0, 0), (0, 1)])
13 | >>> poly = Polygon([[(0, 0), (0, 10), (10, 10), (10, 0), (0, 0)], [(4, 4), (4, 6), (6, 6), (6, 4), (4, 4)]])
14 |
15 | Conversion
16 | ----------
17 |
18 | Converting to a new ``plpygis`` geometry from a WKB, WKT, GeoJSON or Shapely instance, will produce a new subclass of the base :class:`Geometry ` class:
19 |
20 | * :class:`Point `
21 | * :class:`LineString `
22 | * :class:`Polygon `
23 | * :class:`MultiPoint `
24 | * :class:`MultiLineString `
25 | * :class:`MultiPolygon `
26 | * :class:`GeometryCollection `
27 |
28 | Editing
29 | -------
30 |
31 | Coordinates may be accessed and modified after creation.
32 |
33 | .. code-block:: python
34 |
35 | >>> point = Point((0, 0))
36 | >>> print(point.x)
37 | 0
38 | >>> point.x = 10
39 | >>> print(point.x)
40 | 10
41 |
42 | Composition
43 | -----------
44 |
45 | Individual :class:`LineString ` instances are composed of a list of :class:`Point ` instances that each represent a vertex in the line. Similarly, :class:`Polygon ` instances are composed of a list of :class:`LineString ` instances that each represent linear rings.
46 |
47 | The lists of vertices or linear rings can be modified, for example by adding a new :class:`Point ` to the end of a :class:`LineString `.
48 |
49 | .. note::
50 |
51 | The first linear ring in a polygon should represent the exterior ring, while subsequent linear rings are internal boundaries. ``plpygis`` will not validate geometries when they are created.
52 |
53 | The four collection types, :class:`MultiPoint `, :class:`LineString `, :class:`MultiPolygon ` and :class:`GeometryCollection `, are each composed of a list of other geometries of the appropriate type. At creation time, the collection types are created by passing in a list of existing instances:
54 |
55 | .. code-block:: python
56 |
57 | >>> p1 = Point((0, 0))
58 | >>> p2 = Point((1, 1))
59 | >>> mp = MultiPoint([p1, p2])
60 |
61 | `plpygis` will not create copies of any `Geometry >> p = Point((0, 0))
66 | >>> mp = MultiPoint([p])
67 | >>> mp.points[0].x
68 | 0
69 | >>> p.x = 100
70 | >>> mp.points[0].x
71 | 100
72 |
73 | SRIDs
74 | -----
75 |
76 | An SRID may be added at creation time with an optional ``SRID`` parameter:
77 |
78 | .. code-block:: python
79 |
80 | >>> point = Point((0, 0), srid=4326)
81 |
82 | When creating a multigeometry with an SRID, each geometry must have the same SRID or no SRID.
83 |
84 | .. code-block:: python
85 |
86 | >>> p1 = Point((0, 0), srid=4326)
87 | >>> p2 = Point((1, 1), srid=4326)
88 | >>> mp = MultiPoint([p1, p2], srid=4326)
89 |
90 | >>> p3 = Point((0, 0))
91 | >>> p4 = Point((1, 1))
92 | >>> mp = MultiPoint([p3, p4], srid=4326)
93 |
94 | ``plpygis`` will not allow the creation of a multigeometry with no SRID if any of the geometries have one.
95 |
96 | .. warning::
97 |
98 | Changing the SRID of an instance that is part of another geometry (such as a :class:`Point ` that is a vertex in a :class:`LineString ` or a vertex in the linear ring of a :class:`Polygon `) will *not* be detected. When converted to a WKB or Shapely instance, only the SRID of the "parent" geometry will be used.
99 |
100 | Dimensionality
101 | --------------
102 |
103 | The ``dimz`` and ``dimm`` boolean parameters will indicate whether the geometry will have Z and M dimensions. ``plpygis`` will attempt to match provided coordinates with the requested dimensions or will set them to an initial value of ``0`` if they have not been provided:
104 |
105 | .. code-block:: python
106 |
107 | >>> p1 = Point((0, 0, 1), dimz=True, dimm=True)
108 | >>> print("p1", p1.x, p1.y, p1.z, p1.m)
109 | p1 0 0 1 0
110 | >>> p2 = Point((0, 0, 1), dimm=True)
111 | >>> print("p2", p2.x, p2.y, p2.z, p2.m)
112 | p2 0 0 None 1
113 | >>> p3 = Point((0, 0, 1, 2))
114 | >>> print("p3", p3.x, p3.y, p3.z, p3.m)
115 | p3 0 0 1 2
116 |
117 | The dimensionality of an existing instance may be altered after creation, by setting ``dimz`` or ``dimm``. Adding a dimension will add a Z or M coordinate with an initial value of ``0`` to the geometry and all geometries encompassed within it (*e.g.*, each vertex in a :class:`LineString ` or each :class:`Point ` in a :class:`MultiPoint ` will gain the new dimension).
118 |
119 | A new dimension may also be added to a single :class:`Point ` by assigning to the :meth:`z ` or :meth:`m ` properties.
120 |
121 | Adding a new dimension to a :class:`Point ` that is a vertex in a :class:`LineString ` or a vertex in the linear ring of a :class:`Polygon ` will *not* change the dimensionality of the :class:`LineString ` or the :class:`Polygon `. The dimensionality of "parent" instance *must* also be changed for the new coordinates to be reflected when converting to other representations.
122 |
123 | .. code-block:: python
124 |
125 | >>> p1 = Point((0, 0))
126 | >>> p2 = Point((1, 1))
127 | >>> mp = MultiPoint([p1, p2])
128 | >>> print(mp.dimz)
129 | False
130 | >>> p1.z = 2
131 | >>> print(p1.miz)
132 | True
133 | >>> print(mp.dimz)
134 | False
135 | >>> mp.dimz = True
136 | >>> print(mp.dimz)
137 | True
138 | >>> print("p1.z", p1.z, "p2.z", p2.z)
139 | p1.z 2 p2.z 0
140 |
141 | Multigeometries
142 | ---------------
143 |
144 | ``plpygis`` overloads list operations for multigeometries: ``len()`` and ``[]``.
145 |
146 | >>> p0 = Point((0, 0))
147 | >>> p1 = Point((1, 1))
148 | >>> p2 = Point((2, 2))
149 | >>> mp = MultiPoint([p0, p1])
150 | >>> len(mp)
151 | 2
152 | >>> mp[0].geojson
153 | {'type': 'Point', 'coordinates': [0, 0]}
154 |
155 | :meth:`geometries ` returns an immutable tuple of the geometries in the multigeometry. The individual geometries may be modified, but none of the individual geometries may be replaced or removed from the multigeomery, nor can new geometries be added. The correct way to add a new geometry or replace an existing geometry is by using ``+=`` or ``[]`` respectively. There is strict checking of types in both cases. An element may be removed using :meth:`pop `.
156 |
157 | >>> p0 = Point((0, 0))
158 | >>> p1 = Point((1, 1))
159 | >>> p2 = Point((2, 2))
160 | >>> mp = MultiPoint([p0])
161 | >>> mp[0].x
162 | 0
163 | >>> mp[0] = p1
164 | >>> mp[0].x
165 | 1
166 | >>> mp += p2
167 | >>> mp[1].x
168 | 2
169 |
170 |
171 | Performance considerations
172 | --------------------------
173 | Lazy evaluation
174 | ^^^^^^^^^^^^^^^
175 |
176 | ``plpygis`` uses native WKB parsing to extract header information that indicates the geometry type, SRID and the presence of a Z or M dimension. Full parsing of the entire geometry only occurs when needed. It is therefore possible to test the type and dimensionality of a :class:`Geometry ` with only the first few bytes of data having been read. Perform these checks before performing any action that will require reading the remainder of the WKB.
177 |
178 | Caching
179 | ^^^^^^^
180 |
181 | ``plpygis`` will cache the initial WKB it was created from. As soon as any coordinates or composite geometries are referenced, the cached WKB is lost and a subsequent request that requires the WKB will necessitate it being generated from scratch. For sets of large geometries, this can have a noticeable affect on performance. Therefore, if doing a conversion to a Shapely geometry - an action which relies on the availability of the WKB - it is recommended that this conversion be done before any other operations on the ``plpygis`` geometry.
182 |
183 | .. note::
184 |
185 | Getting :meth:`type `, :meth:`srid `, :meth:`dimz ` and :meth:`dimm ` are considered "safe" operations. However writing a new SRID or changing the dimensionality will also result in the cached WKB being lost. A geometry's type may never be changed.
186 |
187 | As a summary, getting the following properties will not affect performance:
188 |
189 | * :meth:`type `
190 | * :meth:`srid `
191 | * :meth:`dimz `
192 | * :meth:`dimm `
193 |
194 | Setting the following properties will cause any cached WKB to be cleared:
195 |
196 | * :meth:`srid `
197 | * :meth:`dimz `
198 | * :meth:`dimm `
199 |
200 | Getting the following property relies on the presence of the WKB (cached or generated):
201 |
202 | * :meth:`shapely `
203 |
204 | If the :class:`Geometry ` was created from a WKB, the following actions will trigger a full parse and will clear the cached copy of the WKB:
205 |
206 | * getting :meth:`geojson ` and :meth:`__geo_interface__ `
207 | * getting :meth:`shapely `
208 | * getting any :class:`Point ` coordinate
209 | * getting :meth:`bounds `
210 | * getting :meth:`vertices `, :meth:`rings `
211 | * getting any component geometry from :class:`MultiPoint `, :class:`MultiLineString `, :class:`MultiPolygon ` or :class:`GeometryCollection `
212 |
--------------------------------------------------------------------------------
/doc/source/usage.rst:
--------------------------------------------------------------------------------
1 | Basic usage
2 | ===========
3 |
4 | ``plpygis`` is a Python converter to and from the PostGIS `geometry `_ type, WKB, EWKB, WKT, EWKT, GeoJSON, Shapely geometries and any object that supports ``__geo_interface__``. ``plpygis`` is intended for use in PL/Python, allowing procedural Python code to complement PostGIS types and functions.
5 |
6 | :class:`Geometry `
7 | ---------------------------------------------
8 |
9 | New :class:`Geometry ` instances can be created using a `Well-Known Binary (WKB) `_ representation of the geometry in hexadecimal form.
10 |
11 | .. code-block:: python
12 |
13 | >>> from plpygis import Geometry
14 | >>> geom = Geometry("01010000000000000000004AC00000000000000000")
15 |
16 | Creation
17 | ~~~~~~~~
18 |
19 | :class:`Geometry ` instances may also be created from different representations of a geometry.
20 |
21 | :class:`Geometry ` instances can be converted using the following methods:
22 |
23 | * :meth:`from_wkt() `
24 | * :meth:`from_geojson() `
25 | * :meth:`from_shapely() `
26 |
27 | .. code-block:: python
28 |
29 | >>> from plpygis import Geometry
30 | >>> point = Geometry.from_geojson({'type': 'Point', 'coordinates': [-52.0, 0.0]})
31 | >>> polygon = Geometry.from_wkt("POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))")
32 |
33 | The :meth:`shape() ` method can convert from any instance that provides ``__geo_interface__`` (see `A Python Protocol for Geospatial Data `_).
34 |
35 | An optional ``srid`` keyword argument may be used with any of the above to set the geometry's SRID. If the representation already provides an SRID (such as with some Shapely geometries) or implies a particular SRID (GeoJSON), it will be overridden by the user-specified value.
36 |
37 | Geometry types
38 | ~~~~~~~~~~~~~~
39 |
40 | Every :class:`Geometry ` has a type that can be accessed using the instance's :meth:`type ` property. The following geometry types are supported:
41 |
42 | * Point
43 | * LineString
44 | * Polygon
45 | * MultiPoint
46 | * MultiLineString
47 | * MultiPolygon
48 | * GeometryCollection
49 |
50 | The following EWKB types are not supported:
51 |
52 | * Unknown
53 | * CircularString
54 | * CompoundCurve
55 | * CurvePolygon
56 | * MultiCurve
57 | * MultiSurface
58 | * PolyhedralSurface
59 | * Triangle
60 | * Tin
61 |
62 | Conversion
63 | ~~~~~~~~~~
64 |
65 | :class:`Geometry ` instances can also be converted to other representations using the following properties:
66 |
67 | * :meth:`geojson `
68 | * :meth:`shapely `
69 | * :meth:`wkb `
70 | * :meth:`ewkb `
71 | * :meth:`wkt `
72 | * :meth:`ewkt `
73 |
74 | .. code-block:: python
75 |
76 | >>> from plpygis import Geometry
77 | >>> geom = Geometry("01010000000000000000004AC00000000000000000")
78 | >>> print(geom.wkt)
79 | POINT (-52 0)
80 |
81 | :class:`Geometry ` also implements :attr:`__geo_interface__ `.
82 |
83 | Conversion to GeoJSON or Shapely will result in the M dimension being lost as these representation only support X, Y and Z coordinates (see `RFC 7946 `_).
84 |
85 | The precision of coordinates in WKT/EWKT can be controlled by setting :attr:`plpygis.wkt.PRECISION `; by default, this value is 6.
86 |
87 | Exceptions
88 | ----------
89 |
90 | All ``plpygis`` exceptions inherit from the :class:`PlpygisError ` class. The specific exceptions that may be raised are:
91 |
92 | * :py:exc:`DependencyError `: missing dependency required for an optional feature, such as :meth:`shapely `
93 | * :py:exc:`CollectionError `: error when attempting to create a multigeometry or geometry collection
94 | * :py:exc:`CoordinateError `: error in the coordinates used to create a :class:`Geometry `
95 | * :py:exc:`DimensionalityError `: error pertaining to the Z or M coordinates of a :class:`Geometry `
96 | * :py:exc:`GeojsonError `: error reading a GeoJSON
97 | * :py:exc:`SridError `: error pertaining to a :class:`Geometry `'s SRIDs
98 | * :py:exc:`WkbError `: error reading or writing a WKB
99 | * :py:exc:`WktError `: error reading or writing a WKT
100 |
--------------------------------------------------------------------------------
/plpygis/__init__.py:
--------------------------------------------------------------------------------
1 | from .geometry import Geometry as Geometry
2 | from .geometry import Point as Point
3 | from .geometry import LineString as LineString
4 | from .geometry import Polygon as Polygon
5 | from .geometry import MultiPoint as MultiPoint
6 | from .geometry import MultiLineString as MultiLineString
7 | from .geometry import MultiPolygon as MultiPolygon
8 | from .geometry import GeometryCollection as GeometryCollection
9 | from ._version import __version__ as __version__
10 |
--------------------------------------------------------------------------------
/plpygis/_version.py:
--------------------------------------------------------------------------------
1 | __version__ = "0.5.5"
2 |
--------------------------------------------------------------------------------
/plpygis/exceptions.py:
--------------------------------------------------------------------------------
1 | class PlpygisError(Exception):
2 | """
3 | Basic exception for ``plpygis``.
4 | """
5 |
6 | def __init__(self, msg):
7 | super().__init__(msg)
8 |
9 |
10 | class CoordinateError(PlpygisError):
11 | """
12 | Exception for problems in the coordinates of geometries.
13 | """
14 |
15 | def __init__(self, geom, msg=None):
16 | if msg is None:
17 | msg = f"Geometry has invalid coordinates: {geom}"
18 | super().__init__(msg)
19 |
20 |
21 | class CollectionError(PlpygisError):
22 | """
23 | Exception for problems with geometries in collection types.
24 | """
25 |
26 | def __init__(self, msg=None):
27 | if msg is None:
28 | msg = "Error in the geometries in a collection."
29 | super().__init__(msg)
30 |
31 |
32 | class DependencyError(PlpygisError, ImportError):
33 | """
34 | Exception for a missing dependency.
35 | """
36 |
37 | def __init__(self, dep):
38 | msg = f"Dependency '{dep}' is not available."
39 | super().__init__(msg)
40 |
41 |
42 | class WkbError(PlpygisError):
43 | """
44 | Exception for problems in parsing WKBs.
45 | """
46 |
47 | def __init__(self, msg=None):
48 | if msg is None:
49 | msg = "Unreadable WKB."
50 | super().__init__(msg)
51 |
52 |
53 | class WktError(PlpygisError):
54 | """
55 | Exception for problems in parsing WKTs.
56 | """
57 |
58 | def __init__(self, reader, msg=None, expected=None):
59 | pos = reader.pos
60 | if not msg:
61 | if expected is None:
62 | msg = f"Unreadable WKT at position {pos+1}."
63 | else:
64 | msg = f"Expected {expected} at position {pos+1}."
65 | super().__init__(msg)
66 |
67 |
68 | class DimensionalityError(PlpygisError):
69 | """
70 | Exception for problems in dimensionality of geometries.
71 | """
72 |
73 | def __init__(self, msg=None):
74 | if msg is None:
75 | msg = "Geometry has invalid dimensionality."
76 | super().__init__(msg)
77 |
78 |
79 | class SridError(PlpygisError):
80 | """
81 | Exception for problems in dimensionality of geometries.
82 | """
83 |
84 | def __init__(self, msg=None):
85 | if msg is None:
86 | msg = "Geometry has invalid SRID."
87 | super().__init__(msg)
88 |
89 |
90 | class GeojsonError(PlpygisError):
91 | """
92 | Exception for problems in GeoJSONs.
93 | """
94 |
95 | def __init__(self, msg=None):
96 | if msg is None:
97 | msg = "Invalid GeoJSON."
98 | super().__init__(msg)
99 |
--------------------------------------------------------------------------------
/plpygis/geometry.py:
--------------------------------------------------------------------------------
1 | import numbers
2 | from copy import copy, deepcopy
3 | from .exceptions import (
4 | DependencyError,
5 | WkbError,
6 | SridError,
7 | DimensionalityError,
8 | CoordinateError,
9 | GeojsonError,
10 | CollectionError,
11 | WktError
12 | )
13 | from .hex import HexReader, HexWriter, HexBytes
14 | from .wkt import WktReader, WktWriter
15 |
16 | class Geometry:
17 | r"""A representation of a PostGIS geometry.
18 |
19 | PostGIS geometries are either an OpenGIS Consortium Simple Features for SQL
20 | specification type or a PostGIS extended type. The object's canonical form
21 | is stored in WKB or EWKB format along with an SRID and flags indicating
22 | whether the coordinates are 3DZ, 3DM or 4D.
23 |
24 | ``Geometry`` objects can be created in a number of ways. In all cases, a
25 | subclass for the particular geometry type will be instantiated.
26 |
27 | From an (E)WKB::
28 |
29 | >>> Geometry(b'\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00')
30 |
31 |
32 | From the hexadecimal string representation of an (E)WKB::
33 |
34 | >>> Geometry("0101000080000000000000000000000000000000000000000000000000")
35 |
36 |
37 | The response above indicates an instance of the ``Point`` class has been
38 | created and that it represents a PostGIS ``geometry(PointZ)`` type.
39 |
40 | From a GeoJSON::
41 |
42 | >>> Geometry.from_geojson({'type': 'Point', 'coordinates': (0.0, 0.0)})
43 |
44 |
45 | From a Shapely object::
46 |
47 | >>> from shapely import Point
48 | >>> point = Point(0, 0)
49 | >>> Geometry.from_shapely(point, 3857)
50 |
51 |
52 | From any object supporting ``__geo_interface__``::
53 |
54 | >>> from shapefile import Reader
55 | >>> feature = Reader("test/multipoint.shp").shape(0)
56 | >>> Geometry.shape(feature)
57 |
58 |
59 | A ``Geometry`` can be read as long as it is one of the following
60 | types: ``Point``, ``LineString``, ``Polygon``, ``MultiPoint``, ``MultiLineString``,
61 | ``MultiPolygon`` or ``GeometryCollection``. The M dimension will be preserved.
62 | """
63 |
64 | _WKBTYPE = 0x1FFFFFFF
65 | _WKBZFLAG = 0x80000000
66 | _WKBMFLAG = 0x40000000
67 | _WKBSRIDFLAG = 0x20000000
68 | __slots__ = ["_wkb", "_reader", "_srid", "_dimz", "_dimm"]
69 |
70 | def __new__(cls, wkb, srid=None, dimz=False, dimm=False):
71 | if cls == Geometry:
72 | if not wkb:
73 | raise WkbError("No EWKB provided")
74 | wkb = HexBytes(wkb)
75 | newcls, dimz, dimm, srid, reader = Geometry._from_wkb(wkb)
76 | geom = super().__new__(newcls)
77 | geom._wkb = wkb
78 | geom._reader = reader
79 | geom._srid = srid
80 | geom._dimz = dimz
81 | geom._dimm = dimm
82 | else:
83 | geom = super().__new__(cls)
84 | geom._wkb = None
85 | geom._reader = None
86 | return geom
87 |
88 | def __copy__(self):
89 | cls = self.__class__
90 | return cls(self.coordinates, self.srid, self.dimz, self.dimm)
91 |
92 | def __deepcopy__(self, _):
93 | return self.__copy__()
94 |
95 | def __add__(self, other):
96 | if self.srid != other.srid:
97 | raise CollectionError(
98 | "Can not add mixed SRID types")
99 |
100 | if issubclass(type(other), _MultiGeometry):
101 | return other + self
102 |
103 | if type(self) is type(other):
104 | if type(self) is Point:
105 | cls = MultiPoint
106 | elif type(self) is LineString:
107 | cls = MultiLineString
108 | elif type(self) is Polygon:
109 | cls = MultiPolygon
110 | else:
111 | cls = GeometryCollection
112 |
113 | return cls([self, other], srid=self.srid)
114 |
115 | @staticmethod
116 | def from_geojson(geojson, srid=4326):
117 | """
118 | Create a Geometry from a GeoJSON. The SRID can be overridden from the
119 | expected 4326.
120 | """
121 | type_ = geojson["type"].lower()
122 | if type_ == "geometrycollection":
123 | geometries = []
124 | for geometry in geojson["geometries"]:
125 | geometries.append(Geometry.from_geojson(geometry, srid=None))
126 | return GeometryCollection(geometries, srid)
127 | if type_ == "point":
128 | return Point(geojson["coordinates"], srid=srid)
129 | if type_ == "linestring":
130 | return LineString(geojson["coordinates"], srid=srid)
131 | if type_ == "polygon":
132 | return Polygon(geojson["coordinates"], srid=srid)
133 | if type_ == "multipoint":
134 | geometries = _MultiGeometry._multi_from_geojson(geojson, Point)
135 | return MultiPoint(geometries, srid=srid)
136 | if type_ == "multilinestring":
137 | geometries = _MultiGeometry._multi_from_geojson(geojson, LineString)
138 | return MultiLineString(geometries, srid=srid)
139 | if type_ == "multipolygon":
140 | geometries = _MultiGeometry._multi_from_geojson(geojson, Polygon)
141 | return MultiPolygon(geometries, srid=srid)
142 | raise GeojsonError(f"Invalid GeoJSON type: {type_}")
143 |
144 | def _read_wkt_geom(reader):
145 | type_ = reader.get_type()
146 | dimz, dimm = reader.get_dims()
147 |
148 | if type_ == "POINT":
149 | cls = Point
150 | elif type_ == "LINESTRING":
151 | cls = LineString
152 | elif type_ == "POLYGON":
153 | cls = Polygon
154 | elif type_ == "MULTIPOINT":
155 | cls = MultiPoint
156 | elif type_ == "MULTILINESTRING":
157 | cls = MultiLineString
158 | elif type_ == "MULTIPOLYGON":
159 | cls = MultiPolygon
160 | elif type_ == "GEOMETRYCOLLECTION":
161 | cls = GeometryCollection
162 |
163 | return cls._read_wkt(reader)
164 |
165 | @staticmethod
166 | def from_wkt(wkt, srid=None):
167 | """
168 | Create a Geometry from a WKT or EWKT.
169 | """
170 | reader = WktReader(wkt)
171 | reader.get_srid()
172 | geom = Geometry._read_wkt_geom(reader)
173 | reader.close()
174 | if srid:
175 | geom.srid = srid
176 | return geom
177 |
178 | @staticmethod
179 | def from_shapely(sgeom, srid=None):
180 | """
181 | Create a Geometry from a Shapely geometry and the specified SRID.
182 |
183 | The Shapely geometry will not be modified.
184 | """
185 | try:
186 | import shapely
187 | if srid:
188 | sgeom = shapely.set_srid(sgeom, srid)
189 | wkb_hex = shapely.to_wkb(sgeom, include_srid=True, hex=True)
190 | return Geometry(wkb_hex)
191 | except ImportError:
192 | raise DependencyError("Shapely")
193 |
194 | @staticmethod
195 | def shape(shape, srid=None):
196 | """
197 | Create a Geometry using ``__geo_interface__`` and the specified SRID.
198 | """
199 | return Geometry.from_geojson(shape.__geo_interface__, srid)
200 |
201 | @property
202 | def type(self):
203 | """
204 | The geometry type.
205 | """
206 | return self.__class__.__name__
207 |
208 | @property
209 | def srid(self):
210 | """
211 | The geometry SRID.
212 | """
213 | return self._srid
214 |
215 | @srid.setter
216 | def srid(self, value):
217 | self._check_cache()
218 | self._srid = value
219 |
220 | @property
221 | def coordinates(self):
222 | """
223 | Get the geometry's coordinates.
224 | """
225 | return self._coordinates()
226 |
227 | @property
228 | def geojson(self):
229 | """
230 | Get the geometry as a GeoJSON dict. There is no check that the
231 | GeoJSON is using an SRID of 4326.
232 | """
233 | return self._to_geojson()
234 |
235 | @property
236 | def wkb(self):
237 | """
238 | Get the geometry as an WKB.
239 | """
240 | return self._to_wkb(use_srid=False, dimz=self.dimz, dimm=self.dimm)
241 |
242 | @property
243 | def wkt(self):
244 | """
245 | Get the geometry as an WKT.
246 | """
247 | return self._to_wkt(use_srid=False)
248 |
249 | @property
250 | def ewkb(self):
251 | """
252 | Get the geometry as an EWKB.
253 | """
254 | return self._to_wkb(use_srid=True, dimz=self.dimz, dimm=self.dimm)
255 |
256 | @property
257 | def ewkt(self):
258 | """
259 | Get the geometry as an EWKT.
260 | """
261 | return self._to_wkt(use_srid=True)
262 |
263 | @property
264 | def shapely(self):
265 | """
266 | Get the geometry as a Shapely geometry. If the geometry has an SRID,
267 | the Shapely object will be created with it set.
268 | """
269 | return self._to_shapely()
270 |
271 | @property
272 | def bounds(self):
273 | """
274 | Get the minimum and maximum extents of the geometry: (minx, miny, maxx,
275 | maxy).
276 | """
277 | return self._bounds()
278 |
279 | @property
280 | def postgis_type(self):
281 | """
282 | Get the type of the geometry in PostGIS format, including additional
283 | dimensions and SRID if they exist.
284 | """
285 | dimz = "Z" if self.dimz else ""
286 | dimm = "M" if self.dimm else ""
287 | if self.srid:
288 | return f"geometry({self.type}{dimz}{dimm},{self.srid})"
289 | return f"geometry({self.type}{dimz}{dimm})"
290 |
291 | @staticmethod
292 | def _from_wkb(wkb):
293 | try:
294 | if wkb.startswith(b"\x00"):
295 | reader = HexReader(wkb, ">") # big-endian reader
296 | elif wkb.startswith(b"\x01"):
297 | reader = HexReader(wkb, "<") # little-endian reader
298 | else:
299 | raise WkbError("First byte in WKB must be 0 or 1.")
300 | except Exception as e:
301 | raise WkbError("Error reading WKB") from e
302 | return Geometry._get_wkb_type(reader) + (reader,)
303 |
304 | def _check_cache(self):
305 | if self._reader is not None:
306 | self._load_geometry()
307 | self._wkb = None
308 | self._reader = None
309 |
310 | def _to_wkb(self, use_srid, dimz, dimm):
311 | if self._wkb is not None:
312 | # use cached WKB if it is an EWKB and user requested EWKB
313 | if use_srid and self.srid is not None:
314 | return self._wkb
315 | # use cached WKB if it is a WKB and user requested WKB
316 | if not use_srid and self.srid is None:
317 | return self._wkb
318 | writer = HexWriter("<")
319 | self._write_wkb_header(writer, use_srid, dimz, dimm)
320 | self._write_wkb(writer, dimz, dimm)
321 | return writer.data
322 |
323 | def _to_wkt(self, use_srid):
324 | writer = WktWriter(self, use_srid)
325 | writer.add(
326 | self._as_wkt(writer)
327 | )
328 | return writer.wkt
329 |
330 | def _to_shapely(self):
331 | try:
332 | import shapely
333 | import shapely.wkb
334 |
335 | sgeom = shapely.wkb.loads(self.ewkb)
336 | srid = shapely.get_srid(sgeom)
337 | if srid == 0:
338 | srid = None
339 | if (srid or self.srid) and srid != self.srid:
340 | raise SridError(f"SRID mismatch: {srid} {self.srid}")
341 | return sgeom
342 | except ImportError:
343 | raise DependencyError("Shapely")
344 |
345 | def _to_geojson(self):
346 | coordinates = self._coordinates(dimm=False, tpl=False)
347 | geojson = {"type": self.type, "coordinates": coordinates}
348 | return geojson
349 |
350 | def __repr__(self):
351 | return f"<{self.type}: '{self.postgis_type}'>"
352 |
353 | def __str__(self):
354 | return self.ewkb.__str__()
355 |
356 | @property
357 | def __geo_interface__(self):
358 | return self.geojson
359 |
360 | def _set_dimensionality(self, geometries):
361 | self._dimz = None
362 | self._dimm = None
363 | for geometry in geometries:
364 | if self._dimz is None:
365 | self._dimz = geometry.dimz
366 | elif self._dimz != geometry.dimz:
367 | raise DimensionalityError("Mixed dimensionality in MultiGeometry")
368 | if self._dimm is None:
369 | self._dimm = geometry.dimm
370 | elif self._dimm != geometry.dimm:
371 | raise DimensionalityError("Mixed dimensionality in MultiGeometry")
372 |
373 | @staticmethod
374 | def _get_wkb_type(reader):
375 | lwgeomtype, dimz, dimm, srid = Geometry._read_wkb_header(reader)
376 | if lwgeomtype == 1:
377 | cls = Point
378 | elif lwgeomtype == 2:
379 | cls = LineString
380 | elif lwgeomtype == 3:
381 | cls = Polygon
382 | elif lwgeomtype == 4:
383 | cls = MultiPoint
384 | elif lwgeomtype == 5:
385 | cls = MultiLineString
386 | elif lwgeomtype == 6:
387 | cls = MultiPolygon
388 | elif lwgeomtype == 7:
389 | cls = GeometryCollection
390 | else:
391 | raise WkbError(f"Unsupported WKB type: {lwgeomtype}")
392 | return cls, dimz, dimm, srid
393 |
394 | @staticmethod
395 | def _read_wkb_header(reader):
396 | try:
397 | reader.get_char()
398 | header = reader.get_int()
399 | lwgeomtype = header & Geometry._WKBTYPE
400 | dimz = bool(header & Geometry._WKBZFLAG)
401 | dimm = bool(header & Geometry._WKBMFLAG)
402 | if header & Geometry._WKBSRIDFLAG:
403 | srid = reader.get_int()
404 | else:
405 | srid = None
406 | except TypeError as e:
407 | raise WkbError() from e
408 | return lwgeomtype, dimz, dimm, srid
409 |
410 | def _write_wkb_header(self, writer, use_srid, dimz, dimm):
411 | if not self.srid:
412 | use_srid = False
413 | writer.add_order()
414 | header = (
415 | self._LWGEOMTYPE
416 | | (Geometry._WKBZFLAG if dimz else 0)
417 | | (Geometry._WKBMFLAG if dimm else 0)
418 | | (Geometry._WKBSRIDFLAG if use_srid else 0)
419 | )
420 | writer.add_int(header)
421 | if use_srid:
422 | writer.add_int(self.srid)
423 | return writer
424 |
425 | def _as_wkt(self, writer):
426 | coords = self._to_wkt_coordinates(writer)
427 | if coords is None:
428 | return f"{writer.type(self)} EMPTY"
429 | else:
430 | coords = writer.wrap(coords)
431 | return f"{writer.type(self)} {coords}"
432 |
433 |
434 | class _MultiGeometry(Geometry):
435 | __slots__ = ["_geometries"]
436 |
437 | def __init__(self, multitype, geometries=None, srid=None):
438 | if self._wkb:
439 | self._geometries = None
440 | else:
441 | if not all(isinstance(geometry, multitype) for geometry in geometries):
442 | raise CollectionError(
443 | f"Found non-{multitype} when creating a Multi{multitype}"
444 | )
445 | self._geometries = geometries
446 | self._srid = srid
447 | self._set_multi_metadata()
448 |
449 | def __copy__(self):
450 | cls = self.__class__
451 | geometries = copy(self._geometries)
452 | return cls(geometries, self.srid)
453 |
454 | def __deepcopy__(self, _):
455 | cls = self.__class__
456 | geometries = [deepcopy(geometry) for geometry in self._geometries]
457 | return cls(geometries, self.srid)
458 |
459 | def __getitem__(self, index):
460 | return self._geometries[index]
461 |
462 | def __setitem__(self, index, value):
463 | if not issubclass(type(value), self.MULTITYPE):
464 | raise CollectionError(
465 | f"Can not add {type(value)} to a {type(self)}")
466 | self._geometries[index] = value
467 |
468 | def __len__(self):
469 | return len(self._geometries)
470 |
471 | def __add__(self, other):
472 | if self.srid != other.srid:
473 | raise CollectionError(
474 | "Can not add mixed SRID types")
475 |
476 | if type(other) is type(self):
477 | new_geom = copy(self)
478 | new_geom._geometries.extend(other._geometries)
479 | elif type(other) is self.MULTITYPE:
480 | new_geom = copy(self)
481 | new_geom._geometries.append(other)
482 | else:
483 | new_geom = GeometryCollection(self._geometries + [other],
484 | srid=self.srid)
485 | return new_geom
486 |
487 | def __iadd__(self, other):
488 | if self.srid != other.srid:
489 | raise CollectionError(
490 | "Can not add mixed SRID types")
491 |
492 | if type(self) is GeometryCollection:
493 | if issubclass(type(other), _MultiGeometry):
494 | self._geometries.extend(other)
495 | else:
496 | self._geometries.append(other)
497 | elif type(self) is type(other):
498 | self._geometries.extend(other.geometries)
499 | elif type(other) is self.MULTITYPE:
500 | self._geometries.append(other)
501 | else:
502 | raise CollectionError(
503 | f"Can not add a {type(other)} to a {type(self)}")
504 | return self
505 |
506 | def pop(self, index=-1):
507 | """
508 | Remove a geometry from the multigeometry.
509 | """
510 | return self._geometries.pop(index)
511 |
512 | @property
513 | def geometries(self):
514 | """
515 | List of all component geometries.
516 | """
517 | self._check_cache()
518 | return tuple(self._geometries)
519 |
520 | @property
521 | def dimz(self):
522 | """
523 | Whether the geometry has a Z dimension.
524 |
525 | :getter: ``True`` if the geometry has a Z dimension.
526 | :setter: Add or remove the Z dimension from this and all geometries in the collection.
527 | :rtype: bool
528 | """
529 | return self._dimz
530 |
531 | @dimz.setter
532 | def dimz(self, value):
533 | if self._dimz == value:
534 | return
535 | for geometry in self.geometries:
536 | geometry.dimz = value
537 | self._dimz = value
538 |
539 | @property
540 | def dimm(self):
541 | """
542 | Whether the geometry has an M dimension.
543 |
544 | :getter: ``True`` if the geometry has an M dimension.
545 | :setter: Add or remove the M dimension from this and all geometries in the collection.
546 | :rtype: bool
547 | """
548 | return self._dimm
549 |
550 | @dimm.setter
551 | def dimm(self, value):
552 | if self._dimm == value:
553 | return
554 | for geometry in self.geometries:
555 | geometry.dimm = value
556 | self._dimm = value
557 |
558 | def _bounds(self):
559 | bounds = [g.bounds for g in self.geometries]
560 | minx = min(b[0] for b in bounds)
561 | miny = min(b[1] for b in bounds)
562 | maxx = max(b[2] for b in bounds)
563 | maxy = max(b[3] for b in bounds)
564 | return (minx, miny, maxx, maxy)
565 |
566 | @staticmethod
567 | def _multi_from_geojson(geojson, cls):
568 | geometries = []
569 | for coordinates in geojson["coordinates"]:
570 | geometry = cls(coordinates, srid=None)
571 | geometries.append(geometry)
572 | return geometries
573 |
574 | def _load_geometry(self):
575 | self._geometries = self.__class__._read_wkb(
576 | self._reader, self._dimz, self._dimm
577 | )
578 |
579 | def _set_multi_metadata(self):
580 | self._dimz = None
581 | self._dimm = None
582 | for geometry in self.geometries:
583 | if self._dimz is None:
584 | self._dimz = geometry.dimz
585 | elif self._dimz != geometry.dimz:
586 | raise DimensionalityError("Mixed dimensionality in MultiGeometry")
587 | if self._dimm is None:
588 | self._dimm = geometry.dimm
589 | elif self._dimm != geometry.dimm:
590 | raise DimensionalityError("Mixed dimensionality in MultiGeometry")
591 | if geometry._srid is not None:
592 | if self._srid != geometry._srid:
593 | raise SridError(
594 | "Geometry can not be different from SRID in MultiGeometry"
595 | )
596 | geometry._srid = None
597 |
598 | def _coordinates(self, dimz=True, dimm=True, tpl=True):
599 | return [g._coordinates(dimz, dimm, tpl) for g in self.geometries]
600 |
601 | def _load_geometry(self):
602 | self._geometries = _MultiGeometry._read_wkb(
603 | self._reader, self._dimz, self._dimm
604 | )
605 |
606 | @staticmethod
607 | def _read_wkb(reader, dimz, dimm):
608 | geometries = []
609 | try:
610 | for _ in range(reader.get_int()):
611 | cls, dimz, dimm, srid = Geometry._get_wkb_type(reader)
612 | coordinates = cls._read_wkb(reader, dimz, dimm)
613 | geometry = cls(coordinates, srid=srid, dimz=dimz, dimm=dimm)
614 | geometries.append(geometry)
615 | except TypeError as e:
616 | raise WkbError() from e
617 | return geometries
618 |
619 | @classmethod
620 | def _read_wkt(cls, reader):
621 | if reader.get_empty():
622 | geoms = []
623 | else:
624 | reader.get_openpar()
625 | geoms = [cls.MULTITYPE._read_wkt(reader)]
626 | while reader.get_comma(req=False):
627 | geom = cls.MULTITYPE._read_wkt(reader)
628 | geoms.append(geom)
629 | reader.get_closepar()
630 | return cls(geoms, srid=reader.srid)
631 |
632 | def _write_wkb(self, writer, dimz, dimm):
633 | writer.add_int(len(self.geometries))
634 | for geometry in self._geometries:
635 | geometry._write_wkb_header(writer, False, dimz, dimm)
636 | geometry._write_wkb(writer, dimz, dimm)
637 |
638 | def _to_wkt_coordinates(self, writer):
639 | if not self.geometries:
640 | return None
641 |
642 | return writer.join(
643 | [writer.wrap(geom._to_wkt_coordinates(writer)) for geom in self.geometries]
644 | )
645 |
646 |
647 | class Point(Geometry):
648 | """
649 | A representation of a PostGIS Point.
650 |
651 | ``Point`` objects can be created directly.
652 |
653 | >>> Point((0, -52, 5), dimm=True, srid=4326)
654 |
655 |
656 | The ``dimz`` and ``dimm`` parameters will indicate how to interpret the
657 | coordinates that have been passed as the first argument. By default, the
658 | third coordinate will be interpreted as representing the Z dimension.
659 | """
660 |
661 | _LWGEOMTYPE = 1
662 | __slots__ = ["_x", "_y", "_z", "_m"]
663 |
664 | def __init__(self, coordinates=None, srid=None, dimz=False, dimm=False):
665 | if self._wkb:
666 | self._x = None
667 | self._y = None
668 | self._z = None
669 | self._m = None
670 | else:
671 | for c in coordinates:
672 | if c is None:
673 | continue
674 | if not isinstance(c, numbers.Number):
675 | raise CoordinateError(
676 | f"Coordinates must be numeric: {coordinates}", coordinates
677 | )
678 | self._srid = srid
679 | self._x = coordinates[0]
680 | self._y = coordinates[1]
681 | num = len(coordinates)
682 | if num > 4:
683 | raise DimensionalityError(
684 | f"Maximum dimensionality supported for coordinates is 4: {coordinates}"
685 | )
686 | if num == 2: # fill in Z and M if we are supposed to have them, else None
687 | if dimz and dimm:
688 | self._z = 0
689 | self._m = 0
690 | elif dimz:
691 | self._z = 0
692 | self._m = None
693 | elif dimm:
694 | self._z = None
695 | self._m = 0
696 | else:
697 | self._z = None
698 | self._m = None
699 | elif num == 3: # use the 3rd coordinate for Z or M as directed or as Z
700 | if dimz and dimm:
701 | self._z = coordinates[2]
702 | self._m = 0
703 | elif dimm:
704 | self._z = None
705 | self._m = coordinates[2]
706 | else:
707 | self._z = coordinates[2]
708 | self._m = None
709 | else: # use both the 3rd and 4th coordinates, ensure not None
710 | self._z = coordinates[2]
711 | self._m = coordinates[3]
712 |
713 | self._dimz = self._z is not None
714 | self._dimm = self._m is not None
715 |
716 | @property
717 | def x(self):
718 | """
719 | X coordinate.
720 | """
721 | self._check_cache()
722 | return self._x
723 |
724 | @x.setter
725 | def x(self, value):
726 | self._check_cache()
727 | self._x = value
728 |
729 | @property
730 | def y(self):
731 | """
732 | M coordinate.
733 | """
734 | self._check_cache()
735 | return self._y
736 |
737 | @y.setter
738 | def y(self, value):
739 | self._check_cache()
740 | self._y = value
741 |
742 | @property
743 | def z(self):
744 | """
745 | Z coordinate.
746 | """
747 | if not self._dimz:
748 | return None
749 | self._check_cache()
750 | return self._z
751 |
752 | @z.setter
753 | def z(self, value):
754 | self._check_cache()
755 | self._z = value
756 | if value is None:
757 | self._dimz = False
758 | else:
759 | self._dimz = True
760 |
761 | @property
762 | def m(self):
763 | """
764 | M coordinate.
765 | """
766 | if not self._dimm:
767 | return None
768 | self._check_cache()
769 | return self._m
770 |
771 | @m.setter
772 | def m(self, value):
773 | self._check_cache()
774 | self._m = value
775 | if value is None:
776 | self._dimm = False
777 | else:
778 | self._dimm = True
779 |
780 | @property
781 | def dimz(self):
782 | """
783 | Whether the geometry has a Z dimension.
784 |
785 | :getter: ``True`` if the geometry has a Z dimension.
786 | :setter: Add or remove the Z dimension.
787 | :rtype: bool
788 | """
789 | return self._dimz
790 |
791 | @dimz.setter
792 | def dimz(self, value):
793 | if self._dimz == value:
794 | return
795 | self._check_cache()
796 | if value and self._z is None:
797 | self._z = 0
798 | elif value is None:
799 | self._z = None
800 | self._dimz = value
801 |
802 | @property
803 | def dimm(self):
804 | """
805 | Whether the geometry has an M dimension.
806 |
807 | :getter: ``True`` if the geometry has an M dimension.
808 | :setter: Add or remove the M dimension.
809 | :rtype: bool
810 | """
811 | return self._dimm
812 |
813 | @dimm.setter
814 | def dimm(self, value):
815 | if self._dimm == value:
816 | return
817 | self._check_cache()
818 | if value and self._m is None:
819 | self._m = 0
820 | elif value is None:
821 | self._m = None
822 | self._dimm = value
823 |
824 | def _coordinates(self, dimz=True, dimm=True, tpl=True):
825 | dimz = dimz & self.dimz
826 | dimm = dimm & self.dimm
827 |
828 | if dimz and dimm:
829 | coordinates = (self.x, self.y, self.z, self.m)
830 | elif dimz:
831 | coordinates = (self.x, self.y, self.z)
832 | elif dimm:
833 | coordinates = (self.x, self.y, self.m)
834 | else:
835 | coordinates = (self.x, self.y)
836 |
837 | if tpl:
838 | return coordinates
839 | return list(coordinates)
840 |
841 | def _bounds(self):
842 | return (self.x, self.y, self.x, self.y)
843 |
844 | def _load_geometry(self):
845 | self._x, self._y, self._z, self._m = Point._read_wkb(
846 | self._reader, self._dimz, self._dimm
847 | )
848 |
849 | @staticmethod
850 | def _read_wkb(reader, dimz, dimm):
851 | try:
852 | x = reader.get_double()
853 | y = reader.get_double()
854 | if dimz and dimm:
855 | z = reader.get_double()
856 | m = reader.get_double()
857 | elif dimz:
858 | z = reader.get_double()
859 | m = None
860 | elif dimm:
861 | z = None
862 | m = reader.get_double()
863 | else:
864 | z = None
865 | m = None
866 | except TypeError as e:
867 | raise WkbError() from e
868 | return x, y, z, m
869 |
870 | def _write_wkb(self, writer, dimz, dimm):
871 | writer.add_double(self.x)
872 | writer.add_double(self.y)
873 | if dimz and dimm:
874 | writer.add_double(self.z)
875 | writer.add_double(self.m)
876 | elif dimz:
877 | writer.add_double(self.z)
878 | elif dimm:
879 | writer.add_double(self.m)
880 |
881 | @staticmethod
882 | def _read_wkt_coordinates(reader):
883 | if reader.get_empty():
884 | raise WktError(reader, "Points with no coordinates are not supported in WKT.")
885 | reader.get_openpar()
886 | coords = reader.get_coordinates()
887 | reader.get_closepar()
888 | return coords
889 |
890 | @staticmethod
891 | def _read_wkt(reader):
892 | coords = Point._read_wkt_coordinates(reader)
893 | return Point(coords, dimz=reader.dimz, dimm=reader.dimm, srid=reader.srid)
894 |
895 | def _to_wkt_coordinates(self, writer):
896 | return writer.format(self.coordinates)
897 |
898 | class LineString(Geometry):
899 | """
900 | A representation of a PostGIS Line.
901 |
902 | ``LineString`` objects can be created directly.
903 |
904 | >>> LineString([(0, 0, 0, 0), (1, 1, 0, 0), (2, 2, 0, 0)])
905 |
906 |
907 | The ``dimz`` and ``dimm`` parameters will indicate how to interpret the
908 | coordinates that have been passed as the first argument. By default, the
909 | third coordinate will be interpreted as representing the Z dimension.
910 | """
911 |
912 | _LWGEOMTYPE = 2
913 | __slots__ = ["_vertices"]
914 |
915 | def __init__(self, vertices=None, srid=None, dimz=False, dimm=False):
916 | if self._wkb:
917 | self._vertices = None
918 | else:
919 | self._srid = srid
920 | self._dimz = dimz
921 | self._dimm = dimm
922 | self._vertices = LineString._from_coordinates(
923 | vertices, dimz=dimz, dimm=dimm
924 | )
925 | self._set_dimensionality(self._vertices)
926 |
927 | @property
928 | def vertices(self):
929 | """
930 | List of vertices that comprise the line.
931 | """
932 | self._check_cache()
933 | return self._vertices
934 |
935 | @property
936 | def dimz(self):
937 | """
938 | Whether the geometry has a Z dimension.
939 |
940 | :getter: ``True`` if the geometry has a Z dimension.
941 | :setter: Add or remove the Z dimension from this and all vertices in the line.
942 | :rtype: bool
943 | """
944 | return self._dimz
945 |
946 | @dimz.setter
947 | def dimz(self, value):
948 | if self.dimz == value:
949 | return
950 | for vertex in self.vertices:
951 | vertex.dimz = value
952 | self._dimz = value
953 |
954 | @property
955 | def dimm(self):
956 | """
957 | Whether the geometry has a M dimension.
958 |
959 | :getter: ``True`` if the geometry has a M dimension.
960 | :setter: Add or remove the M dimension from this and all vertices in the line.
961 | :rtype: bool
962 | """
963 | return self._dimm
964 |
965 | @dimm.setter
966 | def dimm(self, value):
967 | if self.dimm == value:
968 | return
969 | for vertex in self.vertices:
970 | vertex.dimm = value
971 | self._dimm = value
972 |
973 | def _coordinates(self, dimz=True, dimm=True, tpl=True):
974 | return [v._coordinates(dimz, dimm, tpl) for v in self.vertices]
975 |
976 | def _bounds(self):
977 | x = [v.x for v in self.vertices]
978 | y = [v.y for v in self.vertices]
979 | return (min(x), min(y), max(x), max(y))
980 |
981 | @staticmethod
982 | def _from_coordinates(vertices, dimz, dimm):
983 | return [Point(vertex, dimz=dimz, dimm=dimm) for vertex in vertices]
984 |
985 | def _load_geometry(self):
986 | vertices = LineString._read_wkb(self._reader, self._dimz, self._dimm)
987 | self._vertices = LineString._from_coordinates(vertices, self._dimz, self._dimm)
988 |
989 | @staticmethod
990 | def _read_wkb(reader, dimz, dimm):
991 | vertices = []
992 | try:
993 | for _ in range(reader.get_int()):
994 | coordinates = Point._read_wkb(reader, dimz, dimm)
995 | vertices.append(coordinates)
996 | except TypeError as e:
997 | raise WkbError() from e
998 | return vertices
999 |
1000 | def _write_wkb(self, writer, dimz, dimm):
1001 | writer.add_int(len(self.vertices))
1002 | for vertex in self.vertices:
1003 | vertex._write_wkb(writer, dimz, dimm)
1004 |
1005 | @staticmethod
1006 | def _read_wkt_coordinates(reader, required=2):
1007 | reader.get_openpar()
1008 | vertices = [reader.get_coordinates()]
1009 | while reader.get_comma(req=(required>1)):
1010 | coords = reader.get_coordinates()
1011 | vertices.append(coords)
1012 | required -= 1
1013 | reader.get_closepar()
1014 | return vertices
1015 |
1016 | @staticmethod
1017 | def _read_wkt(reader):
1018 | if reader.get_empty():
1019 | raise WktError(reader, "LineStrings with no coordinates are not supported in WKT.")
1020 | vertices = LineString._read_wkt_coordinates(reader)
1021 | return LineString(vertices, dimz=reader.dimz, dimm=reader.dimm, srid=reader.srid)
1022 |
1023 | def _to_wkt_coordinates(self, writer):
1024 | return writer.join(
1025 | [v._to_wkt_coordinates(writer) for v in self.vertices]
1026 | )
1027 |
1028 | class Polygon(Geometry):
1029 | """
1030 | A representation of a PostGIS Polygon.
1031 |
1032 | ``Polygon`` objects can be created directly.
1033 |
1034 | >>> Polygon([[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0), (0, 0, 0)]])
1035 |
1036 |
1037 | The first polygon in the list of linear rings is the exterior ring, while
1038 | any subsequent rings are interior boundaries.
1039 |
1040 | The ``dimz`` and ``dimm`` parameters will indicate how to interpret the
1041 | coordinates that have been passed as the first argument. By default, the
1042 | third coordinate will be interpreted as representing the Z dimension.
1043 | """
1044 |
1045 | _LWGEOMTYPE = 3
1046 | __slots__ = ["_rings"]
1047 |
1048 | def __init__(self, rings=None, srid=None, dimz=False, dimm=False):
1049 | if self._wkb:
1050 | self._rings = None
1051 | else:
1052 | self._srid = srid
1053 | self._dimz = dimz
1054 | self._dimm = dimm
1055 | self._rings = Polygon._from_coordinates(rings, dimz=dimz, dimm=dimm)
1056 | self._set_dimensionality(self._rings)
1057 |
1058 | @property
1059 | def rings(self):
1060 | """
1061 | List of linearrings that comprise the polygon.
1062 | """
1063 | self._check_cache()
1064 | return self._rings
1065 |
1066 | @property
1067 | def exterior(self):
1068 | """
1069 | The exterior ring of the polygon.
1070 | """
1071 | return self.rings[0]
1072 |
1073 | @property
1074 | def interior(self):
1075 | """
1076 | A list of interior rings of the polygon.
1077 | """
1078 | return self.rings[1:]
1079 |
1080 | @property
1081 | def dimz(self):
1082 | """
1083 | Whether the geometry has a Z dimension.
1084 |
1085 | :getter: ``True`` if the geometry has a Z dimension.
1086 | :setter: Add or remove the Z dimension from this and all linear rings in the polygon.
1087 | :rtype: bool
1088 | """
1089 | return self._dimz
1090 |
1091 | @dimz.setter
1092 | def dimz(self, value):
1093 | if self._dimz == value:
1094 | return
1095 | for ring in self.rings:
1096 | ring.dimz = value
1097 | self._dimz = value
1098 |
1099 | @property
1100 | def dimm(self):
1101 | """LineString([(0, 0, 0, 0), (1, 1, 0, 0), (2, 2, 0, 0)])
1102 | Whether the geometry has a M dimension.
1103 |
1104 | :getter: ``True`` if the geometry has a M dimension.
1105 | :setter: Add or remove the M dimension from this and all linear rings in the polygon.
1106 | :rtype: bool
1107 | """
1108 | return self._dimm
1109 |
1110 | @dimm.setter
1111 | def dimm(self, value):
1112 | if self._dimm == value:
1113 | return
1114 | for ring in self.rings:
1115 | ring.dimm = value
1116 | self._dimm = value
1117 |
1118 | def _coordinates(self, dimz=True, dimm=True, tpl=True):
1119 | return [r._coordinates(dimz, dimm, tpl) for r in self.rings]
1120 |
1121 | def _bounds(self):
1122 | return self.exterior.bounds
1123 |
1124 | @staticmethod
1125 | def _from_coordinates(rings, dimz, dimm):
1126 | return [LineString(vertices, dimz=dimz, dimm=dimm) for vertices in rings]
1127 |
1128 | def _load_geometry(self):
1129 | rings = Polygon._read_wkb(self._reader, self._dimz, self._dimm)
1130 | self._rings = Polygon._from_coordinates(rings, self._dimz, self._dimm)
1131 |
1132 | @staticmethod
1133 | def _read_wkb(reader, dimz, dimm):
1134 | rings = []
1135 | try:
1136 | for _ in range(reader.get_int()):
1137 | vertices = LineString._read_wkb(reader, dimz, dimm)
1138 | rings.append(vertices)
1139 | except TypeError as e:
1140 | raise WkbError() from e
1141 | return rings
1142 |
1143 | def _write_wkb(self, writer, dimz, dimm):
1144 | writer.add_int(len(self.rings))
1145 | for ring in self.rings:
1146 | ring._write_wkb(writer, dimz, dimm)
1147 |
1148 | @staticmethod
1149 | def _read_wkt_coordinates(reader):
1150 | reader.get_openpar()
1151 | rings = [LineString._read_wkt_coordinates(reader, required=4)]
1152 | while reader.get_comma(req=False):
1153 | ring = LineString._read_wkt_coordinates(reader, required=4)
1154 | rings.append(ring)
1155 | reader.get_closepar()
1156 | return rings
1157 |
1158 | @staticmethod
1159 | def _read_wkt(reader):
1160 | if reader.get_empty():
1161 | raise WktError(reader, "Polygons with no coordinates are not supported in WKT.")
1162 | rings = Polygon._read_wkt_coordinates(reader)
1163 | return Polygon(rings, dimz=reader.dimz, dimm=reader.dimm, srid=reader.srid)
1164 |
1165 | def _to_wkt_coordinates(self, writer):
1166 | return writer.join(
1167 | [writer.wrap(r._to_wkt_coordinates(writer)) for r in self.rings]
1168 | )
1169 |
1170 |
1171 | class MultiPoint(_MultiGeometry):
1172 | """
1173 | A representation of a PostGIS MultiPoint.
1174 |
1175 | ``MultiPoint`` objects can be created directly from a list of ``Point``
1176 | objects.
1177 |
1178 | >>> p1 = Point((0, 0, 0))
1179 | >>> p2 = Point((1, 1, 0))
1180 | >>> MultiPoint([p1, p2])
1181 |
1182 |
1183 | The SRID and dimensionality of all geometries in the collection must be
1184 | identical.
1185 | """
1186 |
1187 | _LWGEOMTYPE = 4
1188 | MULTITYPE = Point
1189 |
1190 | def __init__(self, points=None, srid=None):
1191 | super().__init__(Point, geometries=points, srid=srid)
1192 |
1193 | @property
1194 | def points(self):
1195 | """
1196 | List of all component points.
1197 | """
1198 | return self.geometries
1199 |
1200 |
1201 | class MultiLineString(_MultiGeometry):
1202 | """
1203 | A representation of a PostGIS MultiLineString
1204 |
1205 | ``MultiLineString`` objects can be created directly from a list of
1206 | ``LineString`` objects.
1207 |
1208 | >>> l1 = LineString([(1, 1, 0), (2, 2, 0)], dimm=True)
1209 | >>> l2 = LineString([(0, 0, 0), (0, 1, 0)], dimm=True)
1210 | >>> MultiLineString([l1, l2])
1211 |
1212 |
1213 | The SRID and dimensionality of all geometries in the collection must be
1214 | identical.
1215 | """
1216 |
1217 | _LWGEOMTYPE = 5
1218 | MULTITYPE = LineString
1219 |
1220 | def __init__(self, linestrings=None, srid=None):
1221 | super().__init__(LineString, geometries=linestrings, srid=srid)
1222 |
1223 | @property
1224 | def linestrings(self):
1225 | """
1226 | List of all component lines.
1227 | """
1228 | return self.geometries
1229 |
1230 |
1231 | class MultiPolygon(_MultiGeometry):
1232 | """
1233 | A representation of a PostGIS MultiPolygon.
1234 |
1235 | ``MultiPolygon`` objects can be created directly from a list of ``Polygon``
1236 | objects.
1237 |
1238 | >>> p1 = Polygon([[(0, 0), (1, 0), (1, 1), (0, 1), (0, 0)]])
1239 | >>> p2 = Polygon([[(2, 2), (3, 2), (3, 3), (2, 3), (2, 2)]])
1240 | >>> MultiPolygon([p1, p2], srid=4326)
1241 |
1242 |
1243 | The SRID and dimensionality of all geometries in the collection must be
1244 | identical.
1245 | """
1246 |
1247 | _LWGEOMTYPE = 6
1248 | MULTITYPE = Polygon
1249 |
1250 | def __init__(self, polygons=None, srid=None):
1251 | super().__init__(Polygon, geometries=polygons, srid=srid)
1252 |
1253 | @property
1254 | def polygons(self):
1255 | """
1256 | List of all component polygons.
1257 | """
1258 | return self.geometries
1259 |
1260 |
1261 | class GeometryCollection(_MultiGeometry):
1262 | """
1263 | A representation of a PostGIS GeometryCollection.
1264 |
1265 | ``GeometryCollection`` objects can be created directly from a list of
1266 | geometries, including other collections.
1267 |
1268 | >>> p = Point((0, 0, 0))
1269 | >>> l = LineString([(1, 1, 0), (2, 2, 0)])
1270 | >>> GeometryCollection([p, l])
1271 |
1272 |
1273 | The SRID and dimensionality of all geometries in the collection must be
1274 | identical.
1275 | """
1276 |
1277 | _LWGEOMTYPE = 7
1278 | MULTITYPE = Geometry
1279 |
1280 | def __init__(self, geometries=None, srid=None):
1281 | super().__init__(Geometry, geometries=geometries, srid=srid)
1282 |
1283 | def _to_geojson(self):
1284 | geometries = [g._to_geojson() for g in self._geometries]
1285 | geojson = {"type": self.type, "geometries": geometries}
1286 | return geojson
1287 |
1288 | def _to_wkt_coordinates(self, writer):
1289 | return writer.join(
1290 | [geom._as_wkt(writer) for geom in self.geometries]
1291 | )
1292 |
1293 | @classmethod
1294 | def _read_wkt(cls, reader):
1295 | if reader.get_empty():
1296 | geoms = []
1297 | else:
1298 | reader.get_openpar()
1299 | geoms = [Geometry._read_wkt_geom(reader)]
1300 | while reader.get_comma(req=False):
1301 | geom = Geometry._read_wkt_geom(reader)
1302 | geoms.append(geom)
1303 | reader.get_closepar()
1304 | return GeometryCollection(geoms, srid=reader.srid)
1305 |
--------------------------------------------------------------------------------
/plpygis/hex.py:
--------------------------------------------------------------------------------
1 | from binascii import hexlify, unhexlify
2 | from struct import calcsize, unpack_from, pack, error
3 | from .exceptions import WkbError
4 |
5 |
6 | class HexReader:
7 | """
8 | A reader for generic hex data. The current position in the stream of bytes
9 | will be retained as data is read.
10 | """
11 |
12 | def __init__(self, hexdata, order, offset=0):
13 | self._data = hexdata
14 | self._order = order
15 | self._ini_offset = offset
16 | self._cur_offset = offset
17 |
18 | def reset(self):
19 | """
20 | Start reading from the initial position again.
21 | """
22 | self._cur_offset = self._ini_offset
23 |
24 | def _get_value(self, fmt):
25 | try:
26 | value = unpack_from(f"{self._order}{fmt}", self._data, self._cur_offset)[0]
27 | except error as e:
28 | raise WkbError(e) from e
29 | self._cur_offset += calcsize(fmt)
30 | return value
31 |
32 | def get_char(self):
33 | """
34 | Get the next character from the stream of bytes.
35 | """
36 | return self._get_value("B")
37 |
38 | def get_int(self):
39 | """
40 | Get the next four-byte integer from the stream of bytes.
41 | """
42 | return self._get_value("I")
43 |
44 | def get_double(self):
45 | """
46 | Get the next double from the stream of bytes.
47 | """
48 | return self._get_value("d")
49 |
50 |
51 | class HexWriter:
52 | """
53 | A writer for generic hex data.
54 | """
55 |
56 | def __init__(self, order):
57 | self._fmts = []
58 | self._values = []
59 | self._order = order
60 |
61 | def _add_value(self, fmt, value):
62 | self._fmts.append(fmt)
63 | self._values.append(value)
64 |
65 | def add_order(self):
66 | """
67 | Add the endianness to the stream of bytes.
68 | """
69 | if self._order == "<":
70 | self.add_char(1)
71 | else:
72 | self.add_char(0)
73 |
74 | def add_char(self, value):
75 | """
76 | Add a single character to the stream of bytes.
77 | """
78 | self._add_value("B", value)
79 |
80 | def add_int(self, value):
81 | """
82 | Add a four-byte integer to the stream of bytes.
83 | """
84 | self._add_value("I", value)
85 |
86 | def add_double(self, value):
87 | """
88 | Add a double to the stream of bytes.
89 | """
90 | self._add_value("d", value)
91 |
92 | @property
93 | def data(self):
94 | """
95 | Return the bytes as hex data.
96 | """
97 | fmt = self._order + "".join(self._fmts)
98 | data = pack(fmt, *self._values)
99 | return HexBytes(data)
100 |
101 |
102 | class HexBytes(bytes):
103 | """A subclass of bytearray that represents binary WKB data.
104 | It can be converted to a hexadecimal representation of the data using str()
105 | and compared to a hexadecimal representation with the normal equality operator."""
106 |
107 | def __new__(cls, data):
108 | if not isinstance(data, (bytes, bytearray)):
109 | data = unhexlify(str(data))
110 | elif data[:2] in (b"00", b"01"): # hex-encoded string as bytes
111 | data = unhexlify(data.decode("ascii"))
112 | return bytes.__new__(cls, data)
113 |
114 | def __str__(self):
115 | return hexlify(self).decode("ascii")
116 |
117 | def __eq__(self, other):
118 | if isinstance(other, str) and other[:2] in ("00", "01"):
119 | other = unhexlify(other)
120 | return super().__eq__(other)
121 |
--------------------------------------------------------------------------------
/plpygis/wkt.py:
--------------------------------------------------------------------------------
1 | import re
2 | from .exceptions import WktError
3 |
4 | PRECISION = 6
5 |
6 | def _regex(expr):
7 | return re.compile(fr"\s*{expr}\s*")
8 |
9 | class WktReader:
10 | """
11 | A reader for Well-Knownn Text.
12 | """
13 | _TYPE = _regex("(POINT|LINESTRING|POLYGON|MULTIPOINT|MULTILINESTRING|MULTIPOLYGON|GEOMETRYCOLLECTION)")
14 | _DIMS = _regex("(ZM|Z|M)")
15 | _EMPTY = _regex("EMPTY")
16 | _OP = _regex("[(]")
17 | _CP = _regex("[)]")
18 | _COMMA = _regex("[,]")
19 | _NUMBER = _regex("[-]?[0-9]+[.]?[0-9]*")
20 | _SRID = _regex("SRID=[0-9]+;")
21 |
22 | def __init__(self, wkt, offset=0):
23 | self._data = wkt.upper().strip()
24 | self._start = offset
25 | self.pos = offset
26 |
27 | def close(self):
28 | """
29 | Terminate reading and raise error if unconsumed characters remain.
30 | """
31 | if self.pos != len(self._data):
32 | raise WktError(self, expected="end of WKT")
33 |
34 | def reset(self):
35 | """
36 | Start reading from the initial position again.
37 | """
38 | self.pos = self._start
39 |
40 | def _get_value(self, expr):
41 | match = expr.match(self._data, pos=self.pos)
42 | if match:
43 | self.pos = match.end()
44 | return match.group().strip()
45 | else:
46 | return None
47 |
48 | def _get_number(self):
49 | value = self._get_value(self._NUMBER)
50 | if not value:
51 | raise WktError(self, expected="number")
52 | return float(value)
53 |
54 | def get_type(self):
55 | value = self._get_value(self._TYPE)
56 | if not value:
57 | raise WktError(self, expected="geometry type")
58 | else:
59 | return value
60 |
61 | def get_dims(self):
62 | value = self._get_value(self._DIMS)
63 |
64 | if value == "Z":
65 | self.dimz = True
66 | self.dimm = False
67 | elif value == "M":
68 | self.dimz = False
69 | self.dimm = True
70 | elif value == "ZM":
71 | self.dimz = True
72 | self.dimm = True
73 | else:
74 | self.dimz = False
75 | self.dimm = False
76 | return self.dimz, self.dimm
77 |
78 | def get_empty(self):
79 | return self._get_value(self._EMPTY)
80 |
81 | def get_openpar(self):
82 | value = self._get_value(self._OP)
83 | if not value:
84 | raise WktError(self, expected="opening parenthesis")
85 | return True
86 |
87 | def get_closepar(self):
88 | value = self._get_value(self._CP)
89 | if not value:
90 | raise WktError(self, expected="closing parenthesis")
91 | return True
92 |
93 | def get_srid(self):
94 | value = self._get_value(self._SRID)
95 | if value:
96 | value = value.strip("SRID=")
97 | value = value.strip(";")
98 | value = int(value)
99 | self.srid = value
100 | return value
101 |
102 | def get_comma(self, req=True):
103 | value = self._get_value(self._COMMA)
104 | if not value:
105 | if req:
106 | raise WktError(self, expected="comma")
107 | else:
108 | return False
109 | return True
110 |
111 | def get_coordinates(self):
112 | x = self._get_number()
113 | y = self._get_number()
114 | if self.dimz:
115 | z = self._get_number()
116 | if self.dimm:
117 | m = self._get_number()
118 |
119 | if self.dimz and self.dimm:
120 | return (x, y, z, m)
121 | elif self.dimz:
122 | return (x, y, z)
123 | elif self.dimm:
124 | return (x, y, m)
125 | else:
126 | return (x, y)
127 |
128 |
129 | class WktWriter:
130 | """
131 | A writer for Well-Knownn Text.
132 | """
133 |
134 | def __init__(self, geom, use_srid=True):
135 | self.geom = geom
136 | self.dims = False
137 | self.add_srid(use_srid)
138 |
139 | def add_srid(self, use_srid):
140 | if use_srid and self.geom.srid:
141 | self.wkt = f"SRID={self.geom.srid};"
142 | else:
143 | self.wkt = ""
144 |
145 | def add_dims(self):
146 | if self.dims:
147 | return ""
148 | self.dims = True
149 | if self.geom.dimz and self.geom.dimm:
150 | return " ZM"
151 | elif self.geom.dimz:
152 | return " Z"
153 | elif self.geom.dimm:
154 | return " M"
155 | else:
156 | return ""
157 |
158 | def type(self, geom):
159 | wkt = geom.type.upper()
160 | wkt += self.add_dims()
161 | return wkt
162 |
163 | def add(self, text):
164 | self.wkt += text
165 |
166 | def format(self, coords):
167 | return " ".join([f"{float(c):#.{PRECISION}f}".rstrip("0").rstrip(".") for c in coords])
168 |
169 | def wrap(self, text):
170 | return f"({text})"
171 |
172 | def join(self, items):
173 | return ", ".join(items)
174 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | requires = ["setuptools>=64"]
3 | build-backend = "setuptools.build_meta"
4 |
5 | [project]
6 | name = "plpygis"
7 | dynamic = ["version"]
8 | description = "Python tools for PostGIS"
9 | readme = "README.md"
10 | requires-python = ">=3.8"
11 | authors = [{name = "Benjamin Trigona-Harany", email = "plpygis@jaxartes.net"}]
12 | license = {text = "GPL-3.0-only"}
13 | classifiers = [
14 | "Topic :: Database",
15 | "Topic :: Scientific/Engineering :: GIS"
16 | ]
17 | keywords = ["gis", "geospatial", "postgis", "postgresql", "pl/python"]
18 |
19 | [tool.setuptools.dynamic]
20 | version = {attr = "plpygis.__version__"}
21 |
22 | [project.urls]
23 | Homepage = "https://github.com/bosth/plpygis"
24 | Issues = "https://github.com/bosth/plpygis/issues"
25 | Documentation = "https://plpygis.readthedocs.io/"
26 |
27 | [project.optional-dependencies]
28 | test = ["pytest", "pyshp", "Shapely>=2.0.4"]
29 | shapely_support = ["Shapely>=2.0.4"]
30 |
--------------------------------------------------------------------------------
/pytest.ini:
--------------------------------------------------------------------------------
1 | [pytest]
2 | addopts = -v --doctest-modules --no-header
3 | python_functions = test_*
4 | testpaths =
5 | plpygis
6 | test
7 |
--------------------------------------------------------------------------------
/requirements-test.txt:
--------------------------------------------------------------------------------
1 | pytest==8.1
2 | pytest-cov==5.0.0
3 | pyshp==2.1.0
4 | Shapely==2.0.4
5 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | Shapely>=2.0.4[shapely_support]
2 |
--------------------------------------------------------------------------------
/test/multipoint.shp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bosth/plpygis/f97997980d038e57c819225c278178fb2b3b20e6/test/multipoint.shp
--------------------------------------------------------------------------------
/test/test_geometry.py:
--------------------------------------------------------------------------------
1 | """
2 | Test Geometry
3 | """
4 |
5 | import pytest
6 | from plpygis import Geometry, Point, LineString, Polygon
7 | from plpygis import MultiPoint, MultiLineString, MultiPolygon, GeometryCollection
8 | from plpygis.exceptions import PlpygisError, WkbError, SridError, DimensionalityError, CoordinateError, GeojsonError, CollectionError, WktError, DependencyError
9 | from copy import copy, deepcopy
10 |
11 | geojson_pt = {"type":"Point","coordinates":[0.0,0.0]}
12 | geojson_ln = {"type":"LineString","coordinates":[[107,60],[102,59]]}
13 | geojson_pg = {"type":"Polygon","coordinates":[[[100,0],[101.0,0.0],[101.0,1.0],[100.0,1.0],[100.0,0.0]]]}
14 | geojson_mpt = {"type":"MultiPoint","coordinates":[[0,0],[1,1]]}
15 | geojson_mln = {"type":"MultiLineString","coordinates":[[[0.1,0.2,0.3],[1.1,1.2,1.3]],[[2.1,2.2,2.3],[3.1,3.2,3.3]]]}
16 | geojson_mpg = {"type":"MultiPolygon","coordinates":[[[[1,0],[111,0.0],[101.0,1.0],[100.0,1.0],[1,0]]],[[[100,0],[101.0,0.0],[101.0,1.0],[100.0,1.0],[100.0,0.0]]]]}
17 | geojson_gc = {"type":"GeometryCollection","geometries":[{"type":"Point","coordinates":[10,0]},{"type":"LineString","coordinates":[[11,0],[12,1]]}]}
18 | geojson_err = {"type":"Hello","coordinates":[0.0,0.0]}
19 | geojson_pg_ring = {"type":"Polygon","coordinates":[[[100,0], [101,0],[201,1],[100,1],[100,0]],[[100.2,0.2],[100.8,0.2],[100.8,0.8],[100.2,0.8],[100.2,0.2]]]}
20 | wkb_ln = "0102000000050000000000000040BE40409D640199EB373F400000000080AC3E40BF244710FD1939400000000000503940D2A6484BEB41374000000000801D3740248729C89C832A400000000000833340940338EFAFBB2C40"
21 | wkb_pg = "010300000002000000060000000000000000003440000000000080414000000000000024400000000000003E40000000000000244000000000000024400000000000003E4000000000000014400000000000804640000000000000344000000000000034400000000000804140040000000000000000003E40000000000000344000000000000034400000000000002E40000000000000344000000000000039400000000000003E400000000000003440"
22 | wkb_mpt = "010400008002000000010100008000000000000059400000000000006940000000000000000001010000800000000000000000000000000000F03F0000000000000000"
23 | wkb_mln = "010500004002000000010200004002000000000000000000000000000000000000000000000000004940000000000000F03F000000000000F03F0000000000003940010200004002000000000000000000F0BF000000000000F0BF000000000000F03F2DB29DEFA7C60140ED0DBE3099AA0A400000000000388F40"
24 | wkb_mpg = "01060000000200000001030000000100000004000000000000000000444000000000000044400000000000003440000000000080464000000000008046400000000000003E4000000000000044400000000000004440010300000002000000060000000000000000003440000000000080414000000000000024400000000000003E40000000000000244000000000000024400000000000003E4000000000000014400000000000804640000000000000344000000000000034400000000000804140040000000000000000003E40000000000000344000000000000034400000000000002E40000000000000344000000000000039400000000000003E400000000000003440"
25 | wkb_gc = "0107000000020000000101000000000000000000000000000000000000000102000000020000000000000000000000000000000000F03F000000000000F03F000000000000F03F"
26 | wkb_mpt_srid = "0104000020e8030000020000000101000000000000000000000000000000000000000101000000000000000000f03f000000000000f03f"
27 |
28 | def test_missing_ewkb():
29 | """
30 | Error on missing EWKB
31 | """
32 | with pytest.raises(WkbError):
33 | Geometry(None)
34 |
35 | def test_malformed_ewkb_len():
36 | """
37 | malformed EWKB (insufficient bytes)
38 | """
39 | with pytest.raises(Exception):
40 | Geometry("0101")
41 |
42 | def test_wkb_type():
43 | """
44 | malformed EWKB (insufficient bytes)
45 | """
46 | with pytest.raises(WkbError):
47 | Geometry(0)
48 |
49 | def test_malformed_ewkb_firstbyte():
50 | """
51 | malformed EWKB (bad first byte)
52 | """
53 | with pytest.raises(WkbError):
54 | Geometry("5101")
55 |
56 | def test_unsupported_ewkb_type():
57 | """
58 | unsupported EWKB type
59 | """
60 | with pytest.raises(WkbError):
61 | Geometry("010800000000000000000000000000000000000000")
62 |
63 | def test_read_wkb_point():
64 | """
65 | read WKB Point
66 | """
67 | wkb = "010100000000000000000000000000000000000000"
68 | geom = Geometry(wkb)
69 | assert geom.type == "Point"
70 | assert geom.srid is None
71 | assert not geom.dimz
72 | assert not geom.dimm
73 | postgis_type = "geometry(Point)"
74 | assert geom.postgis_type == postgis_type
75 | assert geom.__repr__() == ""
76 | geom.srid = geom.srid # clear cached WKB
77 | assert geom.__str__().lower() == wkb.lower()
78 |
79 | def test_read_wkb_point_big_endian():
80 | """
81 | read WKB Point
82 | """
83 | geom = Geometry("000000000140000000000000004010000000000000")
84 | assert isinstance(geom, Point)
85 | assert geom.x == 2
86 | assert geom.y == 4
87 | assert geom.z is None
88 |
89 | def test_read_ewkb_point_srid():
90 | """
91 | read EWKB Point,4326
92 | """
93 | wkb = "0101000020E610000000000000000000000000000000000000"
94 | geom = Geometry(wkb)
95 | assert geom.type == "Point"
96 | assert geom.srid == 4326
97 | assert not geom.dimz
98 | assert not geom.dimm
99 | postgis_type = "geometry(Point,4326)"
100 | assert geom.postgis_type == postgis_type
101 | assert geom.__repr__() == ""
102 | geom.srid = geom.srid # clear cached WKB
103 | assert geom.ewkb == wkb.lower()
104 | assert geom.wkb != geom.ewkb
105 | assert geom.__str__().lower() == wkb.lower()
106 |
107 | def test_read_ewkb_pointz():
108 | """
109 | read EWKB PointZ,4326
110 | """
111 | wkb = "01010000A0E6100000000000000000000000000000000000000000000000000000"
112 | geom = Geometry(wkb)
113 | assert geom.type == "Point"
114 | assert geom.srid == 4326
115 | assert geom.dimz
116 | assert not geom.dimm
117 | postgis_type = "geometry(PointZ,4326)"
118 | assert geom.postgis_type == postgis_type
119 | assert geom.__repr__() == ""
120 | geom.srid = geom.srid # clear cached WKB
121 | assert geom.ewkb == wkb.lower()
122 | assert geom.wkb != geom.ewkb
123 | assert geom.__str__().lower() == wkb.lower()
124 |
125 | def test_read_ewkb_pointm():
126 | """
127 | read EWKB PointM,4326
128 | """
129 | wkb = "0101000060E6100000000000000000000000000000000000000000000000000000"
130 | geom = Geometry(wkb)
131 | assert geom.type == "Point"
132 | assert geom.srid == 4326
133 | assert not geom.dimz
134 | assert geom.dimm
135 | postgis_type = "geometry(PointM,4326)"
136 | assert geom.postgis_type == postgis_type
137 | assert geom.__repr__() == ""
138 | geom.srid = geom.srid # clear cached WKB
139 | assert geom.ewkb == wkb.lower()
140 | assert geom.wkb != geom.ewkb
141 | assert geom.__str__().lower() == wkb.lower()
142 |
143 | def test_read_ewkb_pointzm():
144 | """
145 | read EWKB PointZM,4326
146 | """
147 | wkb = "01010000E0E61000000000000000000000000000000000000000000000000000000000000000000000"
148 | geom = Geometry(wkb)
149 | assert geom.type == "Point"
150 | assert geom.srid == 4326
151 | assert geom.dimz
152 | assert geom.dimm
153 | geom.srid = geom.srid # clear cached WKB
154 | postgis_type = "geometry(PointZM,4326)"
155 | assert geom.postgis_type == postgis_type
156 | assert geom.__repr__() == ""
157 | geom.srid = geom.srid # clear cached WKB
158 | assert geom.ewkb == wkb.lower()
159 | assert geom.wkb != geom.ewkb
160 | assert geom.__str__().lower() == wkb.lower()
161 |
162 | def test_read_wkb_data_error():
163 | """
164 | read WKB with good header but malformed data
165 | """
166 | wkb = "0000000001000000000000"
167 | geom = Geometry(wkb)
168 | assert geom.type == "Point"
169 | with pytest.raises(WkbError):
170 | geom.x
171 |
172 | def test_read_wkb_linestring():
173 | """
174 | read WKB LineString
175 | """
176 | wkb = wkb_ln
177 | geom = Geometry(wkb)
178 | assert geom.type == "LineString"
179 | assert geom.srid is None
180 | assert not geom.dimz
181 | assert not geom.dimm
182 | postgis_type = "geometry(LineString)"
183 | geom.vertices
184 | assert geom.postgis_type == postgis_type
185 | assert geom.__repr__() == ""
186 | geom.srid = geom.srid # clear cached WKB
187 | assert geom.__str__().lower() == wkb.lower()
188 |
189 | def test_read_wkb_polygon():
190 | """
191 | read WKB Polygon
192 | """
193 | wkb = wkb_pg
194 | geom = Geometry(wkb)
195 | assert geom.type == "Polygon"
196 | assert geom.srid is None
197 | assert not geom.dimz
198 | assert not geom.dimm
199 | postgis_type = "geometry(Polygon)"
200 | assert geom.exterior.type == "LineString"
201 | assert geom.postgis_type == postgis_type
202 | assert geom.__repr__() == ""
203 | geom.srid = geom.srid # clear cached WKB
204 | assert geom.__str__().lower() == wkb.lower()
205 |
206 | def test_read_wkb_multipoint():
207 | """
208 | read WKB MultiPoint
209 | """
210 | wkb = wkb_mpt
211 | geom = Geometry(wkb)
212 | assert geom.type == "MultiPoint"
213 | assert geom.srid is None
214 | assert geom.dimz
215 | assert not geom.dimm
216 | postgis_type = "geometry(MultiPointZ)"
217 | assert geom.postgis_type == postgis_type
218 | assert geom.__repr__() == ""
219 | geom.srid = geom.srid # clear cached WKB
220 | assert geom.__str__().lower() == wkb.lower()
221 | for g in geom.geometries:
222 | assert g.type == "Point"
223 | for g in geom.points:
224 | assert g.type == "Point"
225 | assert wkb == geom.wkb
226 |
227 | def test_read_wkb_multilinestring():
228 | """
229 | read WKB MultiLineString
230 | """
231 | wkb = wkb_mln
232 | geom = Geometry(wkb)
233 | assert geom.type == "MultiLineString"
234 | assert geom.srid is None
235 | assert not geom.dimz
236 | assert geom.dimm
237 | postgis_type = "geometry(MultiLineStringM)"
238 | assert geom.postgis_type == postgis_type
239 | assert geom.__repr__() == ""
240 | geom.srid = geom.srid # clear cached WKB
241 | assert geom.__str__().lower() == wkb.lower()
242 | for g in geom.geometries:
243 | assert g.type == "LineString"
244 | for g in geom.linestrings:
245 | assert g.type == "LineString"
246 | assert wkb == geom.wkb
247 |
248 | def test_read_wkb_multipolygon():
249 | """
250 | read WKB MultiPolygon
251 | """
252 | wkb = wkb_mpg
253 | geom = Geometry(wkb)
254 | assert geom.type == "MultiPolygon"
255 | assert geom.srid is None
256 | assert not geom.dimz
257 | assert not geom.dimm
258 | postgis_type = "geometry(MultiPolygon)"
259 | assert geom.postgis_type == postgis_type
260 | assert geom.__repr__() == ""
261 | geom.srid = geom.srid # clear cached WKB
262 | assert geom.__str__().lower() == wkb.lower()
263 | for g in geom.geometries:
264 | assert g.type == "Polygon"
265 | for g in geom.polygons:
266 | assert g.type == "Polygon"
267 | assert wkb == geom.wkb
268 |
269 | def test_read_wkb_geometrycollection():
270 | """
271 | read WKB GeometryCollection
272 | """
273 | wkb = wkb_gc
274 | geom = Geometry(wkb)
275 | assert geom.type == "GeometryCollection"
276 | assert geom.srid is None
277 | assert not geom.dimz
278 | assert not geom.dimm
279 | postgis_type = "geometry(GeometryCollection)"
280 | assert geom.postgis_type == postgis_type
281 | assert geom.__repr__() == ""
282 | geom.srid = geom.srid # clear cached WKB
283 | assert geom.__str__().lower() == wkb.lower()
284 | assert geom.geometries[0].type == "Point"
285 | assert geom.geometries[1].type == "LineString"
286 | assert wkb == geom.wkb
287 |
288 | def test_read_wkb_srid():
289 | wkb_mpt_srid = "0104000020e8030000020000000101000000000000000000000000000000000000000101000000000000000000f03f000000000000f03f"
290 | geom = Geometry(wkb_mpt_srid)
291 | wkb = geom.wkb
292 | ewkb = geom.ewkb
293 | assert wkb != ewkb
294 |
295 | def test_write_wkb_srid():
296 | p = Point([100, 100], srid=4236)
297 | p2 = Geometry(p.wkb)
298 | assert p.x == p2.x
299 | assert p.y == p2.y
300 | assert p.dimz == p2.dimz
301 | assert p.dimm == p2.dimm
302 | assert p2.srid is None
303 |
304 | p3 = Geometry(p.ewkb)
305 | assert p.x == p3.x
306 | assert p.y == p3.y
307 | assert p.dimz == p3.dimz
308 | assert p.dimm == p3.dimm
309 | assert p.srid == p3.srid
310 |
311 | def test_multigeometry_raise_error():
312 | """
313 | raise error when adding wrong type to a multigeometry
314 | """
315 | pt = Point((0,1))
316 | ls = LineString([(0,1), (2,3)])
317 |
318 | MultiPoint([pt, copy(pt)])
319 |
320 | with pytest.raises(CollectionError):
321 | MultiPoint([pt, ls])
322 |
323 | with pytest.raises(CollectionError):
324 | MultiLineString([pt, ls])
325 |
326 | with pytest.raises(CollectionError):
327 | MultiPolygon([pt, ls])
328 |
329 | with pytest.raises(CollectionError):
330 | GeometryCollection([pt, ls, True])
331 |
332 | def test_multigeometry_nochangedimensionality():
333 | mp = MultiPoint([Point((0, 1, 2)), Point((5, 6, 7))])
334 | mp.dimz = True
335 | assert mp.dimz is True
336 | mp.dimm = False
337 | assert mp.dimm is False
338 |
339 | def test_point_nullifyzm():
340 | p = Point((0, 1, 2, 3))
341 | assert p.dimz is True
342 | p.z = None
343 | assert p.dimz is False
344 | p.dimz = False
345 | assert p.dimz is False
346 |
347 | assert p.dimm is True
348 | p.m = None
349 | assert p.dimm is False
350 | p.dimm = False
351 | assert p.dimm is False
352 |
353 | def test_multigeometry_changedimensionality():
354 | """
355 | change dimensionality of a MultiGeometry
356 | """
357 | wkb = wkb_gc
358 | geom = Geometry(wkb)
359 | assert not geom.dimz
360 | assert not geom.dimm
361 | geom.dimz = True
362 | geom.dimm = True
363 | assert geom.dimz
364 | assert geom.dimm
365 | geom.srid = geom.srid # clear cached WKB
366 | assert geom.__str__().lower() != wkb.lower()
367 |
368 | def test_malformed_coordinates():
369 | """
370 | malformed coordinates (wrong type)
371 | """
372 | coordinates = (1, "test")
373 | with pytest.raises(CoordinateError):
374 | Point(coordinates)
375 |
376 | coordinates = [(1, 2), [(1, 2), (3, 4)]]
377 | with pytest.raises(CoordinateError):
378 | LineString(coordinates)
379 |
380 | def test_multigeometry_srid():
381 | """
382 | create geometry with SRID
383 | """
384 | p1 = Point((0, 0), srid=1000)
385 | p2 = Point((1, 1), srid=1000)
386 | mp = MultiPoint([p1, p2], srid=1000)
387 | assert mp.ewkb == wkb_mpt_srid
388 |
389 | def test_multigeometry_srid_exception():
390 | """
391 | mixed SRIDs on multigeometry creation
392 | """
393 | p1 = Point((0, 0), srid=1000)
394 | p2 = Point((1, 1), srid=1000)
395 | with pytest.raises(SridError):
396 | MultiPoint([p1, p2])
397 |
398 | p1 = Point((0, 0), srid=1000)
399 | p2 = Point((1, 1), srid=1000)
400 | with pytest.raises(SridError):
401 | MultiPoint([p1, p2], 4326)
402 |
403 | def test_translate_geojson_pt():
404 | """
405 | load and dump GeoJSON point
406 | """
407 | geom = Geometry.from_geojson(geojson_pt)
408 | assert geom.srid == 4326
409 | assert Point == type(geom)
410 | assert geom.geojson == geojson_pt
411 |
412 | def test_translate_geojson_ln():
413 | """
414 | load and dump GeoJSON line
415 | """
416 | geom = Geometry.from_geojson(geojson_ln)
417 | assert geom.srid == 4326
418 | assert LineString == type(geom)
419 | assert geom.geojson == geojson_ln
420 |
421 | def test_translate_geojson_pg():
422 | """
423 | load and dump GeoJSON polygon
424 | """
425 | geom = Geometry.from_geojson(geojson_pg)
426 | assert geom.srid == 4326
427 | assert Polygon == type(geom)
428 | assert geom.geojson == geojson_pg
429 |
430 | def test_translate_geojson_mpt():
431 | """
432 | load and dump GeoJSON multipoint
433 | """
434 | geom = Geometry.from_geojson(geojson_mpt)
435 | assert geom.srid == 4326
436 | assert MultiPoint == type(geom)
437 | assert geom.geojson == geojson_mpt
438 |
439 | def test_translate_geojson_mln():
440 | """
441 | load and dump GeoJSON multiline
442 | """
443 | geom = Geometry.from_geojson(geojson_mln)
444 | assert geom.srid == 4326
445 | assert MultiLineString == type(geom)
446 | assert geom.geojson == geojson_mln
447 |
448 | def test_translate_geojson_mpg():
449 | """
450 | load and dump GeoJSON multipolygon
451 | """
452 | geom = Geometry.from_geojson(geojson_mpg)
453 | assert geom.srid == 4326
454 | assert MultiPolygon == type(geom)
455 | assert geom.geojson == geojson_mpg
456 |
457 | def test_translate_geojson_gc():
458 | """
459 | load and dump GeoJSON GeometryCollection
460 | """
461 | geom = Geometry.from_geojson(geojson_gc)
462 | assert geom.srid == 4326
463 | assert GeometryCollection == type(geom)
464 | assert geom.geojson == geojson_gc
465 |
466 | def test_translate_geojson_zm():
467 | geom = Point((0, 1, 2, 3))
468 | assert geom.geojson == {"type":"Point","coordinates":[0.0,1.0,2.0]}
469 |
470 | def test_translate_geojson_error():
471 | """
472 | catch invalid GeoJSON
473 | """
474 | with pytest.raises(GeojsonError):
475 | Geometry.from_geojson(geojson_err)
476 |
477 | def test_geo_interface():
478 | """
479 | access using __geo_interface__
480 | """
481 | geom = Geometry.from_geojson(geojson_pt)
482 | assert geojson_pt == geom.__geo_interface__
483 |
484 | def test_shape():
485 | """
486 | access using __geo_interface__ and shape
487 | """
488 | point = Point.from_geojson(geojson_pt)
489 | geom = Geometry.shape(point)
490 | assert point.__geo_interface__ == geom.__geo_interface__
491 |
492 | def test_shapely_dump():
493 | """
494 | convert to Shapely
495 | """
496 | point = Point((123,123))
497 | try:
498 | from shapely import geometry
499 | sgeom = point.shapely
500 | assert sgeom.wkb == point.wkb
501 | except ImportError:
502 | with pytest.raises(DependencyError):
503 | sgeom = point.shapely
504 |
505 | def test_shapely_load():
506 | """
507 | convert from Shapely
508 | """
509 | try:
510 | import shapely
511 | from shapely import geometry
512 | sgeom = geometry.Point(99,-99)
513 | point = Geometry.from_shapely(sgeom)
514 | assert point.wkb == sgeom.wkb
515 | assert point.ewkb == shapely.to_wkb(sgeom, include_srid=True)
516 | except ImportError:
517 | with pytest.raises(DependencyError):
518 | point = Geometry.from_shapely(1)
519 |
520 | def test_strip_srid():
521 | """
522 | strip SRID
523 | """
524 | geom1 = Geometry("010100000000000000000000000000000000000000")
525 | geom2 = Geometry("0101000020E610000000000000000000000000000000000000")
526 | assert geom1.srid is None
527 | assert geom2.srid == 4326
528 | geom2.srid = None
529 | assert geom2.srid is None
530 | assert geom1.wkb == geom2.wkb
531 | geom2.srid = None
532 | assert geom2.srid is None
533 | assert geom1.wkb == geom2.wkb
534 |
535 | def test_bounds_point():
536 | """
537 | check bounds of Point
538 | """
539 | geom = Geometry.from_geojson(geojson_pt)
540 | bounds = geom.bounds
541 | assert bounds[0] == 0.0
542 | assert bounds[1] == 0.0
543 | assert bounds[2] == 0.0
544 | assert bounds[3] == 0.0
545 |
546 | def test_bounds_linestring():
547 | """
548 | check bounds of LineString
549 | """
550 | geom = Geometry.from_geojson(geojson_ln)
551 | bounds = geom.bounds
552 | assert bounds[0] == 102
553 | assert bounds[1] == 59
554 | assert bounds[2] == 107
555 | assert bounds[3] == 60
556 |
557 | def test_bounds_polygon():
558 | """
559 | check bounds of Polygon
560 | """
561 | geom = Geometry.from_geojson(geojson_pg)
562 | bounds = geom.bounds
563 | assert bounds[0] == 100
564 | assert bounds[1] == 0
565 | assert bounds[2] == 101
566 | assert bounds[3] == 1
567 |
568 | def test_bounds_multipoint():
569 | """
570 | check bounds of MultiPoint
571 | """
572 | geom = Geometry.from_geojson(geojson_mpt)
573 | bounds = geom.bounds
574 | assert bounds[0] == 0
575 | assert bounds[1] == 0
576 | assert bounds[2] == 1
577 | assert bounds[3] == 1
578 |
579 | def test_bounds_multilinestring():
580 | """
581 | check bounds of MultiLineString
582 | """
583 | geom = Geometry.from_geojson(geojson_mln)
584 | bounds = geom.bounds
585 | assert bounds[0] == 0.1
586 | assert bounds[1] == 0.2
587 | assert bounds[2] == 3.1
588 | assert bounds[3] == 3.2
589 |
590 | def test_bounds_multipolygon():
591 | """
592 | check bounds of MultiPolygon
593 | """
594 | geom = Geometry.from_geojson(geojson_mpg)
595 | bounds = geom.bounds
596 | assert bounds[0] == 1
597 | assert bounds[1] == 0
598 | assert bounds[2] == 111
599 | assert bounds[3] == 1
600 |
601 | def test_bounds_geomcollection():
602 | """
603 | check bounds of GeometryCollection
604 | """
605 | geom = Geometry.from_geojson(geojson_gc)
606 | bounds = geom.bounds
607 | assert bounds[0] == 10
608 | assert bounds[1] == 0
609 | assert bounds[2] == 12
610 | assert bounds[3] == 1
611 |
612 | def test_mixed_dimensionality():
613 | """
614 | detect mixed dimensionality in MultiGeometries
615 | """
616 | p1 = Point((0, 1, 2))
617 | p2 = Point((0, 1))
618 | with pytest.raises(DimensionalityError):
619 | MultiPoint([p1, p2])
620 |
621 | def test_invalid_point_dimension():
622 | """
623 | detect extra dimensions in Point creation
624 | """
625 | with pytest.raises(DimensionalityError):
626 | Point((0, 1, 2, 3, 4))
627 |
628 | def test_dimension_reading():
629 | """
630 | check dimensions are set correctly
631 | """
632 | p = Point((0, 1))
633 | assert not p.dimz
634 | assert not p.dimm
635 | p = Point((0, 1, 2))
636 | assert p.dimz
637 | assert not p.dimm
638 | p = Point((0, 1, 2, 3))
639 | assert p.dimz
640 | assert p.dimm
641 | p = Point((0, 1, 2, 3), dimz=False, dimm=False)
642 | assert p.dimz
643 | assert p.dimm
644 | p = Point((0, 1), dimz=True, dimm=True)
645 | assert p.dimz
646 | assert p.dimm
647 | p = Point((0, 1), dimz=True)
648 | assert p.dimz
649 | assert not p.dimm
650 | p = Point((0, 1), dimm=True)
651 | assert not p.dimz
652 | assert p.dimm
653 | p = Point((0, 1, 2), dimz=True)
654 | assert p.dimz
655 | assert not p.dimm
656 | p = Point((0, 1, 2), dimm=True)
657 | assert not p.dimz
658 | assert p.dimm
659 | p = Point((0, 1, 2), dimz=True, dimm=True)
660 | assert p.dimz
661 | assert p.dimm
662 |
663 | def test_modify_point():
664 | """
665 | modify Point
666 | """
667 | wkb = "010100000000000000000000000000000000000000"
668 | p = Geometry(wkb)
669 | oldx = p.x
670 | oldy = p.y
671 | oldsrid = p.srid
672 | assert not p.dimz
673 | assert not p.dimm
674 | newx = -99
675 | newy = -101
676 | newz = 88
677 | newm = 8
678 | newsrid = 900913
679 | assert p.x != newx
680 | assert p.y != newy
681 | assert p.z != newz
682 | assert p.m != newm
683 | assert p.srid != newsrid
684 | p.x = newx
685 | p.y = newy
686 | p.z = newz
687 | p.m = newm
688 | p.srid = newsrid
689 | assert p.x == newx
690 | assert p.y == newy
691 | assert p.z == newz
692 | assert p.m == newm
693 | assert p.srid == newsrid
694 | assert p.__str__().lower() != wkb.lower()
695 | p.x = oldx
696 | p.y = oldy
697 | p.srid = oldsrid
698 | p.dimz = None
699 | p.dimm = None
700 | assert p.__str__().lower() == wkb.lower()
701 |
702 | def test_binary_wkb_roundtrip():
703 | ewkb = b'\x01\x01\x00\x00\x20\xe6\x10\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
704 | geom = Geometry(ewkb)
705 | assert geom.ewkb == ewkb
706 | assert geom.srid == 4326
707 | assert geom.wkb != ewkb
708 |
709 | def test_byte_array_wkb_string():
710 | wkb = bytearray(b'010100000000000000000000000000000000000000')
711 | assert str(Geometry(wkb)) == wkb.decode('ascii')
712 |
713 | def test_point_coordinates():
714 | """
715 | get coordinates of a Point
716 | """
717 | coordinates = (1, 2, 3, 4)
718 | p = Point(coordinates)
719 | assert p.coordinates == coordinates
720 | assert p._coordinates(dimm=True, dimz=True) == coordinates
721 | assert p._coordinates(dimm=False) == (1, 2, 3)
722 | assert p._coordinates(dimz=False) == (1, 2, 4)
723 | assert p._coordinates(dimm=False, dimz=False) == (1, 2)
724 |
725 | assert p._coordinates(dimm=True, dimz=True, tpl=False) == list(coordinates)
726 | assert p._coordinates(dimm=False, tpl=False) == [1, 2, 3]
727 | assert p._coordinates(dimz=False, tpl=False) == [1, 2, 4]
728 | assert p._coordinates(dimm=False, dimz=False, tpl=False) == [1, 2]
729 |
730 | def test_linestring_coordinates():
731 | """
732 | get coordinates of a LineString
733 | """
734 | coordinates = [(1, 2, 3, 4), (6, 7, 8, 9)]
735 | ls = LineString(coordinates)
736 | assert ls.coordinates == coordinates
737 | assert ls._coordinates(dimm=True, dimz=True) == coordinates
738 | assert ls._coordinates(dimm=False) == [(1, 2, 3), (6, 7, 8)]
739 | assert ls._coordinates(dimz=False) == [(1, 2, 4), (6, 7, 9)]
740 | assert ls._coordinates(dimz=False, dimm=False) == [(1, 2), (6, 7)]
741 |
742 | assert ls._coordinates(dimm=True, dimz=True, tpl=False) == [[1, 2, 3, 4], [6, 7, 8, 9]]
743 | assert ls._coordinates(dimm=False, tpl=False) == [[1, 2, 3], [6, 7, 8]]
744 | assert ls._coordinates(dimz=False, tpl=False) == [[1, 2, 4], [6, 7, 9]]
745 | assert ls._coordinates(dimz=False, dimm=False, tpl=False) == [[1, 2], [6, 7]]
746 |
747 | def test_linestring_coordinates_setm():
748 | """
749 | set m on a a LineString
750 | """
751 | coordinates = [(1, 2, 3), (6, 7, 8)]
752 | ls = LineString(coordinates)
753 | ls.dimm = True
754 | assert ls._coordinates(tpl=False) == [[1, 2, 3, 0], [6, 7, 8, 0]]
755 |
756 | def test_polygon_coordinates():
757 | """
758 | get coordinates of a Polygon
759 | """
760 | coordinates = [[(1, 2, 3, 4), (6, 7, 8, 9), (10, 11, 12, 13), (1, 2, 3, 4)]]
761 | p = Polygon(coordinates)
762 | assert p.coordinates == coordinates
763 | assert p._coordinates(dimm=True, dimz=True) == coordinates
764 |
765 | coordinates = [list(map(list, sub)) for sub in coordinates]
766 | assert p._coordinates(dimm=True, dimz=True, tpl=False) == coordinates
767 |
768 | def test_polygon_coordinates_setm():
769 | """
770 | set m on a Polygon
771 | """
772 | coordinates = [[(1, 2, 3), (6, 7, 8), (10, 11, 12), (1, 2, 3)]]
773 | p = Polygon(coordinates)
774 | p.dimm = True
775 | assert p._coordinates(tpl=True) == [[(1, 2, 3, 0), (6, 7, 8, 0), (10, 11, 12, 0), (1, 2, 3, 0)]]
776 | p.dimm = True
777 | assert p._coordinates(tpl=True) == [[(1, 2, 3, 0), (6, 7, 8, 0), (10, 11, 12, 0), (1, 2, 3, 0)]]
778 |
779 | def test_multipoint_coordinates():
780 | """
781 | get coordinates of a MultiPoint
782 | """
783 | coordinates = [ (170.0, 45.0), (180.0, 45.0), (-100.0, 45.0), (-170.0, 45.0) ]
784 | mp = MultiPoint([Point(p) for p in coordinates])
785 | assert mp.coordinates == coordinates
786 |
787 | def test_multilinestring_coordinates():
788 | """
789 | get coordinates of a MultiLineString
790 | """
791 | coordinates = [ [ (170.0, 45.0), (180.0, 45.0) ], [ (-180.0, 45.0), (-170.0, 45.0) ] ]
792 | ml = MultiLineString([LineString(c) for c in coordinates])
793 | assert ml.coordinates == coordinates
794 |
795 | def test_multipolygon_coordinates():
796 | """
797 | get coordinates of a MultiPolygon
798 | """
799 | coordinates = [ [ [ (180.0, 40.0), (180.0, 50.0), (170.0, 50.0), (170.0, 40.0), (180.0, 40.0) ] ], [ [ (-170.0, 40.0), (-170.0, 50.0), (-180.0, 50.0), (-180.0, 40.0), (-170.0, 40.0) ] ] ]
800 | mp = MultiPolygon([Polygon(c) for c in coordinates])
801 | assert mp.coordinates == coordinates
802 |
803 | def test_point_copy():
804 | """
805 | copy a Point object
806 | """
807 | p1 = Point((0, 1, 2), srid=4326, dimm=True)
808 | p2 = copy(p1)
809 | p3 = copy(p1)
810 |
811 | assert p1.coordinates == p2.coordinates
812 | assert p1.srid == p2.srid
813 | assert p1.wkb == p2.wkb
814 | assert p1 != p2
815 | assert p1 != p3
816 |
817 | p1.x = 10
818 | assert p1.x != p2.x
819 | assert p2.x == p3.x
820 |
821 | def test_multipoint_create():
822 | """
823 | create a MultiPoint object
824 | """
825 | p1 = Point((0, 1, 2))
826 | p2 = Point((3, 4, 5))
827 | p3 = Point((6, 7, 8))
828 | mp = MultiPoint([p1, p2, p3], srid=4326)
829 |
830 | p1.x = 100
831 | assert mp.geometries[0].x == p1.x
832 | assert p1.x == 100
833 |
834 | def test_multipoint_copy():
835 | """
836 | copy a MultiPoint object
837 | """
838 | p1 = Point((0, 1, 2))
839 | p2 = Point((3, 4, 5))
840 | p3 = Point((6, 7, 8))
841 | mp1 = MultiPoint([p1, p2, p3], srid=4326)
842 | mp2 = copy(mp1)
843 |
844 | assert mp1.coordinates == mp2.coordinates
845 |
846 | def test_geometrycollection_create():
847 | """
848 | create a GeometryCollection object
849 | """
850 | pt = Point((0, 1, 2))
851 | ls = LineString([(3, 4, 5), (9, 10, 11)])
852 | pl = Polygon([[(1, 2, 3), (6, 7, 8), (10, 11, 12), (1, 2, 3)]])
853 | gc1 = GeometryCollection([pt, ls, pl])
854 | gc2 = copy(gc1)
855 | for i, _ in enumerate(gc1):
856 | assert gc1[i] == gc2[i]
857 | pt.x = 100
858 | assert gc1.coordinates == gc2.coordinates
859 |
860 | gc1.geometries[0].x = 200
861 | assert gc1.coordinates == gc2.coordinates
862 |
863 | def test_geometrycollection_copy():
864 | """
865 | modify a GeometryCollection object
866 | """
867 | pt = Point((0, 1, 2))
868 | ls = LineString([(3, 4, 5), (9, 10, 11)])
869 | pl = Polygon([[(1, 2, 3), (6, 7, 8), (10, 11, 12), (1, 2, 3)]])
870 | gc1 = GeometryCollection([pt, ls, pl])
871 | gc1.wkb
872 | gc2 = copy(gc1)
873 | assert gc1.wkb == gc2.wkb
874 |
875 | gc2.geometries[0].x = 123
876 | assert gc1.coordinates == gc2.coordinates
877 | assert gc2.geometries[0].x == 123
878 | assert gc1.wkb == gc2.wkb
879 |
880 | pt = Point((-1, -5, -1))
881 | gc2[1] = pt
882 | assert gc1.coordinates != gc2.coordinates
883 | assert gc2.geometries[1].x == -1
884 | assert gc2.geometries[1].y == -5
885 | assert gc1.wkb != gc2.wkb
886 |
887 | def test_geometrycollection_deepcopy():
888 | """
889 | modify a GeometryCollection object
890 | """
891 | pt = Point((0, 1, 2))
892 | ls = LineString([(3, 4, 5), (9, 10, 11)])
893 | pl = Polygon([[(1, 2, 3), (6, 7, 8), (10, 11, 12), (1, 2, 3)]])
894 | gc1 = GeometryCollection([pt, ls, pl])
895 | gc1.wkb
896 | gc2 = deepcopy(gc1)
897 | assert gc1.wkb == gc2.wkb
898 | geoms = zip(gc1.geometries, gc2.geometries)
899 | for a, b in geoms:
900 | assert a.wkb == b.wkb
901 | assert a is not b
902 |
903 | pt = Point((-1, -5, -1))
904 | gc2[1] = pt
905 | assert gc1.coordinates != gc2.coordinates
906 |
907 | gc1.geometries[0].x = 0.5
908 | assert gc1.geometries[0].x == 0.5
909 | assert gc2.geometries[0].x == 0
910 | assert gc1[0].wkb != gc2[0].wkb
911 |
912 | def test_geometrycollection_edit_wkb():
913 | """
914 | Editing object changes WKB
915 | """
916 | pt = Point((0, 0))
917 | gc = GeometryCollection([pt])
918 | wkb1 = gc.wkb
919 |
920 | pt.x = 1
921 | wkb2 = gc.wkb
922 |
923 | assert wkb1 != wkb2
924 |
925 | def test_geometrycollection_index():
926 | """
927 | Overloaded [] access correct item in multigeometry
928 | """
929 | pt = Point((0, 0))
930 | gc = GeometryCollection([pt])
931 |
932 | assert gc.geometries[0] == gc[0]
933 |
934 | def test_multigeometry_add():
935 | """
936 | Add operator for multigeometries
937 | """
938 | p1 = Point((1, 1, 1))
939 | p2 = Point((2, 2, 2))
940 | p3 = Point((3, 3, 3))
941 | p4 = Point((4, 4, 4))
942 |
943 | mp1 = MultiPoint([p1, p2])
944 | assert len(mp1) == 2
945 |
946 | mpX = mp1 + p1
947 | assert len(mp1) == 2
948 | assert len(mpX) == 3
949 | assert type(mpX) == MultiPoint
950 |
951 | ls = LineString([(3, 4, 5), (9, 10, 11)])
952 |
953 | mg = mp1 + ls
954 | assert len(mg) == 3
955 | assert type(mg) == GeometryCollection
956 |
957 | mp2 = MultiPoint([p3, p4])
958 | mp3 = mp1 + mp2
959 | assert mp3[0].x == 1
960 | assert mp3[1].x == 2
961 | assert mp3[2].x == 3
962 | assert mp3[3].x == 4
963 | assert len(mp3) == 4
964 | assert type(mp3) == MultiPoint
965 |
966 | def test_multigeometry_iadd():
967 | p1 = Point((1, 1, 1))
968 | p2 = Point((2, 2, 2))
969 | p3 = Point((3, 3, 3))
970 | p4 = Point((4, 4, 4))
971 | p5 = Point((5, 5, 5))
972 |
973 | mp1 = MultiPoint([p1, p2])
974 | mp1 += p3
975 | assert len(mp1) == 3
976 | assert type(mp1) == MultiPoint
977 |
978 | mp2 = MultiPoint([p4, p5])
979 | mp1 += mp2
980 | assert len(mp1) == 5
981 | assert type(mp1) == MultiPoint
982 |
983 | ls = LineString([(3, 4, 5), (9, 10, 11)])
984 | with pytest.raises(CollectionError):
985 | mp1 += ls
986 |
987 | gc = GeometryCollection([p1, ls])
988 | gc += p2
989 | assert len(gc) == 3
990 | assert type(gc) == GeometryCollection
991 |
992 | gc += mp2
993 | assert len(gc) == 5
994 | assert type(gc) == GeometryCollection
995 |
996 | def test_multigeometry_iadd_mixed_srid():
997 | p1 = Point((1, 1, 1), srid=1000)
998 | p2 = Point((2, 2, 2), srid=2000)
999 |
1000 | with pytest.raises(CollectionError):
1001 | p1 + p2
1002 |
1003 | def test_geometry_add():
1004 | p1 = Point((1, 1, 1))
1005 | p2 = Point((2, 2, 2))
1006 | p3 = Point((3, 3, 3))
1007 |
1008 | mp1 = p1 + p2
1009 | assert type(mp1) == MultiPoint
1010 | assert len(mp1) == 2
1011 | assert mp1[0].x == 1
1012 | assert mp1[0].y == 1
1013 | assert mp1[0].z == 1
1014 | assert mp1[1].x == 2
1015 | assert mp1[1].y == 2
1016 | assert mp1[1].z == 2
1017 |
1018 | mp2 = MultiPoint([p2, p3])
1019 |
1020 | mp3 = p1 + mp2
1021 | assert type(mp3) == MultiPoint
1022 | assert len(mp3) == 3
1023 |
1024 | mp4 = mp2 + p1
1025 | assert type(mp4) == MultiPoint
1026 | assert len(mp4) == 3
1027 |
1028 | def test_geometry_add_ls():
1029 | ls1 = LineString([(3, 4, 5), (9, 10, 11)])
1030 | ls2 = LineString([(9, 0, 0), (1, 1, 1)])
1031 |
1032 | mls = ls1 + ls2
1033 | assert len(mls) == 2
1034 | assert type(mls) == MultiLineString
1035 |
1036 | def test_geometry_add_pg():
1037 | geojson_pg = {"type":"Polygon","coordinates":[[[100,0],[101.0,0.0],[101.0,1.0],[100.0,1.0],[100.0,0.0]]]}
1038 | pg1 = Geometry.from_geojson(geojson_pg, srid=4326)
1039 | pg2 = Geometry(wkb_pg)
1040 | pg2.srid = 4326
1041 |
1042 | mpg = pg1 + pg2
1043 | assert len(mpg) == 2
1044 | assert type(mpg) == MultiPolygon
1045 |
1046 | def test_geometry_add_gc():
1047 | p1 = Point((1, 1, 1))
1048 | p2 = Point((2, 2, 2))
1049 | ls = LineString([(3, 4, 5), (9, 10, 11)])
1050 | mg1 = p1 + ls
1051 | assert len(mg1) == 2
1052 | assert type(mg1) == GeometryCollection
1053 |
1054 | mg2 = mg1 + p2
1055 | assert len(mg2) == 3
1056 | assert type(mg2) == GeometryCollection
1057 |
1058 | mg3 = p2 + mg1
1059 | assert len(mg3) == 3
1060 | assert type(mg3) == GeometryCollection
1061 |
1062 | def test_geometry_add_srid():
1063 | p1 = Point((1, 1, 1), srid=4326)
1064 | p2 = Point((2, 2, 2), srid=1234)
1065 |
1066 | with pytest.raises(CollectionError):
1067 | mp = p1 + p2
1068 |
1069 | p2.srid = 4326
1070 | mp = p1 + p2
1071 | assert len(mp) == 2
1072 |
1073 | def test_geometry_add_srid():
1074 | p1 = Point((0, 0), 4326)
1075 | p2 = Point((1, 1), 4326)
1076 | p3 = Point((2, 2), 1234)
1077 | p4 = Point((3, 3), 1234)
1078 |
1079 | mp1 = p1 + p2
1080 | assert mp1.srid == 4326
1081 | mp2 = p3 + p4
1082 | assert mp2.srid == 1234
1083 |
1084 | with pytest.raises(CollectionError):
1085 | mp3 = mp1 + mp2
1086 |
1087 | mp1.srid = 3857
1088 | mp2.srid = 3857
1089 | mp3 = mp1 + mp2
1090 | assert mp3.srid == 3857
1091 | assert len(mp3) == 4
1092 |
1093 | def test_geometry_add_srid():
1094 | p1 = Point((0, 0))
1095 | p2 = Point((1, 1), 4326)
1096 | p3 = Point((2, 2))
1097 | p4 = Point((3, 3), 1234)
1098 |
1099 | mp1 = MultiPoint([p1], 4326)
1100 | mp1 += p2
1101 | assert mp1.srid == 4326
1102 |
1103 | with pytest.raises(CollectionError):
1104 | mp1 += p4
1105 |
1106 | mp2 = MultiPoint([p3], 1234)
1107 | mp2 += p4
1108 | assert mp2.srid == 1234
1109 |
1110 | with pytest.raises(CollectionError):
1111 | mp3 = mp1 + mp2
1112 | assert len(mp3) == 3
1113 |
1114 | def test_polygon_change_srid():
1115 | p = Geometry(wkb_pg)
1116 | p.dimz = True
1117 | assert p.dimz is True
1118 | for ring in p.rings:
1119 | for v in ring.vertices:
1120 | assert v.dimz is True
1121 | p.dimz = False
1122 | assert p.dimz is False
1123 | for ring in p.rings:
1124 | for v in ring.vertices:
1125 | assert v.dimz is False
1126 |
1127 | def test_multigeometry_getset():
1128 | p0 = Point((0, 0))
1129 | p1 = Point((1, 1))
1130 | p2 = Point((2, 2))
1131 | mp = MultiPoint([p0, p1])
1132 | assert len(mp) == 2
1133 | assert mp[0].x == 0
1134 | assert mp[0].y == 0
1135 | assert mp[1].x == 1
1136 | assert mp[1].y == 1
1137 |
1138 | mp[0] = p2
1139 | assert mp[0].x == 2
1140 | assert mp[0].y == 2
1141 | assert mp[1].x == 1
1142 | assert mp[1].y == 1
1143 |
1144 | ls = LineString([(3, 4), (9, 10)])
1145 | with pytest.raises(CollectionError):
1146 | mp[0] = ls
1147 |
1148 | gc = GeometryCollection([ls, p0])
1149 | with pytest.raises(CollectionError):
1150 | mp[0] = gc
1151 | gc[0] = mp
1152 | assert type(gc[0]) == MultiPoint
1153 |
1154 | def test_interior_ring():
1155 | p = Geometry.from_geojson(geojson_pg_ring)
1156 | exterior = p.exterior
1157 | interior = p.interior
1158 |
1159 | assert type(exterior) == LineString
1160 | assert len(interior) == 1
1161 |
1162 | def test_wkt_read_point():
1163 | p = Geometry.from_wkt("POINT Z (0 1 1)")
1164 | assert p.type == "Point"
1165 | assert p.x == 0
1166 | assert p.dimz is True
1167 | assert p.dimm is False
1168 |
1169 | def test_wkt_read_linestring():
1170 | l = Geometry.from_wkt("LINESTRING (30 10, 10 30.5, 40 40) ")
1171 | assert l.type == "LineString"
1172 | assert l.vertices[0].x == 30
1173 | assert l.vertices[0].y == 10
1174 | assert l.vertices[1].x == 10
1175 | assert l.vertices[1].y == 30.5
1176 | assert l.dimz is False
1177 | assert l.dimm is False
1178 |
1179 | def test_wkt_read_polygon():
1180 | p = Geometry.from_wkt("POLYGON ((99 0, 1 0, 1 1, 0 1, 0 0))")
1181 | assert p.type == "Polygon"
1182 | assert p.exterior.type == "LineString"
1183 | assert len(p.exterior.vertices) == 5
1184 | assert p.exterior.vertices[0].x == 99
1185 | assert p.interior == []
1186 | assert p.dimz is False
1187 | assert p.dimm is False
1188 |
1189 | def test_wkt_read_polygon_interior():
1190 | p = Geometry.from_wkt("POLYGON M ((7.5 1 9, 4 0 -1, 4 4 44, 0 4 0.5, 0 0 1), (1 1 -9, 1 1 2, 2 2 2, 0 2 1, 0.5 1 1))")
1191 | assert p.type == "Polygon"
1192 | assert p.exterior.type == "LineString"
1193 | assert len(p.exterior.vertices) == 5
1194 | assert p.exterior.vertices[0].x == 7.5
1195 | assert p.exterior.vertices[0].m == 9
1196 | assert len(p.interior) == 1
1197 | assert len(p.interior[0].vertices) == 5
1198 | assert p.dimz is False
1199 | assert p.dimm is True
1200 |
1201 | def test_wkt_read_multipoint():
1202 | mp = Geometry.from_wkt("MULTIPOINT ((0 1), (2 3))")
1203 | assert mp.type == "MultiPoint"
1204 | assert len(mp) == 2
1205 | assert mp[0].x == 0
1206 | assert mp[0].y == 1
1207 | assert mp[1].x == 2
1208 | assert mp[1].y == 3
1209 | assert mp.dimz is False
1210 | assert mp.dimm is False
1211 |
1212 | def test_wkt_read_multilinestring():
1213 | ml = Geometry.from_wkt("MULTILINESTRING ((0 0, 1 1), (2 2, 3 3))")
1214 | assert ml.type == "MultiLineString"
1215 | assert len(ml) == 2
1216 | assert str(ml[0].wkb) == "01020000000200000000000000000000000000000000000000000000000000f03f000000000000f03f"
1217 | assert ml.dimz is False
1218 | assert ml.dimm is False
1219 |
1220 | def test_wkt_read_multipolygon():
1221 | mp = Geometry.from_wkt("MULTIPOLYGON (((1 1, 1 3, 3 3, 3 1, 1 1)), ((4 3, 6 3, 6 1, 4 1, 4 3))) ")
1222 | assert mp.type == "MultiPolygon"
1223 | assert len(mp) == 2
1224 | assert str(mp[0].wkb) == "01030000000100000005000000000000000000f03f000000000000f03f000000000000f03f0000000000000840000000000000084000000000000008400000000000000840000000000000f03f000000000000f03f000000000000f03f"
1225 | assert mp.dimz is False
1226 | assert mp.dimm is False
1227 |
1228 | def test_wkt_read_collection():
1229 | mp = Geometry.from_wkt("GEOMETRYCOLLECTION (MULTIPOINT((0 0), (1 1)), POINT(3 4), LINESTRING(2 3, 3 4))")
1230 | assert mp.type == "GeometryCollection"
1231 | assert len(mp) == 3
1232 | assert mp[0].type == "MultiPoint"
1233 | assert mp[1].type == "Point"
1234 | assert mp[2].type == "LineString"
1235 | assert mp[1].x == 3
1236 | assert mp[1].y == 4
1237 | assert str(mp[0].wkb) == "0104000000020000000101000000000000000000000000000000000000000101000000000000000000f03f000000000000f03f"
1238 | assert mp.dimz is False
1239 | assert mp.dimm is False
1240 |
1241 | def test_wkt_read_collection_dimensions():
1242 | with pytest.raises(DimensionalityError):
1243 | Geometry.from_wkt("GEOMETRYCOLLECTION (MULTIPOINT((0 0), (1 1)), POINT M (3 4 1), LINESTRING(2 3, 3 4))")
1244 |
1245 | def test_ewkt_read_point():
1246 | p = Geometry.from_wkt("SRID=4326;POINT Z (0 1 1)")
1247 | assert p.type == "Point"
1248 | assert p.srid == 4326
1249 | assert p.x == 0
1250 | assert p.dimz is True
1251 | assert p.dimm is False
1252 |
1253 | def test_ewkt_read_collection():
1254 | mp = Geometry.from_wkt("SRID=4326;GEOMETRYCOLLECTION (MULTIPOINT((0 0), (1 1)), POINT(3 4), LINESTRING(2 3, 3 4))")
1255 | assert mp.type == "GeometryCollection"
1256 | assert mp.srid == 4326
1257 | assert len(mp) == 3
1258 | assert mp[0].type == "MultiPoint"
1259 | assert mp[1].type == "Point"
1260 | assert mp[2].type == "LineString"
1261 | assert mp[1].x == 3
1262 | assert mp[1].y == 4
1263 | assert str(mp[0].wkb) == "0104000000020000000101000000000000000000000000000000000000000101000000000000000000f03f000000000000f03f"
1264 | assert mp.dimz is False
1265 | assert mp.dimm is False
1266 |
1267 | def test_ewkt_read_point():
1268 | with pytest.raises(WktError):
1269 | Geometry.from_wkt("SRID=hello;POINT Z (0 1 1)")
1270 |
1271 | def test_ewkt_read_empty():
1272 | with pytest.raises(WktError):
1273 | Geometry.from_wkt("POINT Z EMPTY")
1274 | with pytest.raises(WktError):
1275 | Geometry.from_wkt("LINESTRING ZM EMPTY")
1276 | with pytest.raises(WktError):
1277 | Geometry.from_wkt("POLYGON EMPTY")
1278 |
1279 | mg = Geometry.from_wkt("MULTIPOINT EMPTY")
1280 | assert mg.type == "MultiPoint"
1281 | assert len(mg) == 0
1282 |
1283 | mg = Geometry.from_wkt("MULTILINESTRING EMPTY")
1284 | assert mg.type == "MultiLineString"
1285 | assert len(mg) == 0
1286 |
1287 | mg = Geometry.from_wkt("MULTIPOLYGON EMPTY")
1288 | assert mg.type == "MultiPolygon"
1289 | assert len(mg) == 0
1290 |
1291 | mg = Geometry.from_wkt("GEOMETRYCOLLECTION EMPTY")
1292 | assert mg.type == "GeometryCollection"
1293 | assert len(mg) == 0
1294 |
1295 | def test_read_wkt_malformed():
1296 | with pytest.raises(WktError):
1297 | Geometry.from_wkt("POINT(0 1 1)")
1298 |
1299 | with pytest.raises(WktError):
1300 | Geometry.from_wkt("POINT ZMX (0 1 1)")
1301 |
1302 | with pytest.raises(WktError):
1303 | Geometry.from_wkt("POINT EMPTY")
1304 |
1305 | with pytest.raises(WktError):
1306 | Geometry.from_wkt("HELLO")
1307 |
1308 | with pytest.raises(WktError):
1309 | Geometry.from_wkt("LINESTRING (0 0)")
1310 |
1311 | with pytest.raises(WktError):
1312 | Geometry.from_wkt("LINESTRING ((0 0, 1 1))")
1313 |
1314 | with pytest.raises(WktError):
1315 | Geometry.from_wkt("POLYGON (0 1)")
1316 |
1317 | with pytest.raises(WktError):
1318 | Geometry.from_wkt("POLYGON ((0 0, 1 1, 2 2))")
1319 |
1320 | with pytest.raises(WktError):
1321 | Geometry.from_wkt("POLYGON ((0 0, 1 1, 2 2, 3 3), (0 0, 1 1, 2 2)")
1322 |
1323 | with pytest.raises(WktError):
1324 | Geometry.from_wkt("POINT (0 1) extra")
1325 |
1326 | with pytest.raises(WktError):
1327 | Geometry.from_wkt("POINT (0 1")
1328 |
1329 | with pytest.raises(WktError):
1330 | Geometry.from_wkt("POINT (0 )")
1331 |
1332 | def test_wkt_write_empty_collection():
1333 | mp = MultiPoint([])
1334 | assert mp.type == "MultiPoint"
1335 | assert len(mp) == 0
1336 | assert mp.wkt == "MULTIPOINT EMPTY"
1337 |
1338 | def test_wkt_write_point():
1339 | wkt = "SRID=900913;POINT ZM (0 1 2 3)"
1340 | geom = Geometry.from_wkt(wkt)
1341 | assert geom.wkt == wkt.split(";")[1]
1342 | assert geom.ewkt == wkt
1343 |
1344 | def test_wkt_write_linestring():
1345 | wkt = "LINESTRING (0 0, 0 1, 1 2)"
1346 | geom = Geometry.from_wkt(wkt)
1347 | assert geom.wkt == wkt
1348 |
1349 | def test_wkt_write_polygon():
1350 | wkt = "POLYGON ((0 0, 4 0, 4 4, 0 4, 0 0), (1 1, 1 2, 2 2, 2 1, 1 1))"
1351 | geom = Geometry.from_wkt(wkt)
1352 | assert geom.wkt == wkt
1353 |
1354 | def test_wkt_write_multipoint():
1355 | wkt = "MULTIPOINT ((0 0), (1 1))"
1356 | geom = Geometry.from_wkt(wkt)
1357 | assert geom.wkt == wkt
1358 |
1359 | def test_wkt_write_multilinestring():
1360 | wkt = "MULTILINESTRING ((0 0, 1 1), (2 2, 3 3))"
1361 | geom = Geometry.from_wkt(wkt)
1362 | assert geom.wkt == wkt
1363 |
1364 | def test_wkt_write_multipolygon():
1365 | wkt = "MULTIPOLYGON (((1 1, 1 3, 3 3, 3 1, 1 1)), ((4 3, 6 3, 6 1, 4 1, 4 3)), ((1 1, 1 3, 3 3, 3 1, 1 1)), ((0 0, 4 0, 4 4, 0 4, 0 0), (1 1, 1 2, 2 2, 2 1, 1 1)))"
1366 | geom = Geometry.from_wkt(wkt)
1367 | assert geom.wkt == wkt
1368 |
1369 | def test_wkt_write_linestring():
1370 | wkt = "GEOMETRYCOLLECTION (MULTIPOINT ((0 0), (1 1)), POINT (3 4), LINESTRING (2 3, 3 4))"
1371 | geom = Geometry.from_wkt(wkt)
1372 | assert geom.wkt == wkt
1373 |
1374 | def test_wkt_rounding():
1375 | p = Point((1, 1000, 1000.0000, 1.1000))
1376 | assert p.wkt == "POINT ZM (1 1000 1000 1.1)"
1377 |
1378 | def test_wkt_read_srid():
1379 | p = Geometry.from_wkt("POINT (0 1)", srid=1234)
1380 | assert p.type == "Point"
1381 | assert p.srid == 1234
1382 |
1383 | def test_ewkt_read_srid():
1384 | p = Geometry.from_wkt("SRID=4326;POINT (0 1)", srid=1234)
1385 | assert p.type == "Point"
1386 | assert p.srid == 1234
1387 |
1388 | def test_wkt_write_precision():
1389 | p = Point((0.00000000000001, 1000000000000000))
1390 | assert p.wkt == "POINT (0 1000000000000000)"
1391 |
1392 | p = Point((-0.123456789, 0.123456789))
1393 | assert p.wkt == "POINT (-0.123457 0.123457)"
1394 |
1395 | from plpygis import wkt
1396 |
1397 | wkt.PRECISION = 1
1398 | assert p.wkt == "POINT (-0.1 0.1)"
1399 |
1400 | wkt.PRECISION = 6
1401 | assert p.wkt == "POINT (-0.123457 0.123457)"
1402 |
1403 | def test_raise_exception():
1404 | with pytest.raises(PlpygisError):
1405 | geom = Point((0,1))
1406 | raise CoordinateError(geom)
1407 |
1408 | with pytest.raises(PlpygisError):
1409 | raise CollectionError()
1410 |
1411 | with pytest.raises(PlpygisError):
1412 | raise DependencyError("module")
1413 |
1414 | with pytest.raises(PlpygisError):
1415 | raise WkbError()
1416 |
1417 | with pytest.raises(PlpygisError):
1418 | class WktReaderMock:
1419 | pos = 1
1420 | raise WktError(reader=WktReaderMock())
1421 |
1422 | with pytest.raises(PlpygisError):
1423 | raise DimensionalityError()
1424 |
1425 | with pytest.raises(PlpygisError):
1426 | raise SridError()
1427 |
1428 | with pytest.raises(PlpygisError):
1429 | raise GeojsonError()
1430 |
--------------------------------------------------------------------------------