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.
- geodata_sync/__init__.py +4 -0
- geodata_sync/__main__.py +3 -0
- geodata_sync/cli.py +141 -0
- geodata_sync/crs.py +21 -0
- geodata_sync/discover.py +315 -0
- geodata_sync/ingest.py +346 -0
- geodata_sync/loader.py +132 -0
- geodata_sync/raster.py +132 -0
- geodata_sync/remote.py +238 -0
- geodata_sync/schedule.py +96 -0
- geodata_sync/schema/v1alpha1.json +288 -0
- geodata_sync/secrets.py +55 -0
- geodata_sync/sources/__init__.py +105 -0
- geodata_sync/sources/ckan.py +135 -0
- geodata_sync/sources/formats.py +100 -0
- geodata_sync/sources/ogcapi.py +115 -0
- geodata_sync/sources/os_downloads.py +134 -0
- geodata_sync/sources/url.py +99 -0
- geodata_sync/sources/wfs.py +123 -0
- geodata_sync/status.py +90 -0
- geodata_sync/sync.py +227 -0
- geodata_sync/validate.py +188 -0
- geodata_sync-0.1.0.dist-info/METADATA +135 -0
- geodata_sync-0.1.0.dist-info/RECORD +28 -0
- geodata_sync-0.1.0.dist-info/WHEEL +5 -0
- geodata_sync-0.1.0.dist-info/entry_points.txt +2 -0
- geodata_sync-0.1.0.dist-info/licenses/LICENSE +21 -0
- geodata_sync-0.1.0.dist-info/top_level.txt +1 -0
geodata_sync/__init__.py
ADDED
geodata_sync/__main__.py
ADDED
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
|
geodata_sync/discover.py
ADDED
|
@@ -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
|