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.
- geodata_sync-0.1.0/LICENSE +21 -0
- geodata_sync-0.1.0/PKG-INFO +135 -0
- geodata_sync-0.1.0/README.md +119 -0
- geodata_sync-0.1.0/geodata_sync/__init__.py +4 -0
- geodata_sync-0.1.0/geodata_sync/__main__.py +3 -0
- geodata_sync-0.1.0/geodata_sync/cli.py +141 -0
- geodata_sync-0.1.0/geodata_sync/crs.py +21 -0
- geodata_sync-0.1.0/geodata_sync/discover.py +315 -0
- geodata_sync-0.1.0/geodata_sync/ingest.py +346 -0
- geodata_sync-0.1.0/geodata_sync/loader.py +132 -0
- geodata_sync-0.1.0/geodata_sync/raster.py +132 -0
- geodata_sync-0.1.0/geodata_sync/remote.py +238 -0
- geodata_sync-0.1.0/geodata_sync/schedule.py +96 -0
- geodata_sync-0.1.0/geodata_sync/schema/v1alpha1.json +288 -0
- geodata_sync-0.1.0/geodata_sync/secrets.py +55 -0
- geodata_sync-0.1.0/geodata_sync/sources/__init__.py +105 -0
- geodata_sync-0.1.0/geodata_sync/sources/ckan.py +135 -0
- geodata_sync-0.1.0/geodata_sync/sources/formats.py +100 -0
- geodata_sync-0.1.0/geodata_sync/sources/ogcapi.py +115 -0
- geodata_sync-0.1.0/geodata_sync/sources/os_downloads.py +134 -0
- geodata_sync-0.1.0/geodata_sync/sources/url.py +99 -0
- geodata_sync-0.1.0/geodata_sync/sources/wfs.py +123 -0
- geodata_sync-0.1.0/geodata_sync/status.py +90 -0
- geodata_sync-0.1.0/geodata_sync/sync.py +227 -0
- geodata_sync-0.1.0/geodata_sync/validate.py +188 -0
- geodata_sync-0.1.0/geodata_sync.egg-info/PKG-INFO +135 -0
- geodata_sync-0.1.0/geodata_sync.egg-info/SOURCES.txt +37 -0
- geodata_sync-0.1.0/geodata_sync.egg-info/dependency_links.txt +1 -0
- geodata_sync-0.1.0/geodata_sync.egg-info/entry_points.txt +2 -0
- geodata_sync-0.1.0/geodata_sync.egg-info/requires.txt +7 -0
- geodata_sync-0.1.0/geodata_sync.egg-info/top_level.txt +1 -0
- geodata_sync-0.1.0/pyproject.toml +33 -0
- geodata_sync-0.1.0/setup.cfg +4 -0
- geodata_sync-0.1.0/tests/test_discover.py +661 -0
- geodata_sync-0.1.0/tests/test_discover_services.py +331 -0
- geodata_sync-0.1.0/tests/test_raster.py +137 -0
- geodata_sync-0.1.0/tests/test_schedule.py +20 -0
- geodata_sync-0.1.0/tests/test_sync.py +175 -0
- 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 <year>" 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 <year>" wherever it's shown.
|
|
@@ -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
|