whatcrs 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.
whatcrs/__init__.py ADDED
@@ -0,0 +1,41 @@
1
+ """whatcrs - "What CRS is this?"
2
+
3
+ Guess the coordinate reference system of mystery coordinates and rank
4
+ candidate EPSG codes with explanations::
5
+
6
+ >>> import whatcrs
7
+ >>> best = whatcrs.guess_crs([(440000, 4474000)], hint="Spain")[0]
8
+ >>> best.epsg
9
+ 25830
10
+
11
+ Works offline with a bundled set of ~900 widely used CRSs and pure-Python
12
+ inverse projections; installs with ``pyproj`` widen the search to every EPSG
13
+ projected CRS.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from .core import Candidate, describe_coordinates, fix_swapped_axes, guess_crs, to_lonlat
19
+ from .crs_db import CRSRecord, builtin_records, pyproj_available
20
+ from .readers import PointSource, read_points, read_text
21
+ from .regions import Region, country_at, parse_hint
22
+
23
+ __version__ = "0.1.0"
24
+
25
+ __all__ = [
26
+ "Candidate",
27
+ "CRSRecord",
28
+ "PointSource",
29
+ "Region",
30
+ "__version__",
31
+ "builtin_records",
32
+ "country_at",
33
+ "describe_coordinates",
34
+ "fix_swapped_axes",
35
+ "guess_crs",
36
+ "parse_hint",
37
+ "pyproj_available",
38
+ "read_points",
39
+ "read_text",
40
+ "to_lonlat",
41
+ ]
whatcrs/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from whatcrs.cli import main
2
+
3
+ raise SystemExit(main())
whatcrs/cli.py ADDED
@@ -0,0 +1,192 @@
1
+ """Command line interface: ``whatcrs``.
2
+
3
+ Examples::
4
+
5
+ whatcrs 512345 4182345
6
+ whatcrs 512345 4182345 --hint Spain
7
+ whatcrs data.csv --x lon --y lat --hint "Spain"
8
+ whatcrs shapes.geojson --json
9
+ whatcrs "POINT (2600000 1200000)"
10
+ cat points.csv | whatcrs -
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import json
17
+ import re
18
+ import sys
19
+ from pathlib import Path
20
+ from typing import List, Optional, Sequence, TextIO
21
+
22
+ from . import __version__
23
+ from .core import Candidate, describe_coordinates, guess_crs
24
+ from .crs_db import pyproj_available
25
+ from .readers import PointSource, read_points, read_text
26
+
27
+ _NUMBER = re.compile(r"^[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?$")
28
+
29
+
30
+ def _build_parser() -> argparse.ArgumentParser:
31
+ p = argparse.ArgumentParser(
32
+ prog="whatcrs",
33
+ description=(
34
+ "What CRS is this? Guess the coordinate reference system of mystery coordinates "
35
+ "and rank candidate EPSG codes with explanations."
36
+ ),
37
+ epilog=(
38
+ "INPUT is either numbers (x y [x y ...], also 'x,y'), a CSV/GeoJSON/WKT file, "
39
+ "a WKT string, or '-' for standard input."
40
+ ),
41
+ )
42
+ p.add_argument("input", nargs="+", metavar="INPUT", help="coordinates, a file, WKT or '-'")
43
+ p.add_argument("--hint", help='where the data should be, e.g. "Spain", "Texas", "Europe"')
44
+ p.add_argument(
45
+ "--bbox",
46
+ help="expected area as west,south,east,north in degrees (use --bbox=-10,35,5,44 "
47
+ "when the first value is negative)",
48
+ )
49
+ p.add_argument("--units", help="known linear unit: m, ft or deg")
50
+ p.add_argument(
51
+ "--x", dest="xcol", help="CSV column with the first coordinate (name or 0-based index)"
52
+ )
53
+ p.add_argument("--y", dest="ycol", help="CSV column with the second coordinate")
54
+ p.add_argument("--delimiter", help="CSV delimiter (default: sniffed)")
55
+ p.add_argument(
56
+ "--format", dest="fmt", choices=["csv", "tsv", "geojson", "wkt"], help="input format"
57
+ )
58
+ p.add_argument("--top", type=int, default=5, help="number of candidates to show (default 5)")
59
+ p.add_argument("--json", action="store_true", help="print JSON")
60
+ p.add_argument(
61
+ "--no-pyproj",
62
+ action="store_true",
63
+ help="use only the bundled CRS subset even if pyproj is installed",
64
+ )
65
+ p.add_argument("--no-swap", action="store_true", help="do not consider swapped axes")
66
+ p.add_argument("--version", action="version", version=f"whatcrs {__version__}")
67
+ return p
68
+
69
+
70
+ def _parse_numbers(tokens: Sequence[str]) -> Optional[List[float]]:
71
+ values: List[float] = []
72
+ for tok in tokens:
73
+ for part in re.split(r"[,;\s]+", tok.strip()):
74
+ if not part:
75
+ continue
76
+ if not _NUMBER.match(part):
77
+ return None
78
+ values.append(float(part))
79
+ return values
80
+
81
+
82
+ def _load(args: argparse.Namespace) -> PointSource:
83
+ tokens: List[str] = args.input
84
+ numbers = _parse_numbers(tokens)
85
+ if numbers is not None:
86
+ if len(numbers) < 2 or len(numbers) % 2:
87
+ raise ValueError("give coordinates as pairs: x y [x y ...]")
88
+ pts = [(numbers[i], numbers[i + 1]) for i in range(0, len(numbers), 2)]
89
+ return PointSource(pts, "arguments")
90
+ if len(tokens) != 1:
91
+ raise ValueError("give either numbers, or a single file / WKT string / '-'")
92
+ tok = tokens[0]
93
+ if tok == "-":
94
+ return read_text(
95
+ sys.stdin.read(), args.fmt, x=args.xcol, y=args.ycol, delimiter=args.delimiter
96
+ )
97
+ path = Path(tok)
98
+ if path.is_file():
99
+ return read_points(path, x=args.xcol, y=args.ycol, delimiter=args.delimiter, fmt=args.fmt)
100
+ if re.match(r"^\s*(SRID\s*=\s*\d+\s*;\s*)?[A-Za-z]+\s*(Z|M|ZM)?\s*\(", tok):
101
+ return read_text(tok, "wkt")
102
+ raise ValueError(f"not a number list, an existing file or WKT: {tok!r}")
103
+
104
+
105
+ def _hemi(lon: float, lat: float) -> str:
106
+ return f"{abs(lat):.5f}°{'N' if lat >= 0 else 'S'} {abs(lon):.5f}°{'E' if lon >= 0 else 'W'}"
107
+
108
+
109
+ def _print_text(src: PointSource, cands: List[Candidate], out: TextIO, hint: Optional[str]) -> None:
110
+ n = len(src.points)
111
+ x0, y0 = src.points[0]
112
+ fields = f" (x={src.x_field}, y={src.y_field})" if src.x_field else ""
113
+ out.write(
114
+ f"whatcrs {__version__}: {n} point{'s' if n != 1 else ''} from {src.format}{fields}\n"
115
+ )
116
+ out.write(f"first point: {x0:.10g}, {y0:.10g}\n")
117
+ for note in src.notes:
118
+ out.write(f"note: {note}\n")
119
+ for note in describe_coordinates(src.points):
120
+ out.write(f"pattern: {note}\n")
121
+ if src.declared_crs:
122
+ out.write(f"declared in file: {src.declared_crs}\n")
123
+ if hint:
124
+ out.write(f"hint: {hint}\n")
125
+ if not cands:
126
+ out.write("\nNo candidate CRS fits these coordinates.\n")
127
+ return
128
+ out.write("\n")
129
+ for i, c in enumerate(cands, 1):
130
+ swap = " [axes swapped]" if c.swapped else ""
131
+ out.write(f"{i}. {c.code} {c.name} - confidence {c.confidence:.0%}{swap}\n")
132
+ where = f" ({c.country})" if c.country else " (at sea)"
133
+ out.write(f" centre: {_hemi(*c.centroid)}{where}\n")
134
+ label = "WGS 84" if c.wgs84 else "lon/lat"
135
+ sample = "; ".join(f"{lon:.5f}, {lat:.5f}" for lon, lat in c.sample_lonlat[:3])
136
+ out.write(f" {label}: {sample}\n")
137
+ for r in c.reasons:
138
+ out.write(f" - {r}\n")
139
+ if src.declared_crs and src.declared_crs.startswith("EPSG:"):
140
+ best = cands[0].code
141
+ verdict = "agrees with" if best == src.declared_crs else "differs from"
142
+ out.write(f"\nBest guess {best} {verdict} the declared {src.declared_crs}.\n")
143
+ if not pyproj_available():
144
+ out.write("\n(bundled CRS subset; `pip install whatcrs[pyproj]` searches all EPSG codes)\n")
145
+
146
+
147
+ def main(argv: Optional[Sequence[str]] = None) -> int:
148
+ for stream in (sys.stdout, sys.stderr):
149
+ reconfigure = getattr(stream, "reconfigure", None)
150
+ if reconfigure is not None:
151
+ try:
152
+ reconfigure(errors="replace")
153
+ except (ValueError, OSError): # pragma: no cover - exotic streams
154
+ pass
155
+ parser = _build_parser()
156
+ args = parser.parse_args(argv)
157
+ try:
158
+ src = _load(args)
159
+ bbox = None
160
+ if args.bbox:
161
+ bbox = [float(v) for v in re.split(r"[,\s]+", args.bbox.strip()) if v]
162
+ cands = guess_crs(
163
+ src.points,
164
+ args.hint,
165
+ bbox=bbox,
166
+ units=args.units,
167
+ top=max(1, args.top),
168
+ use_pyproj=False if args.no_pyproj else None,
169
+ allow_swapped=not args.no_swap,
170
+ )
171
+ except (ValueError, KeyError, OSError) as exc:
172
+ parser.exit(2, f"whatcrs: error: {exc}\n")
173
+ if args.json:
174
+ payload = {
175
+ "version": __version__,
176
+ "n_points": len(src.points),
177
+ "format": src.format,
178
+ "x_field": src.x_field,
179
+ "y_field": src.y_field,
180
+ "declared_crs": src.declared_crs,
181
+ "hint": args.hint,
182
+ "pattern": describe_coordinates(src.points),
183
+ "candidates": [c.to_dict() for c in cands],
184
+ }
185
+ sys.stdout.write(json.dumps(payload, indent=2, ensure_ascii=False) + "\n")
186
+ else:
187
+ _print_text(src, cands, sys.stdout, args.hint)
188
+ return 0 if cands else 1
189
+
190
+
191
+ if __name__ == "__main__": # pragma: no cover
192
+ sys.exit(main())