geofix 0.1.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.
- geofix/__init__.py +59 -0
- geofix/__main__.py +7 -0
- geofix/api.py +359 -0
- geofix/cli.py +350 -0
- geofix/config.py +102 -0
- geofix/extent.py +121 -0
- geofix/geometry.py +649 -0
- geofix/model.py +90 -0
- geofix/options.py +40 -0
- geofix/py.typed +0 -0
- geofix/reader.py +258 -0
- geofix/repair.py +681 -0
- geofix/report.py +180 -0
- geofix/rules.py +349 -0
- geofix/stats.py +244 -0
- geofix/validate.py +903 -0
- geofix-0.1.0.dist-info/METADATA +382 -0
- geofix-0.1.0.dist-info/RECORD +22 -0
- geofix-0.1.0.dist-info/WHEEL +5 -0
- geofix-0.1.0.dist-info/entry_points.txt +2 -0
- geofix-0.1.0.dist-info/licenses/LICENSE +21 -0
- geofix-0.1.0.dist-info/top_level.txt +1 -0
geofix/__init__.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""geofix: fast, dependency-light GeoJSON (RFC 7946) validator and repairer.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
import geofix
|
|
6
|
+
|
|
7
|
+
issues = geofix.check_file("parcels.geojson")
|
|
8
|
+
for issue in issues:
|
|
9
|
+
print(issue.code, issue.severity, issue.path, issue.message)
|
|
10
|
+
|
|
11
|
+
result = geofix.fix_file("parcels.geojson", "parcels.fixed.geojson")
|
|
12
|
+
for change in result.changes:
|
|
13
|
+
print(change.code, change.path, change.message)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
__version__ = "0.1.0"
|
|
19
|
+
__author__ = "Yusuf Eminoğlu"
|
|
20
|
+
__email__ = "yusufeminoglu@gmail.com"
|
|
21
|
+
|
|
22
|
+
from .api import FixResult, check, check_file, fix, fix_file
|
|
23
|
+
from .config import ConfigError, load_config
|
|
24
|
+
from .model import Change, Issue, Rule, Severity
|
|
25
|
+
from .options import Options
|
|
26
|
+
from .reader import GeoJSONSyntaxError, Reader, iter_features
|
|
27
|
+
from .repair import Repairer, shapely_available
|
|
28
|
+
from .report import FileResult
|
|
29
|
+
from .rules import RULES, resolve_codes
|
|
30
|
+
from .stats import Stats, stats_file, stats_object
|
|
31
|
+
from .validate import Validator
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"RULES",
|
|
35
|
+
"Change",
|
|
36
|
+
"ConfigError",
|
|
37
|
+
"FileResult",
|
|
38
|
+
"FixResult",
|
|
39
|
+
"GeoJSONSyntaxError",
|
|
40
|
+
"Issue",
|
|
41
|
+
"Options",
|
|
42
|
+
"Reader",
|
|
43
|
+
"Repairer",
|
|
44
|
+
"Rule",
|
|
45
|
+
"Severity",
|
|
46
|
+
"Stats",
|
|
47
|
+
"Validator",
|
|
48
|
+
"__version__",
|
|
49
|
+
"check",
|
|
50
|
+
"check_file",
|
|
51
|
+
"fix",
|
|
52
|
+
"fix_file",
|
|
53
|
+
"iter_features",
|
|
54
|
+
"load_config",
|
|
55
|
+
"resolve_codes",
|
|
56
|
+
"shapely_available",
|
|
57
|
+
"stats_file",
|
|
58
|
+
"stats_object",
|
|
59
|
+
]
|
geofix/__main__.py
ADDED
geofix/api.py
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"""Public high-level API: ``check``, ``check_file``, ``fix``, ``fix_file``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import IO, Any, Dict, List, Optional, Set, Tuple, Union
|
|
13
|
+
|
|
14
|
+
from .extent import merge, new_extent
|
|
15
|
+
from .model import Change, Issue, Severity
|
|
16
|
+
from .options import Options
|
|
17
|
+
from .reader import GeoJSONSyntaxError, Reader
|
|
18
|
+
from .repair import Repairer
|
|
19
|
+
from .rules import RULES
|
|
20
|
+
from .validate import Validator
|
|
21
|
+
|
|
22
|
+
PathLike = Union[str, "os.PathLike[str]"]
|
|
23
|
+
|
|
24
|
+
GEOJSON_SUFFIXES = (
|
|
25
|
+
".geojson",
|
|
26
|
+
".geojsonl",
|
|
27
|
+
".geojsons",
|
|
28
|
+
".geojsonseq",
|
|
29
|
+
".ndgeojson",
|
|
30
|
+
".geo.json",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class FixResult:
|
|
36
|
+
"""Outcome of a repair.
|
|
37
|
+
|
|
38
|
+
``data`` is the repaired object (``None`` for :func:`fix_file`, which streams).
|
|
39
|
+
``issues`` are the problems that remain after the repair.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
data: Any
|
|
43
|
+
changes: List[Change] = field(default_factory=list)
|
|
44
|
+
issues: List[Issue] = field(default_factory=list)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def changed(self) -> bool:
|
|
48
|
+
return bool(self.changes)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _options(options: Optional[Options], kwargs: Dict[str, Any]) -> Options:
|
|
52
|
+
if options is not None and kwargs:
|
|
53
|
+
raise TypeError("pass either an Options object or keyword options, not both")
|
|
54
|
+
return options if options is not None else Options(**kwargs)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _parse_text(data: Union[str, bytes]) -> Any:
|
|
58
|
+
if isinstance(data, bytes):
|
|
59
|
+
data = data.decode("utf-8-sig")
|
|
60
|
+
return data
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def check(data: Any, options: Optional[Options] = None, **kwargs: Any) -> List[Issue]:
|
|
64
|
+
"""Validate GeoJSON given as a parsed object, a JSON string or bytes.
|
|
65
|
+
|
|
66
|
+
Keyword arguments are forwarded to :class:`~geofix.Options` (``select``, ``ignore``,
|
|
67
|
+
``precision``...). Returns the list of issues (empty when the input is valid).
|
|
68
|
+
"""
|
|
69
|
+
opts = _options(options, kwargs)
|
|
70
|
+
v = Validator(opts)
|
|
71
|
+
if isinstance(data, (str, bytes)):
|
|
72
|
+
_run(io.StringIO(_parse_text(data)), v)
|
|
73
|
+
else:
|
|
74
|
+
v.document(data)
|
|
75
|
+
return v.issues
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def fix(data: Any, options: Optional[Options] = None, **kwargs: Any) -> FixResult:
|
|
79
|
+
"""Repair GeoJSON given as a parsed object, a JSON string or bytes.
|
|
80
|
+
|
|
81
|
+
The input is not modified. ``FixResult.data`` holds the repaired object (parsed).
|
|
82
|
+
"""
|
|
83
|
+
opts = _options(options, kwargs)
|
|
84
|
+
if isinstance(data, (str, bytes)):
|
|
85
|
+
out = io.StringIO()
|
|
86
|
+
v = Validator(opts)
|
|
87
|
+
r = Repairer(opts)
|
|
88
|
+
w = _Writer(out)
|
|
89
|
+
_run(io.StringIO(_parse_text(data)), v, r, w)
|
|
90
|
+
if w.aborted:
|
|
91
|
+
return FixResult(data=None, changes=r.changes, issues=v.issues)
|
|
92
|
+
text = out.getvalue()
|
|
93
|
+
parsed = _load_written(text, w.kind)
|
|
94
|
+
return FixResult(data=parsed, changes=r.changes, issues=v.issues)
|
|
95
|
+
r = Repairer(opts)
|
|
96
|
+
repaired = r.document(data)
|
|
97
|
+
v = Validator(opts)
|
|
98
|
+
v.document(repaired)
|
|
99
|
+
return FixResult(data=repaired, changes=r.changes, issues=v.issues)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _load_written(text: str, kind: str) -> Any:
|
|
103
|
+
if kind == "sequence":
|
|
104
|
+
return [json.loads(line.lstrip("\x1e")) for line in text.splitlines() if line.strip()]
|
|
105
|
+
return json.loads(text)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _open_text(path: PathLike) -> IO[str]:
|
|
109
|
+
if str(path) == "-":
|
|
110
|
+
return io.TextIOWrapper(sys.stdin.buffer, encoding="utf-8-sig")
|
|
111
|
+
return open(path, encoding="utf-8-sig", newline="")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def check_file(path: PathLike, options: Optional[Options] = None, **kwargs: Any) -> List[Issue]:
|
|
115
|
+
"""Validate a GeoJSON or newline-delimited GeoJSON file without loading it whole.
|
|
116
|
+
|
|
117
|
+
``path`` may be ``"-"`` for standard input. Raises ``OSError`` if it cannot be read.
|
|
118
|
+
"""
|
|
119
|
+
opts = _options(options, kwargs)
|
|
120
|
+
v = Validator(opts)
|
|
121
|
+
fp = _open_text(path)
|
|
122
|
+
try:
|
|
123
|
+
_run(fp, v)
|
|
124
|
+
finally:
|
|
125
|
+
if str(path) != "-":
|
|
126
|
+
fp.close()
|
|
127
|
+
return v.issues
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def fix_file(
|
|
131
|
+
src: PathLike,
|
|
132
|
+
dst: Optional[PathLike] = None,
|
|
133
|
+
options: Optional[Options] = None,
|
|
134
|
+
*,
|
|
135
|
+
indent: Optional[int] = None,
|
|
136
|
+
**kwargs: Any,
|
|
137
|
+
) -> FixResult:
|
|
138
|
+
"""Repair a file, streaming feature by feature.
|
|
139
|
+
|
|
140
|
+
``dst`` may be another path, the same path (in place), ``"-"`` for standard output
|
|
141
|
+
or ``None`` for a dry run. Nothing is written when the input has a syntax error. When
|
|
142
|
+
no change is needed the destination receives the original bytes, so an in-place run
|
|
143
|
+
leaves a valid file untouched.
|
|
144
|
+
"""
|
|
145
|
+
opts = _options(options, kwargs)
|
|
146
|
+
v = Validator(opts)
|
|
147
|
+
r = Repairer(opts)
|
|
148
|
+
src_s = str(src)
|
|
149
|
+
dst_s = None if dst is None else str(dst)
|
|
150
|
+
tmp_path: Optional[str] = None
|
|
151
|
+
sink: IO[str]
|
|
152
|
+
if dst_s is None:
|
|
153
|
+
sink = io.StringIO()
|
|
154
|
+
elif dst_s == "-":
|
|
155
|
+
sink = io.StringIO()
|
|
156
|
+
else:
|
|
157
|
+
directory = os.path.dirname(os.path.abspath(dst_s))
|
|
158
|
+
fd, tmp_path = tempfile.mkstemp(prefix=".geofix-", suffix=".tmp", dir=directory)
|
|
159
|
+
sink = os.fdopen(fd, "w", encoding="utf-8", newline="\n")
|
|
160
|
+
w = _Writer(sink, indent=indent) if dst_s is not None else _DryWriter(sink)
|
|
161
|
+
fp = _open_text(src)
|
|
162
|
+
try:
|
|
163
|
+
_run(fp, v, r, w)
|
|
164
|
+
finally:
|
|
165
|
+
if src_s != "-":
|
|
166
|
+
fp.close()
|
|
167
|
+
if tmp_path is not None:
|
|
168
|
+
sink.close()
|
|
169
|
+
try:
|
|
170
|
+
if dst_s is None or w.aborted:
|
|
171
|
+
return FixResult(data=None, changes=r.changes, issues=v.issues)
|
|
172
|
+
if dst_s == "-":
|
|
173
|
+
assert isinstance(sink, io.StringIO)
|
|
174
|
+
text = sink.getvalue() if r.changes else None
|
|
175
|
+
if text is None:
|
|
176
|
+
with open(src_s, encoding="utf-8-sig", newline="") as f:
|
|
177
|
+
text = f.read()
|
|
178
|
+
_write_stdout(text)
|
|
179
|
+
elif r.changes:
|
|
180
|
+
assert tmp_path is not None
|
|
181
|
+
os.replace(tmp_path, dst_s)
|
|
182
|
+
tmp_path = None
|
|
183
|
+
elif os.path.abspath(dst_s) != os.path.abspath(src_s):
|
|
184
|
+
shutil.copyfile(src_s, dst_s)
|
|
185
|
+
finally:
|
|
186
|
+
if tmp_path is not None and os.path.exists(tmp_path):
|
|
187
|
+
os.remove(tmp_path)
|
|
188
|
+
return FixResult(data=None, changes=r.changes, issues=v.issues)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _write_stdout(text: str) -> None:
|
|
192
|
+
buf = getattr(sys.stdout, "buffer", None)
|
|
193
|
+
if buf is None: # e.g. replaced by a StringIO in tests
|
|
194
|
+
sys.stdout.write(text)
|
|
195
|
+
sys.stdout.flush()
|
|
196
|
+
return
|
|
197
|
+
sys.stdout.flush()
|
|
198
|
+
buf.write(text.encode("utf-8"))
|
|
199
|
+
buf.flush()
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ---------------------------------------------------------------------------------------
|
|
203
|
+
# Streaming driver and writer
|
|
204
|
+
# ---------------------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _dumps(obj: Any, indent: Optional[int] = None) -> str:
|
|
208
|
+
return json.dumps(obj, ensure_ascii=False, indent=indent, allow_nan=True)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class _Writer:
|
|
212
|
+
"""Writes repaired events back out: one feature per line for collections."""
|
|
213
|
+
|
|
214
|
+
def __init__(self, fp: IO[str], indent: Optional[int] = None) -> None:
|
|
215
|
+
self.fp = fp
|
|
216
|
+
self.indent = indent
|
|
217
|
+
self.aborted = False
|
|
218
|
+
self.kind = "object"
|
|
219
|
+
self.rs = False
|
|
220
|
+
self._first = True
|
|
221
|
+
self._written: Set[str] = set()
|
|
222
|
+
|
|
223
|
+
def fc_begin(self, members: Dict[str, Any]) -> None:
|
|
224
|
+
self.kind = "collection"
|
|
225
|
+
parts = []
|
|
226
|
+
for k, val in members.items():
|
|
227
|
+
if k == "bbox":
|
|
228
|
+
continue
|
|
229
|
+
parts.append(f"{_dumps(k)}: {_dumps(val)}")
|
|
230
|
+
self._written.add(k)
|
|
231
|
+
head = ", ".join(parts)
|
|
232
|
+
self.fp.write("{" + head + (", " if head else "") + '"features": [\n')
|
|
233
|
+
|
|
234
|
+
def feature(self, f: Any) -> None:
|
|
235
|
+
if not self._first:
|
|
236
|
+
self.fp.write(",\n")
|
|
237
|
+
self._first = False
|
|
238
|
+
self.fp.write(_dumps(f))
|
|
239
|
+
|
|
240
|
+
def fc_end(self, members: Dict[str, Any]) -> None:
|
|
241
|
+
self.fp.write("\n]")
|
|
242
|
+
for k, val in members.items():
|
|
243
|
+
if k not in self._written:
|
|
244
|
+
self.fp.write(f", {_dumps(k)}: {_dumps(val)}")
|
|
245
|
+
self.fp.write("}\n")
|
|
246
|
+
|
|
247
|
+
def object(self, obj: Any) -> None:
|
|
248
|
+
self.fp.write(_dumps(obj, self.indent) + "\n")
|
|
249
|
+
|
|
250
|
+
def record(self, obj: Any) -> None:
|
|
251
|
+
self.kind = "sequence"
|
|
252
|
+
self.fp.write(("\x1e" if self.rs else "") + _dumps(obj) + "\n")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class _DryWriter(_Writer):
|
|
256
|
+
"""Discards output (dry runs) without serialising anything."""
|
|
257
|
+
|
|
258
|
+
def fc_begin(self, members: Dict[str, Any]) -> None:
|
|
259
|
+
self.kind = "collection"
|
|
260
|
+
|
|
261
|
+
def feature(self, f: Any) -> None:
|
|
262
|
+
pass
|
|
263
|
+
|
|
264
|
+
def fc_end(self, members: Dict[str, Any]) -> None:
|
|
265
|
+
pass
|
|
266
|
+
|
|
267
|
+
def object(self, obj: Any) -> None:
|
|
268
|
+
pass
|
|
269
|
+
|
|
270
|
+
def record(self, obj: Any) -> None:
|
|
271
|
+
self.kind = "sequence"
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _run(
|
|
275
|
+
fp: IO[str],
|
|
276
|
+
v: Validator,
|
|
277
|
+
r: Optional[Repairer] = None,
|
|
278
|
+
w: Optional[_Writer] = None,
|
|
279
|
+
) -> None:
|
|
280
|
+
reader = Reader(fp)
|
|
281
|
+
try:
|
|
282
|
+
if r is None or w is None:
|
|
283
|
+
for ev in reader.events():
|
|
284
|
+
v.event(ev)
|
|
285
|
+
return
|
|
286
|
+
fixable = frozenset(c for c in v.codes if RULES[c].fix)
|
|
287
|
+
|
|
288
|
+
def needs_repair(token: Tuple[int, int, Any]) -> bool:
|
|
289
|
+
return any(i.code in fixable for i in v.issues[token[0] :])
|
|
290
|
+
|
|
291
|
+
begin_keys: Set[str] = set()
|
|
292
|
+
crs_dropped = False
|
|
293
|
+
for ev in reader.events():
|
|
294
|
+
kind = ev[0]
|
|
295
|
+
if kind == "feature" or kind == "record":
|
|
296
|
+
idx, obj, line = ev[1], ev[2], ev[3]
|
|
297
|
+
check = v.feature if kind == "feature" else v.record
|
|
298
|
+
token = v.mark()
|
|
299
|
+
ext = check(idx, obj, line)
|
|
300
|
+
if needs_repair(token):
|
|
301
|
+
# Repair, then validate the repaired object in place of the original.
|
|
302
|
+
v.rollback(token)
|
|
303
|
+
fixed = (
|
|
304
|
+
r.feature(idx, obj, line) if kind == "feature" else r.record(idx, obj, line)
|
|
305
|
+
)
|
|
306
|
+
if fixed is None:
|
|
307
|
+
continue
|
|
308
|
+
check(idx, fixed, line)
|
|
309
|
+
obj = fixed
|
|
310
|
+
elif kind == "feature":
|
|
311
|
+
merge(r.collection_extent, ext)
|
|
312
|
+
if kind == "feature":
|
|
313
|
+
w.feature(obj)
|
|
314
|
+
else:
|
|
315
|
+
w.rs = reader.record_separator
|
|
316
|
+
w.record(obj)
|
|
317
|
+
elif kind == "fc_begin":
|
|
318
|
+
members = ev[1]
|
|
319
|
+
begin_keys = set(members)
|
|
320
|
+
r.collection_extent = new_extent()
|
|
321
|
+
fixed_members = r.begin_members(members, "$", ev[2])
|
|
322
|
+
crs_dropped = "crs" in members and "crs" not in fixed_members
|
|
323
|
+
v.fc_begin(fixed_members, ev[2])
|
|
324
|
+
w.fc_begin(fixed_members)
|
|
325
|
+
elif kind == "fc_end":
|
|
326
|
+
members = ev[1]
|
|
327
|
+
if crs_dropped:
|
|
328
|
+
members = {k: val for k, val in members.items() if k != "crs"}
|
|
329
|
+
fixed_members = r.end_members(
|
|
330
|
+
members, "$", check_crs="crs" not in begin_keys, line=ev[2]
|
|
331
|
+
)
|
|
332
|
+
v.fc_end(fixed_members, ev[2])
|
|
333
|
+
w.fc_end(fixed_members)
|
|
334
|
+
elif kind == "object":
|
|
335
|
+
token = v.mark()
|
|
336
|
+
v.document(ev[1], ev[2])
|
|
337
|
+
out = ev[1]
|
|
338
|
+
if needs_repair(token):
|
|
339
|
+
v.rollback(token)
|
|
340
|
+
out = r.document(ev[1], ev[2])
|
|
341
|
+
v.document(out, ev[2])
|
|
342
|
+
w.object(out)
|
|
343
|
+
elif kind == "error":
|
|
344
|
+
v.syntax_error(ev[1])
|
|
345
|
+
w.aborted = True
|
|
346
|
+
except GeoJSONSyntaxError as exc:
|
|
347
|
+
v.syntax_error(exc)
|
|
348
|
+
if w is not None:
|
|
349
|
+
w.aborted = True
|
|
350
|
+
except UnicodeDecodeError as exc:
|
|
351
|
+
v._line = None
|
|
352
|
+
v._emit("GF000", "$", f"input is not UTF-8 ({exc.reason} at byte {exc.start} of a chunk)")
|
|
353
|
+
if w is not None:
|
|
354
|
+
w.aborted = True
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def worst_severity(issues: List[Issue]) -> Optional[Severity]:
|
|
358
|
+
"""Highest severity among ``issues`` (``None`` when empty)."""
|
|
359
|
+
return max((i.severity for i in issues), default=None)
|