├── .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 | [![Continuous Integration](https://github.com/bosth/plpygis/workflows/tests/badge.svg)](https://github.com/bosth/plpygis/actions?query=workflow%3A%22tests%22) [![Documentation Status](https://readthedocs.org/projects/plpygis/badge/?version=latest)](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 | --------------------------------------------------------------------------------