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 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
@@ -0,0 +1,7 @@
1
+ """``python -m geofix``."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ sys.exit(main())
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)