geodata-sync 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.
@@ -0,0 +1,4 @@
1
+ """geodata-sync: open GIS data loaded into PostGIS, and kept up to date, from YAML files."""
2
+
3
+ __version__ = "0.1.0"
4
+ API_VERSION = "geodata/v1alpha1"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
geodata_sync/cli.py ADDED
@@ -0,0 +1,141 @@
1
+ """The geodata command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from . import __version__
12
+ from .discover import DiscoverError
13
+ from .discover import run as discover
14
+ from .status import run as status
15
+ from .sync import SyncError
16
+ from .sync import run as sync
17
+ from .loader import load
18
+ from .validate import validate
19
+
20
+ def shown(path: Path) -> str:
21
+ """A path relative to the current directory when that's shorter."""
22
+ try:
23
+ rel = os.path.relpath(path)
24
+ except ValueError:
25
+ return str(path)
26
+ return rel if len(rel) < len(str(path)) else str(path)
27
+
28
+
29
+ def cmd_validate(args) -> int:
30
+ root = Path(args.dir)
31
+ if not root.is_dir():
32
+ print(f"geodata: {root} is not a directory", file=sys.stderr)
33
+ return 2
34
+ project = load(root)
35
+ problems = validate(project)
36
+ errors = [p for p in problems if p.severity == "error"]
37
+
38
+ if args.format == "json":
39
+ print(json.dumps([
40
+ {"file": shown(p.file), "line": p.line, "severity": p.severity, "resource": p.resource, "message": p.message}
41
+ for p in problems
42
+ ], indent=2))
43
+ else:
44
+ for p in problems:
45
+ where = f"{shown(p.file)}:{p.line}" if p.line else shown(p.file)
46
+ resource = f"{p.resource}: " if p.resource else ""
47
+ print(f"{where}: {p.severity}: {resource}{p.message}")
48
+ warnings = len(problems) - len(errors)
49
+ summary = f"{len(project.resources)} resources in {len(project.files)} files"
50
+ if problems:
51
+ print(f"\n{len(errors)} error(s), {warnings} warning(s); {summary}")
52
+ else:
53
+ print(f"Valid: {summary}")
54
+ return 1 if errors else 0
55
+
56
+
57
+ def cmd_discover(args) -> int:
58
+ try:
59
+ return discover(
60
+ args.source, args.product, Path(args.dir), fmt=args.format,
61
+ layers=not args.no_layers, use_cache=not args.refresh,
62
+ search=args.search, limit=args.limit, resource=args.resource, url=args.url,
63
+ )
64
+ except DiscoverError as e:
65
+ print(f"geodata discover: {e}", file=sys.stderr)
66
+ return e.code
67
+
68
+
69
+ def cmd_status(args) -> int:
70
+ try:
71
+ return status(Path(args.dir), fmt=args.format)
72
+ except SyncError as e:
73
+ print(f"geodata status: {e}", file=sys.stderr)
74
+ return e.code
75
+
76
+
77
+ def cmd_sync(args) -> int:
78
+ try:
79
+ return sync(Path(args.dir), args.datasets, force=args.force, loaded_file=args.loaded)
80
+ except SyncError as e:
81
+ print(f"geodata sync: {e}", file=sys.stderr)
82
+ return e.code
83
+
84
+
85
+ def main(argv: list[str] | None = None) -> int:
86
+ parser = argparse.ArgumentParser(prog="geodata", description="Open GIS data loaded into PostGIS, and kept up to date, from YAML files.")
87
+ parser.add_argument("--version", action="version", version=f"geodata {__version__}")
88
+ commands = parser.add_subparsers(dest="command", metavar="command")
89
+
90
+ v = commands.add_parser("validate", help="check the files against the spec (no network)")
91
+ v.add_argument("dir", nargs="?", default=".", help="project directory (default: current)")
92
+ v.add_argument("--format", choices=["text", "json"], default="text")
93
+ v.set_defaults(func=cmd_validate)
94
+
95
+ d = commands.add_parser(
96
+ "discover", help="list what a source offers, or the layers inside one product",
97
+ description="List the products a source offers, or the downloads and layers of one product. "
98
+ "SOURCE is a Source in the project (e.g. os) or a source type: os-downloads, ckan, wfs or ogc-api-features (with --url), "
99
+ "or url (the product is then a file's address).",
100
+ )
101
+ d.add_argument("source", help="a Source name from the project, or a source type")
102
+ d.add_argument("product", nargs="?", help="a product ID (a CKAN package, a file's URL), to list its downloads and layers")
103
+ d.add_argument("--search", metavar="WORDS", help="list only products matching these words")
104
+ d.add_argument("--limit", type=int, metavar="N", help="list at most N products (CKAN default: 20)")
105
+ d.add_argument("--resource", metavar="ID", help="read this download of the product (a CKAN resource ID or name)")
106
+ d.add_argument("--url", help="the source's address, e.g. a CKAN portal (overrides the Source's spec.url)")
107
+ d.add_argument("-C", "--dir", default=".", help="project directory, for Sources and secrets (default: current)")
108
+ d.add_argument("--format", choices=["text", "json"], default="text")
109
+ d.add_argument("--no-layers", action="store_true", help="list downloads only; don't read layers from the file")
110
+ d.add_argument("--refresh", action="store_true", help="read layers again instead of using the cache")
111
+ d.set_defaults(func=cmd_discover)
112
+
113
+ st = commands.add_parser("status", help="datasets: loaded, when, next check",
114
+ description="Show the project's datasets from the ingest log. Changes nothing.")
115
+ st.add_argument("-C", "--dir", default=".", help="project directory (default: current)")
116
+ st.add_argument("--format", choices=["text", "json"], default="text")
117
+ st.set_defaults(func=cmd_status)
118
+
119
+ s = commands.add_parser(
120
+ "sync", help="load new versions of datasets into PostGIS",
121
+ description="Check each due Dataset for a new version and load it: stage, check, back up, swap in. "
122
+ "Named datasets are checked whatever their schedule. The database comes from the Project's connection secret.",
123
+ )
124
+ s.add_argument("datasets", nargs="*", help="only these Datasets, due or not")
125
+ s.add_argument("-C", "--dir", default=".", help="project directory (default: current)")
126
+ s.add_argument("--force", action="store_true", help="load even when the version hasn't changed")
127
+ s.add_argument("--loaded", type=Path, metavar="FILE",
128
+ help="write what was loaded to FILE, one per line (schema.table, or a raster's path), for `configops refresh --from FILE`")
129
+ s.set_defaults(func=cmd_sync)
130
+
131
+ argv = sys.argv[1:] if argv is None else argv
132
+ if argv[:1] == ["discover"]:
133
+ # options may come between the source and the product
134
+ args = d.parse_intermixed_args(argv[1:])
135
+ args.command = "discover"
136
+ else:
137
+ args = parser.parse_args(argv)
138
+ if not args.command:
139
+ parser.print_help()
140
+ return 2
141
+ return args.func(args)
geodata_sync/crs.py ADDED
@@ -0,0 +1,21 @@
1
+ """Coordinate reference system names, in the short form the spec uses (EPSG:27700)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ # urn:ogc:def:crs:EPSG::27700, urn:x-ogc:def:crs:EPSG:27700, EPSG:27700,
8
+ # http://www.opengis.net/def/crs/EPSG/0/27700, http://www.opengis.net/gml/srs/epsg.xml#27700
9
+ EPSG = re.compile(r"(?:EPSG(?::[\d.]*)?:|/EPSG/[\d.]+/|epsg\.xml#)(\d+)$", re.IGNORECASE)
10
+ CRS84 = re.compile(r"(?:OGC/1\.3/|OGC:(?:1\.3:)?)CRS84$", re.IGNORECASE)
11
+
12
+
13
+ def short(name: str | None) -> str | None:
14
+ """EPSG:<code> or CRS84 when recognised, else the name as given."""
15
+ if not name:
16
+ return None
17
+ name = name.strip()
18
+ if CRS84.search(name):
19
+ return "CRS84"
20
+ m = EPSG.search(name)
21
+ return f"EPSG:{m.group(1)}" if m else name
@@ -0,0 +1,315 @@
1
+ """`geodata discover`: what a source offers, and the layers inside one product."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import shlex
9
+ import sys
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+ from . import remote, secrets
14
+ from .loader import load
15
+ from .sources import Download, NotFound, Product, SourceError, source_types
16
+ from .sources.formats import SERVICE_TYPES, service_address
17
+
18
+ # Key in a source's credentials secret that holds the API key
19
+ API_KEY = "apiKey"
20
+
21
+
22
+ class DiscoverError(Exception):
23
+ """Ends the command with a message; `code` is the exit code."""
24
+
25
+ def __init__(self, message: str, code: int = 1):
26
+ super().__init__(message)
27
+ self.code = code
28
+
29
+
30
+ @dataclass
31
+ class ResolvedSource:
32
+ label: str # how to name it in messages: the Source's name, or the type
33
+ type: str
34
+ client: object
35
+ command: str # how to run discover on it again, for hints
36
+
37
+
38
+ def resolve(name: str, root: Path, url: str | None = None) -> ResolvedSource:
39
+ """
40
+ A Source named `name` in the project at `root`, or else a source type
41
+ used directly. `url` is the source's address (a CKAN portal); it
42
+ overrides the Source's own.
43
+ """
44
+ types = source_types()
45
+ source = load(root).named("Source").get(name) if root.is_dir() else None
46
+ if source is not None:
47
+ stype = source.spec.get("type")
48
+ if stype not in types:
49
+ raise DiscoverError(f"Source {name} has type '{stype}', which can't be discovered (yet). Types that can: {', '.join(types)}")
50
+ api_key = None
51
+ secret = (source.spec.get("credentials") or {}).get("secret")
52
+ if secret:
53
+ api_key = secrets.lookup(secret, API_KEY, root)
54
+ if api_key is None:
55
+ print(
56
+ f"geodata: note: secret {secret} (key {API_KEY}) isn't set "
57
+ f"(${secrets.env_name(secret, API_KEY)} or secrets.yaml); trying without a key",
58
+ file=sys.stderr,
59
+ )
60
+ c = client(types[stype], api_key, url or source.spec.get("url"))
61
+ return ResolvedSource(name, stype, c, command(name, c, url))
62
+ if name in types:
63
+ c = client(types[name], None, url)
64
+ return ResolvedSource(name, name, c, command(name, c, url))
65
+ raise DiscoverError(
66
+ f"no Source named '{name}' in {root}, and '{name}' isn't a source type. "
67
+ f"Name a Source from the project, or a type: {', '.join(types)}",
68
+ code=2,
69
+ )
70
+
71
+
72
+ def command(name: str, c, url: str | None) -> str:
73
+ """How to run discover on this source again: with its tidied address when one was given."""
74
+ if not url:
75
+ return f"geodata discover {name}"
76
+ address = getattr(c, "url", None) or getattr(c, "portal", None) or url
77
+ return f"geodata discover {name} --url {shlex.quote(address)}"
78
+
79
+
80
+ def client(source_type: type, api_key: str | None, url: str | None):
81
+ try:
82
+ return source_type(api_key=api_key, url=url)
83
+ except SourceError as e:
84
+ raise DiscoverError(str(e), code=2) from None
85
+
86
+
87
+ # --- layer cache: a product version never changes, so its layers don't either
88
+
89
+
90
+ def cache_dir() -> Path:
91
+ base = os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache"
92
+ return Path(base) / "geodata" / "layers"
93
+
94
+
95
+ def cache_path(source_type: str, product: Product) -> Path | None:
96
+ d = product.chosen
97
+ if not product.version or d is None:
98
+ return None
99
+ key = json.dumps([source_type, product.id, product.version, d.id, d.url if d.id else "", d.format, d.subformat, d.area, d.file_name, d.md5])
100
+ return cache_dir() / f"{hashlib.sha256(key.encode()).hexdigest()[:32]}.json"
101
+
102
+
103
+ def cached_layers(path: Path | None) -> list[remote.Layer] | None:
104
+ if path is None:
105
+ return None
106
+ try:
107
+ return [remote.Layer.from_dict(d) for d in json.loads(path.read_text())]
108
+ except (OSError, ValueError, KeyError, TypeError):
109
+ return None
110
+
111
+
112
+ def save_layers(path: Path | None, layers: list[remote.Layer]) -> None:
113
+ if path is None:
114
+ return
115
+ try:
116
+ path.parent.mkdir(parents=True, exist_ok=True)
117
+ path.write_text(json.dumps([layer.to_dict() for layer in layers]))
118
+ except OSError:
119
+ pass # a cache that can't be written only costs time
120
+
121
+
122
+ def product_layers(src: ResolvedSource, product: Product, use_cache: bool = True) -> list[remote.Layer]:
123
+ path = cache_path(src.type, product)
124
+ if use_cache:
125
+ hit = cached_layers(path)
126
+ if hit is not None:
127
+ return hit
128
+ d = product.chosen
129
+ if hasattr(src.client, "layers"): # a service: asked directly, no file
130
+ layers = src.client.layers(product)
131
+ else:
132
+ print(
133
+ f"Reading layers from {d.file_name or d.url} ({size(d.size)}) without downloading it; "
134
+ "a large file can take a minute or two...",
135
+ file=sys.stderr,
136
+ )
137
+ layers = remote.url_layers(src.client.download_url(d), file_name=d.file_name)
138
+ save_layers(path, layers)
139
+ return layers
140
+
141
+
142
+ # --- output
143
+
144
+
145
+ def size(n: int | None) -> str:
146
+ if n is None:
147
+ return "size unknown"
148
+ if n < 1000:
149
+ return f"{n} bytes"
150
+ for unit in ("KB", "MB", "GB"):
151
+ n /= 1000
152
+ if n < 1000 or unit == "GB":
153
+ return f"{n:.1f} {unit}"
154
+
155
+
156
+ def table(rows: list[list[str]]) -> str:
157
+ widths = [max(len(r[i]) for r in rows) for i in range(len(rows[0]))]
158
+ return "\n".join(" ".join(c.ljust(w) for c, w in zip(r, widths)).rstrip() for r in rows)
159
+
160
+
161
+ def short(text: str, width: int = 70) -> str:
162
+ text = " ".join(text.split())
163
+ return text if len(text) <= width else text[: width - 1] + "…"
164
+
165
+
166
+ def version(v: str | None) -> str:
167
+ """A date-time version (CKAN's last change) shown as its date."""
168
+ if not v:
169
+ return "-"
170
+ return v[:10] if len(v) > 10 and v[4:5] == "-" and v[10:11] == "T" else v
171
+
172
+
173
+ def print_products(src: ResolvedSource, products: list[Product], total: int | None, search: str | None) -> None:
174
+ if not products:
175
+ print(f"No products from {src.label}" + (f" match '{search}'" if search else ""))
176
+ return
177
+ def via_service(p: Product) -> str | None:
178
+ return next((f for f in p.formats if f in SERVICE_TYPES), None)
179
+
180
+ rows = [["PRODUCT", "VERSION", "LOADS AS", "NAME"]]
181
+ for p in products:
182
+ loads = p.loads_as or (f"via {via_service(p)}" if via_service(p) else "-")
183
+ rows.append([p.id, version(p.version), loads, short(p.name)])
184
+ print(table(rows))
185
+ services = sum(1 for p in products if not p.loads_as and via_service(p))
186
+ skipped = sum(1 for p in products if not p.loads_as) - services
187
+ shown = f"{len(products)} of {total:,}" if total and total > len(products) else f"{len(products)}"
188
+ print(f"\n{shown} product{'s' if len(products) != 1 or total != 1 else ''} from {src.label}" + (f" matching '{search}'" if search else ""), end="")
189
+ print(f"; {skipped} have nothing geodata can load" if skipped else "")
190
+ if services:
191
+ print(f"{services} offer only a WFS or OGC API service: the product's details show how to discover it")
192
+ if total and total > len(products):
193
+ print("More with --limit, or narrow with --search")
194
+ print(f"Layers inside one: {src.command} <product>")
195
+
196
+
197
+ def download_line(d: Download, used: bool) -> str:
198
+ label = f"{d.format} {d.subformat}".strip()
199
+ file_name = d.file_name if "." in d.file_name and d.file_name != d.name else "" # not ".../download"
200
+ what = ", ".join(x for x in (d.name, file_name) if x) or d.area or d.url
201
+ line = f" {'*' if used else ' '} {label}: {short(what, 60)}"
202
+ if d.loadable:
203
+ line += f", {size(d.size)}"
204
+ elif d.format in SERVICE_TYPES:
205
+ line += " (a service, not a file)"
206
+ else:
207
+ line += " (not a file geodata loads)"
208
+ line += f" [{d.id}]" if d.id else ""
209
+ if not d.loadable and d.format in SERVICE_TYPES:
210
+ line += f"\n discover it: geodata discover {SERVICE_TYPES[d.format]} --url {shlex.quote(service_address(d.format, d.url))}"
211
+ return line
212
+
213
+
214
+ def print_product(src: ResolvedSource, p: Product, layers: list[remote.Layer] | None) -> None:
215
+ if src.type == "url":
216
+ print(f"{p.name}\n {p.id}")
217
+ else:
218
+ print(f"{p.name} ({p.id})" + (f", version {version(p.version)}" if p.version else ""))
219
+ if p.chosen is not None and p.chosen.service:
220
+ print(f" {f'{p.chosen.format} {p.chosen.subformat}'.strip()}: {p.chosen.url}")
221
+ if p.description:
222
+ print(f" {short(p.description, 100)}")
223
+ print_layers(layers, footer=f"In a Dataset: `product: {p.id}`; the table defaults to <dataset>_<layer>, so `tables` can name it.")
224
+ return
225
+ groups: dict[str, list[Download]] = {}
226
+ for d in p.downloads:
227
+ groups.setdefault(f"{d.format} {d.subformat}".strip(), []).append(d)
228
+ for label, ds in groups.items():
229
+ used = p.chosen in ds
230
+ if len(ds) > 1 and all(d.area and not d.id for d in ds):
231
+ # one format cut into many areas (OS grid squares): one line
232
+ line = f" {'*' if used else ' '} {label}: {len(ds)} files by area"
233
+ if used:
234
+ line += f"; using {p.chosen.file_name or p.chosen.area}, {size(p.chosen.size)}"
235
+ print(line)
236
+ else:
237
+ for d in ds:
238
+ print(download_line(d, d is p.chosen))
239
+ if not p.downloads:
240
+ print("\nNo downloads listed.")
241
+ return
242
+ if p.chosen is None:
243
+ print(f"\nNo download geodata can load (it reads {', '.join(src.client.formats)} as one file).")
244
+ return
245
+ print(" (* the download sync uses)")
246
+ if sum(1 for d in p.downloads if d.loadable) > 1 and p.chosen.id:
247
+ print(f"Another one: --resource <id or name>; in a Dataset, `resource: {p.chosen.id}`")
248
+ print_layers(layers)
249
+
250
+
251
+ def print_layers(layers: list[remote.Layer] | None, footer: str = "") -> None:
252
+ if layers is None:
253
+ return
254
+ print()
255
+ rows = [["LAYER", "GEOMETRY", "CRS", "FEATURES"]]
256
+ for layer in layers:
257
+ rows.append([
258
+ layer.name,
259
+ layer.geometry or "none",
260
+ layer.crs or "-",
261
+ f"{layer.features:,}" if layer.features is not None else "-",
262
+ ])
263
+ print(table(rows))
264
+ files = sorted({layer.file for layer in layers if layer.file})
265
+ if len(files) > 1:
266
+ print(f"\nfrom {len(files)} files: {', '.join(files)}")
267
+ print("\n" + (footer or "A Dataset loads every layer unless `tables` names some; each becomes table <dataset>_<layer>."))
268
+
269
+
270
+ def run(
271
+ source: str, product_id: str | None, root: Path, fmt: str = "text", layers: bool = True, use_cache: bool = True,
272
+ search: str | None = None, limit: int | None = None, resource: str | None = None, url: str | None = None,
273
+ ) -> int:
274
+ src = resolve(source, root, url)
275
+ try:
276
+ if product_id is None:
277
+ if resource:
278
+ raise DiscoverError("--resource needs a product", code=2)
279
+ listing = src.client.products(search, limit)
280
+ if fmt == "json":
281
+ print(json.dumps({"total": listing.total, "products": [p.to_dict() for p in listing.products]}, indent=2, ensure_ascii=False))
282
+ else:
283
+ print_products(src, listing.products, listing.total, search)
284
+ return 0
285
+
286
+ if search:
287
+ raise DiscoverError("--search lists products; leave out the product to search", code=2)
288
+ try:
289
+ product = src.client.product(product_id, resource)
290
+ except NotFound:
291
+ raise DiscoverError(f"{src.label} has no product '{product_id}'; find one with: {src.command} --search <words>") from None
292
+ except SourceError as e:
293
+ raise DiscoverError(str(e)) from None
294
+
295
+ # A file that can't be read still leaves the downloads worth showing
296
+ found, layer_error = None, None
297
+ if layers and product.chosen:
298
+ try:
299
+ found = product_layers(src, product, use_cache)
300
+ except (SourceError, remote.RemoteError) as e:
301
+ layer_error = f"couldn't read layers: {e}"
302
+
303
+ if fmt == "json":
304
+ out = product.to_dict()
305
+ out["downloads"] = [d.to_dict() for d in product.downloads]
306
+ out["download"] = product.chosen.to_dict() if product.chosen else None
307
+ out["layers"] = [layer.to_dict() for layer in found] if found is not None else None
308
+ out["layersError"] = layer_error
309
+ print(json.dumps(out, indent=2, ensure_ascii=False))
310
+ else:
311
+ print_product(src, product, found)
312
+ if layer_error:
313
+ print(f"geodata discover: {layer_error}", file=sys.stderr)
314
+ return 1
315
+ return 0