geodata-sync 0.1.0__tar.gz

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.
Files changed (39) hide show
  1. geodata_sync-0.1.0/LICENSE +21 -0
  2. geodata_sync-0.1.0/PKG-INFO +135 -0
  3. geodata_sync-0.1.0/README.md +119 -0
  4. geodata_sync-0.1.0/geodata_sync/__init__.py +4 -0
  5. geodata_sync-0.1.0/geodata_sync/__main__.py +3 -0
  6. geodata_sync-0.1.0/geodata_sync/cli.py +141 -0
  7. geodata_sync-0.1.0/geodata_sync/crs.py +21 -0
  8. geodata_sync-0.1.0/geodata_sync/discover.py +315 -0
  9. geodata_sync-0.1.0/geodata_sync/ingest.py +346 -0
  10. geodata_sync-0.1.0/geodata_sync/loader.py +132 -0
  11. geodata_sync-0.1.0/geodata_sync/raster.py +132 -0
  12. geodata_sync-0.1.0/geodata_sync/remote.py +238 -0
  13. geodata_sync-0.1.0/geodata_sync/schedule.py +96 -0
  14. geodata_sync-0.1.0/geodata_sync/schema/v1alpha1.json +288 -0
  15. geodata_sync-0.1.0/geodata_sync/secrets.py +55 -0
  16. geodata_sync-0.1.0/geodata_sync/sources/__init__.py +105 -0
  17. geodata_sync-0.1.0/geodata_sync/sources/ckan.py +135 -0
  18. geodata_sync-0.1.0/geodata_sync/sources/formats.py +100 -0
  19. geodata_sync-0.1.0/geodata_sync/sources/ogcapi.py +115 -0
  20. geodata_sync-0.1.0/geodata_sync/sources/os_downloads.py +134 -0
  21. geodata_sync-0.1.0/geodata_sync/sources/url.py +99 -0
  22. geodata_sync-0.1.0/geodata_sync/sources/wfs.py +123 -0
  23. geodata_sync-0.1.0/geodata_sync/status.py +90 -0
  24. geodata_sync-0.1.0/geodata_sync/sync.py +227 -0
  25. geodata_sync-0.1.0/geodata_sync/validate.py +188 -0
  26. geodata_sync-0.1.0/geodata_sync.egg-info/PKG-INFO +135 -0
  27. geodata_sync-0.1.0/geodata_sync.egg-info/SOURCES.txt +37 -0
  28. geodata_sync-0.1.0/geodata_sync.egg-info/dependency_links.txt +1 -0
  29. geodata_sync-0.1.0/geodata_sync.egg-info/entry_points.txt +2 -0
  30. geodata_sync-0.1.0/geodata_sync.egg-info/requires.txt +7 -0
  31. geodata_sync-0.1.0/geodata_sync.egg-info/top_level.txt +1 -0
  32. geodata_sync-0.1.0/pyproject.toml +33 -0
  33. geodata_sync-0.1.0/setup.cfg +4 -0
  34. geodata_sync-0.1.0/tests/test_discover.py +661 -0
  35. geodata_sync-0.1.0/tests/test_discover_services.py +331 -0
  36. geodata_sync-0.1.0/tests/test_raster.py +137 -0
  37. geodata_sync-0.1.0/tests/test_schedule.py +20 -0
  38. geodata_sync-0.1.0/tests/test_sync.py +175 -0
  39. geodata_sync-0.1.0/tests/test_sync_integration.py +228 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nathan Leeks
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,135 @@
1
+ Metadata-Version: 2.4
2
+ Name: geodata-sync
3
+ Version: 0.1.0
4
+ Summary: Open GIS data (Ordnance Survey, CKAN portals, WFS, OGC API Features, plain links) loaded into PostGIS and kept up to date, from YAML files
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: pyyaml>=6
10
+ Requires-Dist: jsonschema>=4.18
11
+ Requires-Dist: requests>=2.28
12
+ Requires-Dist: psycopg2-binary>=2.9
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest; extra == "dev"
15
+ Dynamic: license-file
16
+
17
+ # geodata-sync
18
+
19
+ Open GIS data loaded into PostGIS, and kept up to date, from YAML files. Say which datasets you want, where they come from and how often to check them; `geodata sync` downloads each new version, loads it into staging tables, checks it, backs up the old tables and swaps the new ones in, in one transaction.
20
+
21
+ - **Find data:** the Ordnance Survey's OpenData downloads, CKAN portals (data.gov.uk and many city, regional and national ones), WFS and OGC API Features services, or plain links. `geodata discover` lists what a source offers and the layers inside a download.
22
+ - **Load it safely:** staging, invalid geometries left out, row counts checked, the old tables kept as backups; a load that fails changes nothing.
23
+ - **Keep it up to date:** each dataset has a schedule (`daily`, `weekly`, `monthly` or a cron expression); `geodata sync` run nightly checks those that are due, and loads only a new version.
24
+ - **Rasters too:** a raster download (OS Terrain 50's 2,800 grid tiles, say) becomes one Cloud-Optimised GeoTIFF.
25
+
26
+ It only loads data. To publish the tables in GeoServer or GeoServer Cloud, use [GeoServer ConfigOps](https://github.com/Yibbzz/geoserver-configops): `configops refresh` brings GeoServer up to date after a load.
27
+
28
+ > **Status: early.** Expect breaking changes.
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ pip install geodata-sync # the geodata command; it needs GDAL's tools (apt install gdal-bin)
34
+ docker run --rm ghcr.io/yibbzz/geodata-sync --help # or its image, GDAL included
35
+ ```
36
+
37
+ ## Quick start
38
+
39
+ With `docker` and `geodata`. This starts a PostGIS database ([`examples/compose.yaml`](examples/compose.yaml)) and loads [`examples/quickstart`](examples/quickstart/project.yaml): Surrey Heath's conservation areas, from data.gov.uk.
40
+
41
+ ```yaml
42
+ apiVersion: geodata/v1alpha1
43
+ kind: Project
44
+ metadata: { name: quickstart }
45
+ spec:
46
+ database:
47
+ connection: { secret: database } # keys: host, port, database, username, password
48
+ crs: EPSG:27700
49
+ ---
50
+ apiVersion: geodata/v1alpha1
51
+ kind: Source
52
+ metadata: { name: data-gov-uk }
53
+ spec: { type: ckan, url: https://data.gov.uk }
54
+ ---
55
+ apiVersion: geodata/v1alpha1
56
+ kind: Dataset
57
+ metadata: { name: conservation-areas }
58
+ spec:
59
+ source: data-gov-uk
60
+ product: conservation-areas125 # `geodata discover data-gov-uk --search "conservation areas"` finds it
61
+ update: monthly
62
+ tables:
63
+ conservation_areas: {} # the layer in the download; table conservation_areas_conservation_areas
64
+ ```
65
+
66
+ ```bash
67
+ docker compose -f examples/compose.yaml up -d --wait
68
+
69
+ cat > examples/quickstart/secrets.yaml <<'EOF' # git-ignored
70
+ database: { host: localhost, port: "5433", database: postgres, username: postgres, password: postgres }
71
+ EOF
72
+
73
+ geodata sync -C examples/quickstart
74
+ # conservation-areas: loaded 2026-11-11 in 0s: data_layers.conservation_areas_conservation_areas (10 rows)
75
+ # note: 1 invalid geometry left out
76
+ geodata status -C examples/quickstart
77
+ # DATASET UPDATE LOADED AT ROWS NEXT CHECK LAST CHECK
78
+ # conservation-areas monthly 2026-11-11 2026-11-13 08:51 10 2026-12-13 07:51 success
79
+ geodata sync -C examples/quickstart
80
+ # conservation-areas: not due (monthly)
81
+ ```
82
+
83
+ `docker compose -f examples/compose.yaml down -v` removes it all. [docs/spec.md](docs/spec.md) describes every kind, field and command.
84
+
85
+ ## Examples
86
+
87
+ | Project | What it holds |
88
+ | --- | --- |
89
+ | [`examples/quickstart`](examples/quickstart/) | The quick start above: one dataset from data.gov.uk, 10 polygons. Loads in seconds |
90
+ | [`examples/uk-basemap`](examples/uk-basemap/) | OS Open Roads (7.3M features), OS Boundary-Line, the Environment Agency's flood zones (415k polygons) and the conservation areas. Downloads about 3 GB; the first load takes 20 minutes or so. GeoServer ConfigOps's `examples/uk-basemap` publishes them |
91
+ | [`examples/terrain`](examples/terrain/) | OS Terrain 50 (heights of Great Britain, 50 m) made into one Cloud-Optimised GeoTIFF |
92
+
93
+ ## How to
94
+
95
+ **Find a dataset:**
96
+
97
+ ```bash
98
+ geodata discover os-downloads # OS products
99
+ geodata discover data-gov-uk --search "flood zones" -C my-data # a Source in the project
100
+ geodata discover data-gov-uk <product> -C my-data # its files and the layers inside
101
+ geodata validate my-data # problems come out as file:line
102
+ ```
103
+
104
+ **Load now** rather than when due: `geodata sync -C my-data <dataset>` (`--force` loads it even when the version hasn't changed).
105
+
106
+ **Refresh GeoServer after a load:** `geodata sync -C my-data --loaded loaded.txt` writes what it loaded (`schema.table`, or a raster's path), and `configops refresh -C my-geoserver --from loaded.txt` works out the bounds of the layers on them again and clears their cached tiles.
107
+
108
+ **Keep the logins out of the files.** A project names Secrets (`connection: { secret: database }`); `geodata` reads each from a git-ignored `secrets.yaml` in the project folder, from `GEODATA_<SECRET>_<KEY>` environment variables, or from files `$GEODATA_SECRETS_DIR/<secret>/<key>` (how Kubernetes mounts Secrets).
109
+
110
+ **Run it in Kubernetes:** [docs/kubernetes.md](docs/kubernetes.md) has a nightly CronJob that loads, then refreshes GeoServer.
111
+
112
+ [docs/sources-notes.md](docs/sources-notes.md) has what we learned about the sources: big layers over WFS or OGC API Features don't load reliably, so use a file download when there is one.
113
+
114
+ ## Testing
115
+
116
+ ```bash
117
+ python3 -m venv .venv && .venv/bin/pip install -e '.[dev]'
118
+ .venv/bin/pytest # the PostGIS tests skip without one
119
+
120
+ docker run -d --rm --name test-postgis -p 55432:5432 \
121
+ -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=gis postgis/postgis:18-3.7
122
+ TEST_DB_HOST=localhost TEST_DB_PORT=55432 .venv/bin/pytest -m integration
123
+ ```
124
+
125
+ CI runs the tests with a real PostGIS (test), shellcheck and a schema check of the Kubernetes example (lint), and the image build (image).
126
+
127
+ ## Releasing
128
+
129
+ Set the version in `pyproject.toml` and `geodata_sync/__init__.py`, merge, then push the tag `v<version>`: the Release workflow publishes the package to PyPI (`geodata-sync`), the image to `ghcr.io/yibbzz/geodata-sync`, and a GitHub release.
130
+
131
+ ## Licence and data attribution
132
+
133
+ MIT (see `LICENSE`). This code started in [GeoServer ConfigOps](https://github.com/Yibbzz/geoserver-configops), where it was the `sync` and `discover` commands.
134
+
135
+ Data you load keeps its own licence. OS OpenData is released under the [Open Government Licence v3.0](https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/) and needs the attribution "Contains OS data © Crown copyright and database right &lt;year&gt;" wherever it's shown.
@@ -0,0 +1,119 @@
1
+ # geodata-sync
2
+
3
+ Open GIS data loaded into PostGIS, and kept up to date, from YAML files. Say which datasets you want, where they come from and how often to check them; `geodata sync` downloads each new version, loads it into staging tables, checks it, backs up the old tables and swaps the new ones in, in one transaction.
4
+
5
+ - **Find data:** the Ordnance Survey's OpenData downloads, CKAN portals (data.gov.uk and many city, regional and national ones), WFS and OGC API Features services, or plain links. `geodata discover` lists what a source offers and the layers inside a download.
6
+ - **Load it safely:** staging, invalid geometries left out, row counts checked, the old tables kept as backups; a load that fails changes nothing.
7
+ - **Keep it up to date:** each dataset has a schedule (`daily`, `weekly`, `monthly` or a cron expression); `geodata sync` run nightly checks those that are due, and loads only a new version.
8
+ - **Rasters too:** a raster download (OS Terrain 50's 2,800 grid tiles, say) becomes one Cloud-Optimised GeoTIFF.
9
+
10
+ It only loads data. To publish the tables in GeoServer or GeoServer Cloud, use [GeoServer ConfigOps](https://github.com/Yibbzz/geoserver-configops): `configops refresh` brings GeoServer up to date after a load.
11
+
12
+ > **Status: early.** Expect breaking changes.
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ pip install geodata-sync # the geodata command; it needs GDAL's tools (apt install gdal-bin)
18
+ docker run --rm ghcr.io/yibbzz/geodata-sync --help # or its image, GDAL included
19
+ ```
20
+
21
+ ## Quick start
22
+
23
+ With `docker` and `geodata`. This starts a PostGIS database ([`examples/compose.yaml`](examples/compose.yaml)) and loads [`examples/quickstart`](examples/quickstart/project.yaml): Surrey Heath's conservation areas, from data.gov.uk.
24
+
25
+ ```yaml
26
+ apiVersion: geodata/v1alpha1
27
+ kind: Project
28
+ metadata: { name: quickstart }
29
+ spec:
30
+ database:
31
+ connection: { secret: database } # keys: host, port, database, username, password
32
+ crs: EPSG:27700
33
+ ---
34
+ apiVersion: geodata/v1alpha1
35
+ kind: Source
36
+ metadata: { name: data-gov-uk }
37
+ spec: { type: ckan, url: https://data.gov.uk }
38
+ ---
39
+ apiVersion: geodata/v1alpha1
40
+ kind: Dataset
41
+ metadata: { name: conservation-areas }
42
+ spec:
43
+ source: data-gov-uk
44
+ product: conservation-areas125 # `geodata discover data-gov-uk --search "conservation areas"` finds it
45
+ update: monthly
46
+ tables:
47
+ conservation_areas: {} # the layer in the download; table conservation_areas_conservation_areas
48
+ ```
49
+
50
+ ```bash
51
+ docker compose -f examples/compose.yaml up -d --wait
52
+
53
+ cat > examples/quickstart/secrets.yaml <<'EOF' # git-ignored
54
+ database: { host: localhost, port: "5433", database: postgres, username: postgres, password: postgres }
55
+ EOF
56
+
57
+ geodata sync -C examples/quickstart
58
+ # conservation-areas: loaded 2026-11-11 in 0s: data_layers.conservation_areas_conservation_areas (10 rows)
59
+ # note: 1 invalid geometry left out
60
+ geodata status -C examples/quickstart
61
+ # DATASET UPDATE LOADED AT ROWS NEXT CHECK LAST CHECK
62
+ # conservation-areas monthly 2026-11-11 2026-11-13 08:51 10 2026-12-13 07:51 success
63
+ geodata sync -C examples/quickstart
64
+ # conservation-areas: not due (monthly)
65
+ ```
66
+
67
+ `docker compose -f examples/compose.yaml down -v` removes it all. [docs/spec.md](docs/spec.md) describes every kind, field and command.
68
+
69
+ ## Examples
70
+
71
+ | Project | What it holds |
72
+ | --- | --- |
73
+ | [`examples/quickstart`](examples/quickstart/) | The quick start above: one dataset from data.gov.uk, 10 polygons. Loads in seconds |
74
+ | [`examples/uk-basemap`](examples/uk-basemap/) | OS Open Roads (7.3M features), OS Boundary-Line, the Environment Agency's flood zones (415k polygons) and the conservation areas. Downloads about 3 GB; the first load takes 20 minutes or so. GeoServer ConfigOps's `examples/uk-basemap` publishes them |
75
+ | [`examples/terrain`](examples/terrain/) | OS Terrain 50 (heights of Great Britain, 50 m) made into one Cloud-Optimised GeoTIFF |
76
+
77
+ ## How to
78
+
79
+ **Find a dataset:**
80
+
81
+ ```bash
82
+ geodata discover os-downloads # OS products
83
+ geodata discover data-gov-uk --search "flood zones" -C my-data # a Source in the project
84
+ geodata discover data-gov-uk <product> -C my-data # its files and the layers inside
85
+ geodata validate my-data # problems come out as file:line
86
+ ```
87
+
88
+ **Load now** rather than when due: `geodata sync -C my-data <dataset>` (`--force` loads it even when the version hasn't changed).
89
+
90
+ **Refresh GeoServer after a load:** `geodata sync -C my-data --loaded loaded.txt` writes what it loaded (`schema.table`, or a raster's path), and `configops refresh -C my-geoserver --from loaded.txt` works out the bounds of the layers on them again and clears their cached tiles.
91
+
92
+ **Keep the logins out of the files.** A project names Secrets (`connection: { secret: database }`); `geodata` reads each from a git-ignored `secrets.yaml` in the project folder, from `GEODATA_<SECRET>_<KEY>` environment variables, or from files `$GEODATA_SECRETS_DIR/<secret>/<key>` (how Kubernetes mounts Secrets).
93
+
94
+ **Run it in Kubernetes:** [docs/kubernetes.md](docs/kubernetes.md) has a nightly CronJob that loads, then refreshes GeoServer.
95
+
96
+ [docs/sources-notes.md](docs/sources-notes.md) has what we learned about the sources: big layers over WFS or OGC API Features don't load reliably, so use a file download when there is one.
97
+
98
+ ## Testing
99
+
100
+ ```bash
101
+ python3 -m venv .venv && .venv/bin/pip install -e '.[dev]'
102
+ .venv/bin/pytest # the PostGIS tests skip without one
103
+
104
+ docker run -d --rm --name test-postgis -p 55432:5432 \
105
+ -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=gis postgis/postgis:18-3.7
106
+ TEST_DB_HOST=localhost TEST_DB_PORT=55432 .venv/bin/pytest -m integration
107
+ ```
108
+
109
+ CI runs the tests with a real PostGIS (test), shellcheck and a schema check of the Kubernetes example (lint), and the image build (image).
110
+
111
+ ## Releasing
112
+
113
+ Set the version in `pyproject.toml` and `geodata_sync/__init__.py`, merge, then push the tag `v<version>`: the Release workflow publishes the package to PyPI (`geodata-sync`), the image to `ghcr.io/yibbzz/geodata-sync`, and a GitHub release.
114
+
115
+ ## Licence and data attribution
116
+
117
+ MIT (see `LICENSE`). This code started in [GeoServer ConfigOps](https://github.com/Yibbzz/geoserver-configops), where it was the `sync` and `discover` commands.
118
+
119
+ Data you load keeps its own licence. OS OpenData is released under the [Open Government Licence v3.0](https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/) and needs the attribution "Contains OS data © Crown copyright and database right &lt;year&gt;" wherever it's shown.
@@ -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())
@@ -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)
@@ -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