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.
Files changed (137) hide show
  1. geodetic_engine/__init__.py +14 -0
  2. geodetic_engine/_bundled_database.py +418 -0
  3. geodetic_engine/_environment.py +25 -0
  4. geodetic_engine/_pins.env +15 -0
  5. geodetic_engine/_pins.py +97 -0
  6. geodetic_engine/_referencing/__init__.py +8 -0
  7. geodetic_engine/_referencing/abridged.py +116 -0
  8. geodetic_engine/_referencing/axes.py +77 -0
  9. geodetic_engine/_referencing/database.py +975 -0
  10. geodetic_engine/_referencing/errors.py +34 -0
  11. geodetic_engine/_referencing/helmert.py +652 -0
  12. geodetic_engine/_referencing/osdu_id.py +228 -0
  13. geodetic_engine/_referencing/projjson.py +94 -0
  14. geodetic_engine/_search_path.py +140 -0
  15. geodetic_engine/_sqlite.py +39 -0
  16. geodetic_engine/bingrid/__init__.py +80 -0
  17. geodetic_engine/bingrid/conversion.py +457 -0
  18. geodetic_engine/bingrid/corners.py +278 -0
  19. geodetic_engine/bingrid/errors.py +66 -0
  20. geodetic_engine/bingrid/matching.py +238 -0
  21. geodetic_engine/bingrid/outline.py +111 -0
  22. geodetic_engine/bingrid/p6.py +403 -0
  23. geodetic_engine/bingrid/squaring.py +304 -0
  24. geodetic_engine/errors.py +9 -0
  25. geodetic_engine/geodesy/__init__.py +117 -0
  26. geodetic_engine/geodesy/_antimeridian.py +326 -0
  27. geodetic_engine/geodesy/_crs_tools.py +192 -0
  28. geodetic_engine/geodesy/_failures.py +355 -0
  29. geodetic_engine/geodesy/_geojson_objects.py +63 -0
  30. geodetic_engine/geodesy/_rounding.py +161 -0
  31. geodetic_engine/geodesy/_segments.py +207 -0
  32. geodetic_engine/geodesy/_shared.py +68 -0
  33. geodetic_engine/geodesy/_wkb.py +124 -0
  34. geodetic_engine/geodesy/crs.py +528 -0
  35. geodetic_engine/geodesy/database.py +54 -0
  36. geodetic_engine/geodesy/errors.py +129 -0
  37. geodetic_engine/geodesy/factors.py +352 -0
  38. geodetic_engine/geodesy/geometry.py +1894 -0
  39. geodetic_engine/geodesy/operation.py +1336 -0
  40. geodetic_engine/geodesy/osdu_id.py +26 -0
  41. geodetic_engine/geodesy/result.py +411 -0
  42. geodetic_engine/geodesy/transformation.py +2831 -0
  43. geodetic_engine/geodesy/utils/__init__.py +28 -0
  44. geodetic_engine/geojson/__init__.py +38 -0
  45. geodetic_engine/geojson/_convert.py +1624 -0
  46. geodetic_engine/geojson/_jsonfg.py +143 -0
  47. geodetic_engine/geojson/_model.py +1004 -0
  48. geodetic_engine/geojson/_osdu.py +86 -0
  49. geodetic_engine/geojson/_scope.py +660 -0
  50. geodetic_engine/geojson/errors.py +61 -0
  51. geodetic_engine/georepository/__init__.py +47 -0
  52. geodetic_engine/georepository/auth.py +131 -0
  53. geodetic_engine/georepository/cache.py +285 -0
  54. geodetic_engine/georepository/client.py +458 -0
  55. geodetic_engine/georepository/config.py +116 -0
  56. geodetic_engine/georepository/errors.py +28 -0
  57. geodetic_engine/osdudb/__init__.py +68 -0
  58. geodetic_engine/osdudb/__main__.py +463 -0
  59. geodetic_engine/osdudb/_data/CRS_CT_osdu_official_crs_catalog.json +434781 -0
  60. geodetic_engine/osdudb/_data/CRS_CT_osdu_official_crs_catalog.source.json +9 -0
  61. geodetic_engine/osdudb/_data/proj_data/proj.db +0 -0
  62. geodetic_engine/osdudb/bound.py +204 -0
  63. geodetic_engine/osdudb/build.py +127 -0
  64. geodetic_engine/osdudb/catalog.py +268 -0
  65. geodetic_engine/osdudb/config.py +277 -0
  66. geodetic_engine/osdudb/context.py +311 -0
  67. geodetic_engine/osdudb/crs.py +477 -0
  68. geodetic_engine/osdudb/definition.py +582 -0
  69. geodetic_engine/osdudb/download.py +278 -0
  70. geodetic_engine/osdudb/errors.py +46 -0
  71. geodetic_engine/osdudb/identifier.py +305 -0
  72. geodetic_engine/osdudb/official.py +57 -0
  73. geodetic_engine/osdudb/operation.py +272 -0
  74. geodetic_engine/osdudb/persistable_reference.py +387 -0
  75. geodetic_engine/osdudb/translate.py +238 -0
  76. geodetic_engine/persistablereference/__init__.py +116 -0
  77. geodetic_engine/persistablereference/emit.py +367 -0
  78. geodetic_engine/persistablereference/envelope.py +393 -0
  79. geodetic_engine/persistablereference/errors.py +56 -0
  80. geodetic_engine/persistablereference/esriwkt.py +409 -0
  81. geodetic_engine/persistablereference/methods.py +399 -0
  82. geodetic_engine/persistablereference/reference.py +1138 -0
  83. geodetic_engine/persistablereference/stored.py +813 -0
  84. geodetic_engine/projdb/__init__.py +40 -0
  85. geodetic_engine/projdb/__main__.py +243 -0
  86. geodetic_engine/projdb/_validate_worker.py +32 -0
  87. geodetic_engine/projdb/alias.py +112 -0
  88. geodetic_engine/projdb/annotate.py +110 -0
  89. geodetic_engine/projdb/authority.py +162 -0
  90. geodetic_engine/projdb/bound.py +232 -0
  91. geodetic_engine/projdb/build.py +325 -0
  92. geodetic_engine/projdb/cli.py +110 -0
  93. geodetic_engine/projdb/common.py +316 -0
  94. geodetic_engine/projdb/config.py +349 -0
  95. geodetic_engine/projdb/context.py +278 -0
  96. geodetic_engine/projdb/coordinate_system.py +117 -0
  97. geodetic_engine/projdb/crs.py +378 -0
  98. geodetic_engine/projdb/datum.py +264 -0
  99. geodetic_engine/projdb/errors.py +48 -0
  100. geodetic_engine/projdb/operation.py +327 -0
  101. geodetic_engine/projdb/parameters.py +219 -0
  102. geodetic_engine/projdb/records.py +136 -0
  103. geodetic_engine/projdb/report.py +385 -0
  104. geodetic_engine/projdb/schema.py +615 -0
  105. geodetic_engine/projdb/settings.py +427 -0
  106. geodetic_engine/projdb/translate.py +166 -0
  107. geodetic_engine/projdb/validate.py +462 -0
  108. geodetic_engine/projdb/writer.py +1012 -0
  109. geodetic_engine/py.typed +0 -0
  110. geodetic_engine/units/__init__.py +44 -0
  111. geodetic_engine/units/errors.py +11 -0
  112. geodetic_engine/units/unit.py +189 -0
  113. geodetic_engine/welltrajectory/README.md +336 -0
  114. geodetic_engine/welltrajectory/__init__.py +82 -0
  115. geodetic_engine/welltrajectory/csv_parser.py +648 -0
  116. geodetic_engine/welltrajectory/datamodels/__init__.py +17 -0
  117. geodetic_engine/welltrajectory/datamodels/trajectory_input.py +875 -0
  118. geodetic_engine/welltrajectory/datamodels/well_trajectory.py +283 -0
  119. geodetic_engine/welltrajectory/errors.py +42 -0
  120. geodetic_engine/welltrajectory/example_data/synthetic_well.csv +135 -0
  121. geodetic_engine/welltrajectory/example_data/volve_f1_survey.txt +145 -0
  122. geodetic_engine/welltrajectory/methods/__init__.py +71 -0
  123. geodetic_engine/welltrajectory/methods/azimuthal_equidistant.py +79 -0
  124. geodetic_engine/welltrajectory/methods/base.py +124 -0
  125. geodetic_engine/welltrajectory/methods/enu.py +50 -0
  126. geodetic_engine/welltrajectory/methods/grid_north_local.py +87 -0
  127. geodetic_engine/welltrajectory/methods/lmp.py +106 -0
  128. geodetic_engine/welltrajectory/minimum_curvature.py +309 -0
  129. geodetic_engine/welltrajectory/plot.py +557 -0
  130. geodetic_engine/welltrajectory/survey.py +177 -0
  131. geodetic_engine/welltrajectory/trajectory.py +157 -0
  132. pygeodetic_engine-0.2.0.dist-info/METADATA +410 -0
  133. pygeodetic_engine-0.2.0.dist-info/RECORD +137 -0
  134. pygeodetic_engine-0.2.0.dist-info/WHEEL +4 -0
  135. pygeodetic_engine-0.2.0.dist-info/entry_points.txt +3 -0
  136. pygeodetic_engine-0.2.0.dist-info/licenses/LICENSE +201 -0
  137. 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/
@@ -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
+ """