pygeodetic-engine 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- geodetic_engine/__init__.py +14 -0
- geodetic_engine/_bundled_database.py +418 -0
- geodetic_engine/_environment.py +25 -0
- geodetic_engine/_pins.env +15 -0
- geodetic_engine/_pins.py +97 -0
- geodetic_engine/_referencing/__init__.py +8 -0
- geodetic_engine/_referencing/abridged.py +116 -0
- geodetic_engine/_referencing/axes.py +77 -0
- geodetic_engine/_referencing/database.py +975 -0
- geodetic_engine/_referencing/errors.py +34 -0
- geodetic_engine/_referencing/helmert.py +652 -0
- geodetic_engine/_referencing/osdu_id.py +228 -0
- geodetic_engine/_referencing/projjson.py +94 -0
- geodetic_engine/_search_path.py +140 -0
- geodetic_engine/_sqlite.py +39 -0
- geodetic_engine/bingrid/__init__.py +80 -0
- geodetic_engine/bingrid/conversion.py +457 -0
- geodetic_engine/bingrid/corners.py +278 -0
- geodetic_engine/bingrid/errors.py +66 -0
- geodetic_engine/bingrid/matching.py +238 -0
- geodetic_engine/bingrid/outline.py +111 -0
- geodetic_engine/bingrid/p6.py +403 -0
- geodetic_engine/bingrid/squaring.py +304 -0
- geodetic_engine/errors.py +9 -0
- geodetic_engine/geodesy/__init__.py +117 -0
- geodetic_engine/geodesy/_antimeridian.py +326 -0
- geodetic_engine/geodesy/_crs_tools.py +192 -0
- geodetic_engine/geodesy/_failures.py +355 -0
- geodetic_engine/geodesy/_geojson_objects.py +63 -0
- geodetic_engine/geodesy/_rounding.py +161 -0
- geodetic_engine/geodesy/_segments.py +207 -0
- geodetic_engine/geodesy/_shared.py +68 -0
- geodetic_engine/geodesy/_wkb.py +124 -0
- geodetic_engine/geodesy/crs.py +528 -0
- geodetic_engine/geodesy/database.py +54 -0
- geodetic_engine/geodesy/errors.py +129 -0
- geodetic_engine/geodesy/factors.py +352 -0
- geodetic_engine/geodesy/geometry.py +1894 -0
- geodetic_engine/geodesy/operation.py +1336 -0
- geodetic_engine/geodesy/osdu_id.py +26 -0
- geodetic_engine/geodesy/result.py +411 -0
- geodetic_engine/geodesy/transformation.py +2831 -0
- geodetic_engine/geodesy/utils/__init__.py +28 -0
- geodetic_engine/geojson/__init__.py +38 -0
- geodetic_engine/geojson/_convert.py +1624 -0
- geodetic_engine/geojson/_jsonfg.py +143 -0
- geodetic_engine/geojson/_model.py +1004 -0
- geodetic_engine/geojson/_osdu.py +86 -0
- geodetic_engine/geojson/_scope.py +660 -0
- geodetic_engine/geojson/errors.py +61 -0
- geodetic_engine/georepository/__init__.py +47 -0
- geodetic_engine/georepository/auth.py +131 -0
- geodetic_engine/georepository/cache.py +285 -0
- geodetic_engine/georepository/client.py +458 -0
- geodetic_engine/georepository/config.py +116 -0
- geodetic_engine/georepository/errors.py +28 -0
- geodetic_engine/osdudb/__init__.py +68 -0
- geodetic_engine/osdudb/__main__.py +463 -0
- geodetic_engine/osdudb/_data/CRS_CT_osdu_official_crs_catalog.json +434781 -0
- geodetic_engine/osdudb/_data/CRS_CT_osdu_official_crs_catalog.source.json +9 -0
- geodetic_engine/osdudb/_data/proj_data/proj.db +0 -0
- geodetic_engine/osdudb/bound.py +204 -0
- geodetic_engine/osdudb/build.py +127 -0
- geodetic_engine/osdudb/catalog.py +268 -0
- geodetic_engine/osdudb/config.py +277 -0
- geodetic_engine/osdudb/context.py +311 -0
- geodetic_engine/osdudb/crs.py +477 -0
- geodetic_engine/osdudb/definition.py +582 -0
- geodetic_engine/osdudb/download.py +278 -0
- geodetic_engine/osdudb/errors.py +46 -0
- geodetic_engine/osdudb/identifier.py +305 -0
- geodetic_engine/osdudb/official.py +57 -0
- geodetic_engine/osdudb/operation.py +272 -0
- geodetic_engine/osdudb/persistable_reference.py +387 -0
- geodetic_engine/osdudb/translate.py +238 -0
- geodetic_engine/persistablereference/__init__.py +116 -0
- geodetic_engine/persistablereference/emit.py +367 -0
- geodetic_engine/persistablereference/envelope.py +393 -0
- geodetic_engine/persistablereference/errors.py +56 -0
- geodetic_engine/persistablereference/esriwkt.py +409 -0
- geodetic_engine/persistablereference/methods.py +399 -0
- geodetic_engine/persistablereference/reference.py +1138 -0
- geodetic_engine/persistablereference/stored.py +813 -0
- geodetic_engine/projdb/__init__.py +40 -0
- geodetic_engine/projdb/__main__.py +243 -0
- geodetic_engine/projdb/_validate_worker.py +32 -0
- geodetic_engine/projdb/alias.py +112 -0
- geodetic_engine/projdb/annotate.py +110 -0
- geodetic_engine/projdb/authority.py +162 -0
- geodetic_engine/projdb/bound.py +232 -0
- geodetic_engine/projdb/build.py +325 -0
- geodetic_engine/projdb/cli.py +110 -0
- geodetic_engine/projdb/common.py +316 -0
- geodetic_engine/projdb/config.py +349 -0
- geodetic_engine/projdb/context.py +278 -0
- geodetic_engine/projdb/coordinate_system.py +117 -0
- geodetic_engine/projdb/crs.py +378 -0
- geodetic_engine/projdb/datum.py +264 -0
- geodetic_engine/projdb/errors.py +48 -0
- geodetic_engine/projdb/operation.py +327 -0
- geodetic_engine/projdb/parameters.py +219 -0
- geodetic_engine/projdb/records.py +136 -0
- geodetic_engine/projdb/report.py +385 -0
- geodetic_engine/projdb/schema.py +615 -0
- geodetic_engine/projdb/settings.py +427 -0
- geodetic_engine/projdb/translate.py +166 -0
- geodetic_engine/projdb/validate.py +462 -0
- geodetic_engine/projdb/writer.py +1012 -0
- geodetic_engine/py.typed +0 -0
- geodetic_engine/units/__init__.py +44 -0
- geodetic_engine/units/errors.py +11 -0
- geodetic_engine/units/unit.py +189 -0
- geodetic_engine/welltrajectory/README.md +336 -0
- geodetic_engine/welltrajectory/__init__.py +82 -0
- geodetic_engine/welltrajectory/csv_parser.py +648 -0
- geodetic_engine/welltrajectory/datamodels/__init__.py +17 -0
- geodetic_engine/welltrajectory/datamodels/trajectory_input.py +875 -0
- geodetic_engine/welltrajectory/datamodels/well_trajectory.py +283 -0
- geodetic_engine/welltrajectory/errors.py +42 -0
- geodetic_engine/welltrajectory/example_data/synthetic_well.csv +135 -0
- geodetic_engine/welltrajectory/example_data/volve_f1_survey.txt +145 -0
- geodetic_engine/welltrajectory/methods/__init__.py +71 -0
- geodetic_engine/welltrajectory/methods/azimuthal_equidistant.py +79 -0
- geodetic_engine/welltrajectory/methods/base.py +124 -0
- geodetic_engine/welltrajectory/methods/enu.py +50 -0
- geodetic_engine/welltrajectory/methods/grid_north_local.py +87 -0
- geodetic_engine/welltrajectory/methods/lmp.py +106 -0
- geodetic_engine/welltrajectory/minimum_curvature.py +309 -0
- geodetic_engine/welltrajectory/plot.py +557 -0
- geodetic_engine/welltrajectory/survey.py +177 -0
- geodetic_engine/welltrajectory/trajectory.py +157 -0
- pygeodetic_engine-0.2.0.dist-info/METADATA +410 -0
- pygeodetic_engine-0.2.0.dist-info/RECORD +137 -0
- pygeodetic_engine-0.2.0.dist-info/WHEEL +4 -0
- pygeodetic_engine-0.2.0.dist-info/entry_points.txt +3 -0
- pygeodetic_engine-0.2.0.dist-info/licenses/LICENSE +201 -0
- pygeodetic_engine-0.2.0.dist-info/licenses/NOTICE +53 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Geodetic transformation engine built on PROJ."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version as _version
|
|
4
|
+
|
|
5
|
+
from geodetic_engine._bundled_database import activate_at_import as _activate
|
|
6
|
+
from geodetic_engine._environment import require_pinned_proj as _require_pinned_proj
|
|
7
|
+
|
|
8
|
+
_require_pinned_proj()
|
|
9
|
+
# Before any other code of this package's touches PROJ's search path, so that a
|
|
10
|
+
# set_data_dir() call of the caller's own is still recognised as one.
|
|
11
|
+
_activate()
|
|
12
|
+
|
|
13
|
+
# The PyPI name, which differs from the import name.
|
|
14
|
+
__version__ = _version("pygeodetic-engine")
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
"""Make the OSDU database shipped with this package the one PROJ reads.
|
|
2
|
+
|
|
3
|
+
A release wheel ships ``geodetic_engine/osdudb/_data/proj_data/proj.db``: the
|
|
4
|
+
proj.db of the pinned pyproj wheel with the official OSDU catalogue built into
|
|
5
|
+
it. When ``geodetic_engine`` is first imported, the first of these rules that
|
|
6
|
+
matches decides whether PROJ reads it:
|
|
7
|
+
|
|
8
|
+
a. ``GEODETIC_ENGINE_OSDU_DB=0``: nothing is changed.
|
|
9
|
+
b. ``pyproj.datadir.set_data_dir()`` or ``append_data_dir()`` was called
|
|
10
|
+
before the import: nothing is changed.
|
|
11
|
+
c. ``PROJ_DATA``, or ``PROJ_LIB`` when ``PROJ_DATA`` is unset, names a
|
|
12
|
+
directory that holds a ``proj.db``: PROJ's search path becomes the
|
|
13
|
+
variable's directories, the first that holds a ``proj.db`` first. That is
|
|
14
|
+
pyproj's search path already when pyproj read the variable on import and
|
|
15
|
+
its first directory holds the ``proj.db``, and nothing is changed. Any
|
|
16
|
+
other search path, say for a value set after pyproj was imported, is
|
|
17
|
+
replaced, with a warning; but if the variable's ``proj.db`` records no
|
|
18
|
+
database layout, another major one, or an older minor one than the
|
|
19
|
+
``proj.db`` PROJ reads, which PROJ would refuse, nothing is changed, with a
|
|
20
|
+
warning.
|
|
21
|
+
d. Otherwise PROJ's search path becomes the bundled database's directory, then
|
|
22
|
+
the ``PROJ_DATA``/``PROJ_LIB`` directories, then pyproj's own data
|
|
23
|
+
directory, so grids and ``proj.ini`` are still found where they were.
|
|
24
|
+
|
|
25
|
+
That is the precedence of this project's pyproj wheels (``set_data_dir``, then
|
|
26
|
+
a ``PROJ_DATA`` holding a database, then the database the wheel bundles) with
|
|
27
|
+
the OSDU database in place of the wheel's, applied to the environment as it is
|
|
28
|
+
on import: the environment a child process starts with. No file is written and
|
|
29
|
+
the environment is left alone, so a child process that imports the package
|
|
30
|
+
applies the same rules.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import logging
|
|
36
|
+
import os
|
|
37
|
+
import sqlite3
|
|
38
|
+
from contextlib import closing, suppress
|
|
39
|
+
from dataclasses import dataclass
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
from typing import Final
|
|
42
|
+
|
|
43
|
+
from pyproj import datadir
|
|
44
|
+
from pyproj.exceptions import DataDirError
|
|
45
|
+
|
|
46
|
+
from geodetic_engine import _search_path
|
|
47
|
+
from geodetic_engine._sqlite import read_only
|
|
48
|
+
|
|
49
|
+
logger = logging.getLogger(__name__)
|
|
50
|
+
|
|
51
|
+
ENVIRONMENT_VARIABLE: Final = "GEODETIC_ENGINE_OSDU_DB"
|
|
52
|
+
|
|
53
|
+
# A directory of its own: PROJ reads the file called proj.db in each directory
|
|
54
|
+
# of its search path, so nothing else may be called that here.
|
|
55
|
+
BUNDLED_DIRECTORY: Final = (
|
|
56
|
+
Path(__file__).resolve().parent / "osdudb" / "_data" / "proj_data"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
_DATABASE: Final = "proj.db"
|
|
60
|
+
_LAYOUT: Final = ("DATABASE.LAYOUT.VERSION.MAJOR", "DATABASE.LAYOUT.VERSION.MINOR")
|
|
61
|
+
_OFF: Final = frozenset({"0", "false", "no", "off"})
|
|
62
|
+
_ON: Final = frozenset({"", "1", "true", "yes", "on"})
|
|
63
|
+
_CHANGED: Final = (
|
|
64
|
+
"pyproj.datadir.set_data_dir() changed PROJ's search path after "
|
|
65
|
+
"geodetic_engine chose the database"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True, slots=True)
|
|
70
|
+
class ActiveDatabase:
|
|
71
|
+
"""The ``proj.db`` PROJ reads, and why it is that one.
|
|
72
|
+
|
|
73
|
+
Attributes:
|
|
74
|
+
path: The database PROJ reads, the ``proj.db`` in the first directory
|
|
75
|
+
of its search path. None when that directory holds none.
|
|
76
|
+
bundled: Whether it is the OSDU database shipped with this package.
|
|
77
|
+
reason: Why it is in use, or why the bundled database is not.
|
|
78
|
+
search_path: Every directory PROJ searches, in order, for the
|
|
79
|
+
database, grids and ``proj.ini``.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
path: Path | None
|
|
83
|
+
bundled: bool
|
|
84
|
+
reason: str
|
|
85
|
+
search_path: tuple[Path, ...]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True, slots=True)
|
|
89
|
+
class _Choice:
|
|
90
|
+
reason: str
|
|
91
|
+
# PROJ's search path as the choice left it; a different one now means the
|
|
92
|
+
# caller has set another since.
|
|
93
|
+
seen: str | None
|
|
94
|
+
# The search path before the bundled database was put first, to go back
|
|
95
|
+
# to when it is switched off.
|
|
96
|
+
restore: str | None = None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
_choice: _Choice | None = None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def activate_at_import() -> None:
|
|
103
|
+
"""Apply rules a to d, once, when ``geodetic_engine`` is first imported."""
|
|
104
|
+
global _choice
|
|
105
|
+
# Logged once the lock is released; see _search_path.
|
|
106
|
+
warnings: list[str] = []
|
|
107
|
+
with _search_path.lock:
|
|
108
|
+
if _choice is not None:
|
|
109
|
+
return
|
|
110
|
+
reason, search = _decide(warnings)
|
|
111
|
+
before = _data_dir()
|
|
112
|
+
if search is not None:
|
|
113
|
+
_search_path.set_search_path(os.pathsep.join(search))
|
|
114
|
+
_choice = _Choice(
|
|
115
|
+
reason, _data_dir(), restore=before if search is not None else None
|
|
116
|
+
)
|
|
117
|
+
active = _active()
|
|
118
|
+
for warning in warnings:
|
|
119
|
+
logger.warning("%s", warning)
|
|
120
|
+
_log(active)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def active_database() -> ActiveDatabase:
|
|
124
|
+
"""Report the ``proj.db`` PROJ reads and why.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
The database, whether it is the bundled OSDU database, and the reason:
|
|
128
|
+
the rule that chose it on import, a call of
|
|
129
|
+
:func:`use_bundled_database`, or a later
|
|
130
|
+
``pyproj.datadir.set_data_dir()``.
|
|
131
|
+
|
|
132
|
+
Example:
|
|
133
|
+
>>> from geodetic_engine.osdudb import active_database
|
|
134
|
+
>>> active_database().bundled # doctest: +SKIP
|
|
135
|
+
True
|
|
136
|
+
"""
|
|
137
|
+
return _reading()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def use_bundled_database(enabled: bool = True) -> ActiveDatabase:
|
|
141
|
+
"""Switch the OSDU database shipped with this package on or off.
|
|
142
|
+
|
|
143
|
+
Overrides the choice made on import, whatever made it. Switched on, the
|
|
144
|
+
bundled database's directory is put first on PROJ's search path and the
|
|
145
|
+
other directories stay, so grids are still found. Switched off, PROJ's
|
|
146
|
+
search path returns to what it was before the bundled database was put
|
|
147
|
+
first, or else loses the bundled directory.
|
|
148
|
+
|
|
149
|
+
A later ``pyproj.datadir.set_data_dir()`` overrides this in turn. pyproj
|
|
150
|
+
keeps one PROJ context per thread: the calling thread switches at once,
|
|
151
|
+
and another thread the next time it calls :func:`active_database` or
|
|
152
|
+
resolves a CRS, operation or identifier through this package. A thread
|
|
153
|
+
that only calls pyproj directly keeps the database it had.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
enabled: True to read the bundled database, False to stop.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
The database PROJ now reads, as :func:`active_database` reports it.
|
|
160
|
+
|
|
161
|
+
Raises:
|
|
162
|
+
FileNotFoundError: If switching on, and this installation has no
|
|
163
|
+
bundled database; or switching off, and no other directory on
|
|
164
|
+
PROJ's search path holds a ``proj.db``.
|
|
165
|
+
"""
|
|
166
|
+
global _choice
|
|
167
|
+
with _search_path.lock:
|
|
168
|
+
database = _bundled_database()
|
|
169
|
+
current = _data_dir()
|
|
170
|
+
directories = _split(current)
|
|
171
|
+
restore: str | None = None
|
|
172
|
+
if enabled:
|
|
173
|
+
if database is None:
|
|
174
|
+
raise FileNotFoundError(
|
|
175
|
+
f"this installation has no bundled OSDU database at "
|
|
176
|
+
f"{BUNDLED_DIRECTORY / _DATABASE}; the wheels on PyPI ship "
|
|
177
|
+
"one. In a source checkout, run "
|
|
178
|
+
"python scripts/build_bundled_osdudb.py, or build a "
|
|
179
|
+
"database of your own with geodetic-osdudb build --official"
|
|
180
|
+
)
|
|
181
|
+
bundled = str(database.parent)
|
|
182
|
+
if not directories or not _same(directories[0], bundled):
|
|
183
|
+
restore = current
|
|
184
|
+
elif _choice is not None and current == _choice.seen:
|
|
185
|
+
restore = _choice.restore
|
|
186
|
+
_search_path.set_search_path(
|
|
187
|
+
os.pathsep.join(_unique([bundled, *directories]))
|
|
188
|
+
)
|
|
189
|
+
reason = "use_bundled_database() switched it on"
|
|
190
|
+
else:
|
|
191
|
+
if database is not None and any(
|
|
192
|
+
_same(entry, database.parent) for entry in directories
|
|
193
|
+
):
|
|
194
|
+
if (
|
|
195
|
+
_choice is not None
|
|
196
|
+
and _choice.restore is not None
|
|
197
|
+
and current == _choice.seen
|
|
198
|
+
):
|
|
199
|
+
search = _database_first(_split(_choice.restore))
|
|
200
|
+
else:
|
|
201
|
+
search = _database_first(
|
|
202
|
+
[e for e in directories if not _same(e, database.parent)]
|
|
203
|
+
)
|
|
204
|
+
_search_path.set_search_path(os.pathsep.join(search))
|
|
205
|
+
reason = "use_bundled_database(False) switched it off"
|
|
206
|
+
_choice = _Choice(reason, _data_dir(), restore=restore)
|
|
207
|
+
active = _reading()
|
|
208
|
+
_log(active)
|
|
209
|
+
return active
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _reading() -> ActiveDatabase:
|
|
213
|
+
"""Re-point the calling thread's PROJ context, then report what it reads.
|
|
214
|
+
|
|
215
|
+
The thread re-points without the lock, so the report is taken under it
|
|
216
|
+
only once no switch has landed in between.
|
|
217
|
+
"""
|
|
218
|
+
while True:
|
|
219
|
+
with suppress(DataDirError):
|
|
220
|
+
_search_path.database_identity()
|
|
221
|
+
with _search_path.lock:
|
|
222
|
+
if _search_path.followed():
|
|
223
|
+
return _active()
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _decide(warnings: list[str]) -> tuple[str, list[str] | None]:
|
|
227
|
+
"""Why the bundled database is or is not used, and the search path to set.
|
|
228
|
+
|
|
229
|
+
What to warn about is added to ``warnings``, for the caller to log.
|
|
230
|
+
"""
|
|
231
|
+
# Read first: once this package has called set_data_dir() itself, a call
|
|
232
|
+
# of the caller's own can no longer be told apart from it.
|
|
233
|
+
explicit = _user_data_dir()
|
|
234
|
+
raw = os.environ.get(ENVIRONMENT_VARIABLE)
|
|
235
|
+
setting = (raw or "").strip().casefold()
|
|
236
|
+
if setting in _OFF:
|
|
237
|
+
return f"{ENVIRONMENT_VARIABLE}={raw} turns it off", None
|
|
238
|
+
if setting not in _ON:
|
|
239
|
+
warnings.append(
|
|
240
|
+
f"{ENVIRONMENT_VARIABLE}={raw!r} is not a boolean; the bundled OSDU "
|
|
241
|
+
"database stays on by default"
|
|
242
|
+
)
|
|
243
|
+
if explicit is not None:
|
|
244
|
+
return (
|
|
245
|
+
"pyproj.datadir.set_data_dir() or append_data_dir() was called "
|
|
246
|
+
"before geodetic_engine was imported",
|
|
247
|
+
None,
|
|
248
|
+
)
|
|
249
|
+
name, value = _environment_data_dir()
|
|
250
|
+
entries = _split(value)
|
|
251
|
+
for index, entry in enumerate(entries):
|
|
252
|
+
if (Path(entry) / _DATABASE).is_file():
|
|
253
|
+
if len(entries) == 1:
|
|
254
|
+
reason = f"{name}={entry} holds a proj.db"
|
|
255
|
+
else:
|
|
256
|
+
reason = f"{name} lists {entry}, which holds a proj.db"
|
|
257
|
+
# pyproj passes PROJ the proj.db of the first directory only.
|
|
258
|
+
search = [entry, *entries[:index], *entries[index + 1 :]]
|
|
259
|
+
# pyproj keeps the value verbatim when it reads it, and reads it
|
|
260
|
+
# once: a value it has not read, or read with no proj.db first, is
|
|
261
|
+
# applied, so that this process reads what a new one would.
|
|
262
|
+
current = _split(_data_dir())
|
|
263
|
+
if current == search:
|
|
264
|
+
return reason, None
|
|
265
|
+
if (refused := _refusal(name, entry, current)) is not None:
|
|
266
|
+
warnings.append(refused)
|
|
267
|
+
return refused, None
|
|
268
|
+
warnings.append(
|
|
269
|
+
f"PROJ's search path becomes {os.pathsep.join(search)}, from "
|
|
270
|
+
f"{name}; it was {os.pathsep.join(current) or 'unset'}"
|
|
271
|
+
)
|
|
272
|
+
return reason, search
|
|
273
|
+
database = _bundled_database()
|
|
274
|
+
if database is None:
|
|
275
|
+
return (
|
|
276
|
+
f"this installation has no bundled OSDU database at "
|
|
277
|
+
f"{BUNDLED_DIRECTORY / _DATABASE}",
|
|
278
|
+
None,
|
|
279
|
+
)
|
|
280
|
+
search = _unique([str(database.parent), *entries, *_split(_data_dir())])
|
|
281
|
+
if entries:
|
|
282
|
+
return f"it is the default, and {name} names no proj.db", search
|
|
283
|
+
return "it is the default, and nothing chose another database", search
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _active() -> ActiveDatabase:
|
|
287
|
+
current = _data_dir()
|
|
288
|
+
directories = tuple(Path(entry) for entry in _split(current))
|
|
289
|
+
path = None
|
|
290
|
+
if directories and (candidate := directories[0] / _DATABASE).is_file():
|
|
291
|
+
path = candidate.resolve()
|
|
292
|
+
database = _bundled_database()
|
|
293
|
+
bundled = path is not None and database is not None and path == database.resolve()
|
|
294
|
+
if _choice is None:
|
|
295
|
+
reason = "geodetic_engine has not chosen a database"
|
|
296
|
+
elif current != _choice.seen:
|
|
297
|
+
reason = _CHANGED
|
|
298
|
+
else:
|
|
299
|
+
reason = _choice.reason
|
|
300
|
+
return ActiveDatabase(path, bundled, reason, directories)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _log(active: ActiveDatabase) -> None:
|
|
304
|
+
if active.bundled:
|
|
305
|
+
logger.info(
|
|
306
|
+
"PROJ reads the bundled OSDU database %s: %s", active.path, active.reason
|
|
307
|
+
)
|
|
308
|
+
else:
|
|
309
|
+
logger.info(
|
|
310
|
+
"PROJ reads %s, not the bundled OSDU database: %s",
|
|
311
|
+
active.path or "no proj.db",
|
|
312
|
+
active.reason,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _bundled_database() -> Path | None:
|
|
317
|
+
path = BUNDLED_DIRECTORY / _DATABASE
|
|
318
|
+
return path if path.is_file() else None
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _user_data_dir() -> str | None:
|
|
322
|
+
# pyproj keeps the set_data_dir() argument in a private global.
|
|
323
|
+
# tests/test_bundled_database.py fails if a pyproj upgrade renames it.
|
|
324
|
+
value = getattr(datadir, "_USER_PROJ_DATA", None)
|
|
325
|
+
return None if value is None else str(value)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _environment_data_dir() -> tuple[str, str]:
|
|
329
|
+
"""The variable pyproj reads its data directories from, and its value."""
|
|
330
|
+
if "PROJ_DATA" in os.environ:
|
|
331
|
+
return "PROJ_DATA", os.environ["PROJ_DATA"]
|
|
332
|
+
return "PROJ_LIB", os.environ.get("PROJ_LIB", "")
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _data_dir() -> str | None:
|
|
336
|
+
try:
|
|
337
|
+
return datadir.get_data_dir()
|
|
338
|
+
except DataDirError:
|
|
339
|
+
return None
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _split(value: str | None) -> list[str]:
|
|
343
|
+
return [entry for entry in (value or "").split(os.pathsep) if entry]
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _key(entry: str) -> str:
|
|
347
|
+
return os.path.normcase(os.path.realpath(entry))
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _same(first: str | Path, second: str | Path) -> bool:
|
|
351
|
+
return _key(str(first)) == _key(str(second))
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _unique(entries: list[str]) -> list[str]:
|
|
355
|
+
seen: set[str] = set()
|
|
356
|
+
found = []
|
|
357
|
+
for entry in entries:
|
|
358
|
+
if (key := _key(entry)) not in seen:
|
|
359
|
+
seen.add(key)
|
|
360
|
+
found.append(entry)
|
|
361
|
+
return found
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _database_first(entries: list[str]) -> list[str]:
|
|
365
|
+
"""Move the first directory holding a proj.db to the front, as pyproj needs."""
|
|
366
|
+
for index, entry in enumerate(entries):
|
|
367
|
+
if (Path(entry) / _DATABASE).is_file():
|
|
368
|
+
return [entry, *entries[:index], *entries[index + 1 :]]
|
|
369
|
+
raise FileNotFoundError(
|
|
370
|
+
"no directory on PROJ's search path but the bundled database's holds "
|
|
371
|
+
"a proj.db to switch to"
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _refusal(name: str, entry: str, current: list[str]) -> str | None:
|
|
376
|
+
"""Why the proj.db a variable names is not applied, or None to apply it.
|
|
377
|
+
|
|
378
|
+
PROJ refuses a database that records no layout version, another major one,
|
|
379
|
+
or a minor one older than it was built for. The layout of the proj.db PROJ
|
|
380
|
+
reads now stands in for that, being at least as new.
|
|
381
|
+
"""
|
|
382
|
+
reading = next(
|
|
383
|
+
(
|
|
384
|
+
Path(directory) / _DATABASE
|
|
385
|
+
for directory in current
|
|
386
|
+
if (Path(directory) / _DATABASE).is_file()
|
|
387
|
+
),
|
|
388
|
+
None,
|
|
389
|
+
)
|
|
390
|
+
expected = None if reading is None else _layout(reading)
|
|
391
|
+
if reading is None or expected is None:
|
|
392
|
+
return None
|
|
393
|
+
candidate = Path(entry) / _DATABASE
|
|
394
|
+
found = _layout(candidate)
|
|
395
|
+
if found is not None and found[0] == expected[0] and found[1] >= expected[1]:
|
|
396
|
+
return None
|
|
397
|
+
layout = "no database layout" if found is None else _layout_name(found)
|
|
398
|
+
return (
|
|
399
|
+
f"{name} names {candidate}, with {layout}, so PROJ keeps reading "
|
|
400
|
+
f"{reading.resolve()}, with {_layout_name(expected)}"
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _layout(database: Path) -> tuple[int, int] | None:
|
|
405
|
+
"""The database layout version a proj.db records, or None if none."""
|
|
406
|
+
try:
|
|
407
|
+
with closing(read_only(database)) as connection:
|
|
408
|
+
rows = connection.execute(
|
|
409
|
+
"SELECT key, value FROM metadata WHERE key IN (?, ?)", _LAYOUT
|
|
410
|
+
).fetchall()
|
|
411
|
+
found = dict(rows)
|
|
412
|
+
return int(found[_LAYOUT[0]]), int(found[_LAYOUT[1]])
|
|
413
|
+
except (sqlite3.Error, KeyError, TypeError, ValueError):
|
|
414
|
+
return None
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _layout_name(layout: tuple[int, int]) -> str:
|
|
418
|
+
return f"database layout {layout[0]}.{layout[1]}"
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Verify that pyproj uses the PROJ release this package was tested against."""
|
|
2
|
+
|
|
3
|
+
from typing import Final
|
|
4
|
+
|
|
5
|
+
import pyproj
|
|
6
|
+
|
|
7
|
+
from geodetic_engine._pins import read
|
|
8
|
+
|
|
9
|
+
_PINS: Final = read()
|
|
10
|
+
PINNED_PROJ_VERSION: Final = _PINS.proj_version
|
|
11
|
+
PINNED_PYPROJ_VERSION: Final = _PINS.pyproj_version
|
|
12
|
+
PYPROJ_WHEEL_INDEX: Final = _PINS.pyproj_wheel_index
|
|
13
|
+
PYPROJ_WHEEL_REQUIREMENT: Final = _PINS.pyproj_requirement
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def require_pinned_proj() -> None:
|
|
17
|
+
"""Refuse to run against a different PROJ and its EPSG dataset."""
|
|
18
|
+
if pyproj.proj_version_str != PINNED_PROJ_VERSION:
|
|
19
|
+
raise ImportError(
|
|
20
|
+
f"geodetic-engine requires PROJ {PINNED_PROJ_VERSION}, but "
|
|
21
|
+
f"pyproj {pyproj.__version__} uses PROJ {pyproj.proj_version_str}. "
|
|
22
|
+
f'Install the pinned wheel with: pip install "{PYPROJ_WHEEL_REQUIREMENT}" '
|
|
23
|
+
f"--extra-index-url {PYPROJ_WHEEL_INDEX}. If no wheel is available "
|
|
24
|
+
"for your platform, build pyproj from source against the pinned PROJ."
|
|
25
|
+
)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# The PROJ, proj-data and pyproj releases geodetic-engine is built, tested and
|
|
2
|
+
# released against, and where the matching pyproj wheels are published. This
|
|
3
|
+
# is the only place they are stated. To change them, follow
|
|
4
|
+
# runbooks/upgrading-proj-and-pyproj.md.
|
|
5
|
+
#
|
|
6
|
+
# KEY=VALUE lines only: no quotes, spaces or shell syntax; it is parsed, never
|
|
7
|
+
# sourced. Read by install-proj.sh, the workflows, scripts/sync_pins.py and the
|
|
8
|
+
# release scripts, and shipped in the wheel as geodetic_engine/_pins.env, where
|
|
9
|
+
# the package reads it on import.
|
|
10
|
+
PROJ_VERSION=9.9.0
|
|
11
|
+
PROJ_SHA256=791a0610547eeabb17006cfd49cdbd2034f3240f47ed5e88a1031811f4e2bcf3
|
|
12
|
+
PROJ_DATA_VERSION=1.25
|
|
13
|
+
PROJ_DATA_SHA256=16d65cb5d25b7d8492dbde4d39cafcc8c42f3e55e6715bac4d87519d6d08f4c8
|
|
14
|
+
PYPROJ_VERSION=3.8.0
|
|
15
|
+
PYPROJ_WHEEL_INDEX=https://equinor.github.io/geodetic-engine/simple/
|
geodetic_engine/_pins.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""The PROJ and pyproj releases this package pins, read from one file.
|
|
2
|
+
|
|
3
|
+
A checkout states them in ``.devcontainer/pins.env``; the build backend ships
|
|
4
|
+
that file in the wheel as ``geodetic_engine/_pins.env``. This module imports
|
|
5
|
+
nothing outside the standard library, so the release scripts load it by path
|
|
6
|
+
without importing the package, and with it pyproj.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Final
|
|
15
|
+
|
|
16
|
+
PACKAGED: Final = Path(__file__).with_name("_pins.env")
|
|
17
|
+
CHECKOUT: Final = Path(__file__).resolve().parents[1] / ".devcontainer" / "pins.env"
|
|
18
|
+
|
|
19
|
+
_VERSION: Final = r"\d+(?:\.\d+)+"
|
|
20
|
+
_SHA256: Final = r"[0-9a-f]{64}"
|
|
21
|
+
# Every key the file must set, with the form its value must take.
|
|
22
|
+
_FORMS: Final = {
|
|
23
|
+
"PROJ_VERSION": re.compile(r"\d+\.\d+\.\d+"),
|
|
24
|
+
"PROJ_SHA256": re.compile(_SHA256),
|
|
25
|
+
"PROJ_DATA_VERSION": re.compile(_VERSION),
|
|
26
|
+
"PROJ_DATA_SHA256": re.compile(_SHA256),
|
|
27
|
+
"PYPROJ_VERSION": re.compile(_VERSION + r"(?:(?:a|b|rc)\d+)?(?:\.post\d+)?"),
|
|
28
|
+
"PYPROJ_WHEEL_INDEX": re.compile(r"https://[^\s\"'`\\]+/"),
|
|
29
|
+
}
|
|
30
|
+
KEYS: Final = tuple(_FORMS)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class Pins:
|
|
35
|
+
"""The pinned releases.
|
|
36
|
+
|
|
37
|
+
Attributes:
|
|
38
|
+
proj_version: The PROJ release, for example ``"9.9.0"``.
|
|
39
|
+
proj_sha256: SHA-256 of its source archive.
|
|
40
|
+
proj_data_version: The proj-data grid package matching that release.
|
|
41
|
+
proj_data_sha256: SHA-256 of the proj-data archive.
|
|
42
|
+
pyproj_version: The pyproj release built against that PROJ.
|
|
43
|
+
pyproj_wheel_index: The package index serving those pyproj wheels.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
proj_version: str
|
|
47
|
+
proj_sha256: str
|
|
48
|
+
proj_data_version: str
|
|
49
|
+
proj_data_sha256: str
|
|
50
|
+
pyproj_version: str
|
|
51
|
+
pyproj_wheel_index: str
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def pyproj_requirement(self) -> str:
|
|
55
|
+
"""The pyproj wheel this project builds against the pinned PROJ."""
|
|
56
|
+
return f"pyproj=={self.pyproj_version}+proj{self.proj_version}"
|
|
57
|
+
|
|
58
|
+
def get(self, key: str) -> str:
|
|
59
|
+
"""A value by its key in the file, or ``PYPROJ_REQUIREMENT``."""
|
|
60
|
+
if key == "PYPROJ_REQUIREMENT":
|
|
61
|
+
return self.pyproj_requirement
|
|
62
|
+
if key not in _FORMS:
|
|
63
|
+
raise ValueError(f"unknown pin {key!r}; known: {', '.join(KEYS)}")
|
|
64
|
+
return str(getattr(self, key.lower()))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def parse(text: str, source: str = "pins.env") -> Pins:
|
|
68
|
+
"""Read the pins, refusing anything but the expected keys in their forms.
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
ValueError: On an unknown, repeated, missing or malformed setting.
|
|
72
|
+
"""
|
|
73
|
+
found: dict[str, str] = {}
|
|
74
|
+
for number, line in enumerate(text.splitlines(), 1):
|
|
75
|
+
if not line.strip() or line.lstrip().startswith("#"):
|
|
76
|
+
continue
|
|
77
|
+
key, separator, value = line.partition("=")
|
|
78
|
+
if not separator or key not in _FORMS:
|
|
79
|
+
raise ValueError(
|
|
80
|
+
f"{source}:{number}: expected KEY=VALUE with KEY one of "
|
|
81
|
+
f"{', '.join(KEYS)}, got {line!r}"
|
|
82
|
+
)
|
|
83
|
+
if key in found:
|
|
84
|
+
raise ValueError(f"{source}:{number}: {key} is set twice")
|
|
85
|
+
if not _FORMS[key].fullmatch(value):
|
|
86
|
+
raise ValueError(f"{source}:{number}: {key}={value!r} is malformed")
|
|
87
|
+
found[key] = value
|
|
88
|
+
missing = [key for key in KEYS if key not in found]
|
|
89
|
+
if missing:
|
|
90
|
+
raise ValueError(f"{source} does not set {', '.join(missing)}")
|
|
91
|
+
return Pins(*(found[key] for key in KEYS))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def read() -> Pins:
|
|
95
|
+
"""The pins of this installation, or of the checkout it is run from."""
|
|
96
|
+
path = PACKAGED if PACKAGED.is_file() else CHECKOUT
|
|
97
|
+
return parse(path.read_text(encoding="utf-8"), str(path))
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""The private layer below ``geodesy`` and ``persistablereference``.
|
|
2
|
+
|
|
3
|
+
What both need and neither owns: reading proj.db, OSDU record ids, the
|
|
4
|
+
PROJJSON of an operation, the ``xy`` axis-order model, the Helmert algebra,
|
|
5
|
+
and the exceptions raised here. It imports neither package, which lets
|
|
6
|
+
``persistablereference`` read payloads without importing ``geodesy``.
|
|
7
|
+
``geodesy`` re-exports what is public. ``lint-imports`` enforces the layering.
|
|
8
|
+
"""
|