bcdata 0.16.0__tar.gz → 0.17.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.
- {bcdata-0.16.0 → bcdata-0.17.0}/PKG-INFO +16 -16
- {bcdata-0.16.0 → bcdata-0.17.0}/README.md +12 -11
- {bcdata-0.16.0 → bcdata-0.17.0}/pyproject.toml +3 -4
- {bcdata-0.16.0 → bcdata-0.17.0}/src/bcdata/__init__.py +2 -2
- {bcdata-0.16.0 → bcdata-0.17.0}/src/bcdata/bc2pg.py +15 -16
- {bcdata-0.16.0 → bcdata-0.17.0}/src/bcdata/bcdc.py +19 -18
- {bcdata-0.16.0 → bcdata-0.17.0}/src/bcdata/cli.py +18 -7
- {bcdata-0.16.0 → bcdata-0.17.0}/src/bcdata/database.py +6 -6
- {bcdata-0.16.0 → bcdata-0.17.0}/src/bcdata/wfs.py +97 -63
- {bcdata-0.16.0 → bcdata-0.17.0}/src/bcdata.egg-info/PKG-INFO +16 -16
- {bcdata-0.16.0 → bcdata-0.17.0}/src/bcdata.egg-info/requires.txt +1 -1
- {bcdata-0.16.0 → bcdata-0.17.0}/tests/test_bc2pg.py +4 -3
- {bcdata-0.16.0 → bcdata-0.17.0}/tests/test_cli.py +9 -8
- {bcdata-0.16.0 → bcdata-0.17.0}/tests/test_wfs.py +61 -1
- {bcdata-0.16.0 → bcdata-0.17.0}/LICENSE.txt +0 -0
- {bcdata-0.16.0 → bcdata-0.17.0}/setup.cfg +0 -0
- {bcdata-0.16.0 → bcdata-0.17.0}/src/bcdata.egg-info/SOURCES.txt +0 -0
- {bcdata-0.16.0 → bcdata-0.17.0}/src/bcdata.egg-info/dependency_links.txt +0 -0
- {bcdata-0.16.0 → bcdata-0.17.0}/src/bcdata.egg-info/entry_points.txt +0 -0
- {bcdata-0.16.0 → bcdata-0.17.0}/src/bcdata.egg-info/top_level.txt +0 -0
- {bcdata-0.16.0 → bcdata-0.17.0}/tests/test_bcdc.py +0 -0
|
@@ -1,27 +1,26 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: bcdata
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.17.0
|
|
4
4
|
Summary: Download open data, monitor and report on changes
|
|
5
5
|
Author-email: Simon Norris <snorris@hillcrestgeo.ca>
|
|
6
6
|
Project-URL: Homepage, https://github.com/smnorris/bcdata
|
|
7
7
|
Project-URL: Issues, https://github.com/smnorris/bcdata
|
|
8
8
|
Classifier: Development Status :: 4 - Beta
|
|
9
|
-
Classifier: Programming Language :: Python :: 3.9
|
|
10
9
|
Classifier: Programming Language :: Python :: 3.10
|
|
11
10
|
Classifier: Programming Language :: Python :: 3.11
|
|
12
11
|
Classifier: Programming Language :: Python :: 3.12
|
|
13
12
|
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
-
Classifier: License :: OSI Approved ::
|
|
13
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
15
14
|
Classifier: Topic :: Scientific/Engineering :: GIS
|
|
16
15
|
Classifier: Operating System :: OS Independent
|
|
17
|
-
Requires-Python: >=3.
|
|
16
|
+
Requires-Python: >=3.10
|
|
18
17
|
Description-Content-Type: text/markdown
|
|
19
18
|
License-File: LICENSE.txt
|
|
19
|
+
Requires-Dist: click
|
|
20
20
|
Requires-Dist: geoalchemy2
|
|
21
21
|
Requires-Dist: geopandas
|
|
22
22
|
Requires-Dist: owslib
|
|
23
23
|
Requires-Dist: psycopg2-binary
|
|
24
|
-
Requires-Dist: rasterio
|
|
25
24
|
Requires-Dist: requests
|
|
26
25
|
Requires-Dist: sqlalchemy
|
|
27
26
|
Requires-Dist: stamina
|
|
@@ -42,7 +41,7 @@ There is a [wealth of British Columbia geographic information available as open
|
|
|
42
41
|
data](https://catalogue.data.gov.bc.ca/dataset?download_audience=Public),
|
|
43
42
|
but most sources are available only via WFS - and the syntax to download WFS data via `ogr2ogr` and/or `curl/wget` can be awkward.
|
|
44
43
|
|
|
45
|
-
This tool attempts to simplify downloads of BC geographic data and smoothly integrate with PostGIS and Python GIS tools like `geopandas
|
|
44
|
+
This tool attempts to simplify downloads of BC geographic data and smoothly integrate with PostGIS and Python GIS tools like `geopandas` and `fiona`. The tool only accesses geographic data [avialable via WFS](https://bcgov.github.io/data-publication/pages/tips_tricks_webservices.html) - for other BC open data, download the files directly with `requests` / `curl` / `ogr2ogr` / etc (or the [bcdata R package](https://github.com/bcgov/bcdata)).
|
|
46
45
|
|
|
47
46
|
If downloads are failing, consult the [BCGov Data Systems and Services uptime monitor](https://uptime.com/statuspage/bcgov-dss) - this tool depends on BC Open Geospatial Web Services API at [openmaps.gov.bc.ca](https://openmaps.gov.bc.ca/geo/pub/ows?service=WFS&request=Getcapabilities) and the [BC Data Catalogue API](https://catalogue.data.gov.bc.ca/dataset/bc-data-catalogue-api).
|
|
48
47
|
|
|
@@ -359,19 +358,20 @@ Use some other tool to reproject the data as required.
|
|
|
359
358
|
|
|
360
359
|
## Development and testing
|
|
361
360
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
psql -c "create database test_bcdata"
|
|
365
|
-
psql test_bcdata -c "create extension postgis"
|
|
366
|
-
|
|
367
|
-
Create virtualenv and install `bcdata` in development mode:
|
|
361
|
+
Clone the repo:
|
|
368
362
|
|
|
369
363
|
$ git clone git@github.com:bcgov/bcdata_py.git
|
|
370
364
|
$ cd bcdata_py
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
365
|
+
|
|
366
|
+
`bc2pg` tests require a PostGIS database, referenced via the `DATABASE_URL` environment variable (matching the image used in CI, see `.github/workflows/tests.yml`). Start one locally with `docker compose`:
|
|
367
|
+
|
|
368
|
+
$ docker compose up -d
|
|
369
|
+
$ export DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres
|
|
370
|
+
|
|
371
|
+
install `bcdata` in development mode with [`uv`](https://docs.astral.sh/uv/):
|
|
372
|
+
|
|
373
|
+
$ uv sync --extra test
|
|
374
|
+
$ uv run pytest
|
|
375
375
|
|
|
376
376
|
|
|
377
377
|
## Other implementations
|
|
@@ -9,7 +9,7 @@ There is a [wealth of British Columbia geographic information available as open
|
|
|
9
9
|
data](https://catalogue.data.gov.bc.ca/dataset?download_audience=Public),
|
|
10
10
|
but most sources are available only via WFS - and the syntax to download WFS data via `ogr2ogr` and/or `curl/wget` can be awkward.
|
|
11
11
|
|
|
12
|
-
This tool attempts to simplify downloads of BC geographic data and smoothly integrate with PostGIS and Python GIS tools like `geopandas
|
|
12
|
+
This tool attempts to simplify downloads of BC geographic data and smoothly integrate with PostGIS and Python GIS tools like `geopandas` and `fiona`. The tool only accesses geographic data [avialable via WFS](https://bcgov.github.io/data-publication/pages/tips_tricks_webservices.html) - for other BC open data, download the files directly with `requests` / `curl` / `ogr2ogr` / etc (or the [bcdata R package](https://github.com/bcgov/bcdata)).
|
|
13
13
|
|
|
14
14
|
If downloads are failing, consult the [BCGov Data Systems and Services uptime monitor](https://uptime.com/statuspage/bcgov-dss) - this tool depends on BC Open Geospatial Web Services API at [openmaps.gov.bc.ca](https://openmaps.gov.bc.ca/geo/pub/ows?service=WFS&request=Getcapabilities) and the [BC Data Catalogue API](https://catalogue.data.gov.bc.ca/dataset/bc-data-catalogue-api).
|
|
15
15
|
|
|
@@ -326,19 +326,20 @@ Use some other tool to reproject the data as required.
|
|
|
326
326
|
|
|
327
327
|
## Development and testing
|
|
328
328
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
psql -c "create database test_bcdata"
|
|
332
|
-
psql test_bcdata -c "create extension postgis"
|
|
333
|
-
|
|
334
|
-
Create virtualenv and install `bcdata` in development mode:
|
|
329
|
+
Clone the repo:
|
|
335
330
|
|
|
336
331
|
$ git clone git@github.com:bcgov/bcdata_py.git
|
|
337
332
|
$ cd bcdata_py
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
333
|
+
|
|
334
|
+
`bc2pg` tests require a PostGIS database, referenced via the `DATABASE_URL` environment variable (matching the image used in CI, see `.github/workflows/tests.yml`). Start one locally with `docker compose`:
|
|
335
|
+
|
|
336
|
+
$ docker compose up -d
|
|
337
|
+
$ export DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres
|
|
338
|
+
|
|
339
|
+
install `bcdata` in development mode with [`uv`](https://docs.astral.sh/uv/):
|
|
340
|
+
|
|
341
|
+
$ uv sync --extra test
|
|
342
|
+
$ uv run pytest
|
|
342
343
|
|
|
343
344
|
|
|
344
345
|
## Other implementations
|
|
@@ -11,24 +11,23 @@ authors = [
|
|
|
11
11
|
{name="Simon Norris", email="snorris@hillcrestgeo.ca"},
|
|
12
12
|
]
|
|
13
13
|
description = "Download open data, monitor and report on changes"
|
|
14
|
-
requires-python = ">=3.
|
|
14
|
+
requires-python = ">=3.10"
|
|
15
15
|
classifiers = [
|
|
16
16
|
"Development Status :: 4 - Beta",
|
|
17
|
-
"Programming Language :: Python :: 3.9",
|
|
18
17
|
"Programming Language :: Python :: 3.10",
|
|
19
18
|
"Programming Language :: Python :: 3.11",
|
|
20
19
|
"Programming Language :: Python :: 3.12",
|
|
21
20
|
"Programming Language :: Python :: 3.13",
|
|
22
|
-
"License :: OSI Approved ::
|
|
21
|
+
"License :: OSI Approved :: Apache Software License",
|
|
23
22
|
"Topic :: Scientific/Engineering :: GIS",
|
|
24
23
|
"Operating System :: OS Independent"
|
|
25
24
|
]
|
|
26
25
|
dependencies = [
|
|
26
|
+
"click",
|
|
27
27
|
"geoalchemy2",
|
|
28
28
|
"geopandas",
|
|
29
29
|
"owslib",
|
|
30
30
|
"psycopg2-binary",
|
|
31
|
-
"rasterio",
|
|
32
31
|
"requests",
|
|
33
32
|
"sqlalchemy",
|
|
34
33
|
"stamina"
|
|
@@ -18,7 +18,7 @@ response = requests.get(PRIMARY_KEY_DB_URL)
|
|
|
18
18
|
if response.status_code == 200:
|
|
19
19
|
primary_keys = response.json()
|
|
20
20
|
else:
|
|
21
|
-
raise
|
|
21
|
+
raise ConnectionError(f"Failed to download primary key database at {PRIMARY_KEY_DB_URL}")
|
|
22
22
|
primary_keys = {}
|
|
23
23
|
|
|
24
|
-
__version__ = "0.
|
|
24
|
+
__version__ = "0.17.0"
|
|
@@ -28,7 +28,7 @@ SUPPORTED_TYPES = [
|
|
|
28
28
|
]
|
|
29
29
|
|
|
30
30
|
|
|
31
|
-
def bc2pg(
|
|
31
|
+
def bc2pg(
|
|
32
32
|
dataset,
|
|
33
33
|
db_url,
|
|
34
34
|
table=None,
|
|
@@ -102,21 +102,20 @@ def bc2pg( # noqa: C901
|
|
|
102
102
|
|
|
103
103
|
# if geometry type is still not populated try the last request
|
|
104
104
|
# (in case all entrys with geom are near the bottom)
|
|
105
|
-
if not geometry_type:
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
del df_temp
|
|
105
|
+
if not geometry_type and urls[-1] != urls[0]:
|
|
106
|
+
df_temp = WFS.request_features(
|
|
107
|
+
url=urls[-1],
|
|
108
|
+
as_gdf=True,
|
|
109
|
+
lowercase=True,
|
|
110
|
+
silent=True,
|
|
111
|
+
)
|
|
112
|
+
geometry_type = df_temp.geom_type.unique()[0] # keep only the first type
|
|
113
|
+
if numpy.any(
|
|
114
|
+
df_temp.has_z.unique()[0]
|
|
115
|
+
): # geopandas does not include Z in geom_type string
|
|
116
|
+
geometry_type = geometry_type + "Z"
|
|
117
|
+
# drop the last request dataframe to free up memory
|
|
118
|
+
del df_temp
|
|
120
119
|
|
|
121
120
|
# ensure geom type is valid
|
|
122
121
|
geometry_type = geometry_type.upper()
|
|
@@ -100,25 +100,26 @@ def get_table_definition(table_name):
|
|
|
100
100
|
# iterate through resources associated with each package
|
|
101
101
|
for resource in result["resources"]:
|
|
102
102
|
# only examine geographic resources with object name key
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
103
|
+
# confirm that object name matches table name and schema is present
|
|
104
|
+
if (
|
|
105
|
+
"object_name" in resource
|
|
106
|
+
and resource["bcdc_type"] == "geographic"
|
|
107
|
+
and (
|
|
108
|
+
table_name == resource["object_name"]
|
|
109
|
+
# hack to handle object name / table name mismatch for NR Districts
|
|
110
|
+
or (
|
|
111
|
+
table_name == "WHSE_ADMIN_BOUNDARIES.ADM_NR_DISTRICTS_SPG"
|
|
112
|
+
and resource["object_name"]
|
|
113
|
+
== "WHSE_ADMIN_BOUNDARIES.ADM_NR_DISTRICTS_SP"
|
|
114
114
|
)
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
115
|
+
)
|
|
116
|
+
and "details" in resource
|
|
117
|
+
and resource["details"] != []
|
|
118
|
+
):
|
|
119
|
+
table_definition["schema"] = resource["details"]
|
|
120
|
+
# look for comments only if details/schema was found
|
|
121
|
+
if "object_table_comments" in resource:
|
|
122
|
+
table_definition["comments"] = resource["object_table_comments"]
|
|
122
123
|
|
|
123
124
|
if not table_definition["schema"]:
|
|
124
125
|
log.warning(f"BC Data Catalouge API search provides no schema for: {table_name}")
|
|
@@ -5,13 +5,26 @@ import re
|
|
|
5
5
|
import sys
|
|
6
6
|
|
|
7
7
|
import click
|
|
8
|
-
from cligj import compact_opt, indent_opt, quiet_opt, verbose_opt
|
|
9
8
|
|
|
10
9
|
import bcdata
|
|
11
10
|
from bcdata.database import Database
|
|
12
11
|
|
|
13
12
|
LOG_FORMAT = "%(asctime)s:%(levelname)s:%(name)s: %(message)s"
|
|
14
13
|
|
|
14
|
+
# inlined from cligj (unmaintained), as per
|
|
15
|
+
# https://github.com/rasterio/rasterio/pull/3364
|
|
16
|
+
verbose_opt = click.option("--verbose", "-v", count=True, help="Increase verbosity.")
|
|
17
|
+
|
|
18
|
+
quiet_opt = click.option("--quiet", "-q", count=True, help="Decrease verbosity.")
|
|
19
|
+
|
|
20
|
+
indent_opt = click.option(
|
|
21
|
+
"--indent", type=int, default=None, help="Indentation level for JSON output"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
compact_opt = click.option(
|
|
25
|
+
"--compact/--not-compact", default=False, help="Use compact separators (',', ':')."
|
|
26
|
+
)
|
|
27
|
+
|
|
15
28
|
|
|
16
29
|
def configure_logging(verbosity):
|
|
17
30
|
log_level = max(10, 30 - 10 * verbosity)
|
|
@@ -45,10 +58,8 @@ def bounds_handler(ctx, param, value):
|
|
|
45
58
|
retval = tuple(float(x) for x in re.split(r"[,\s]+", value))
|
|
46
59
|
assert len(retval) == 4
|
|
47
60
|
return retval
|
|
48
|
-
except Exception:
|
|
49
|
-
raise click.BadParameter(
|
|
50
|
-
"{0!r} is not a valid bounding box representation".format(value)
|
|
51
|
-
)
|
|
61
|
+
except Exception: # noqa: BLE001 - any parse failure should become a clean CLI error
|
|
62
|
+
raise click.BadParameter(f"{value!r} is not a valid bounding box representation")
|
|
52
63
|
else: # pragma: no cover
|
|
53
64
|
return retval
|
|
54
65
|
|
|
@@ -396,7 +407,7 @@ def bc2pg(
|
|
|
396
407
|
# if refreshing, flush from temp bcdata schema to target schema
|
|
397
408
|
if refresh:
|
|
398
409
|
db = Database(db_url)
|
|
399
|
-
|
|
410
|
+
_, table = out_table.split(".")
|
|
400
411
|
db.refresh(schema_target, table)
|
|
401
412
|
out_table = schema_target + "." + table
|
|
402
413
|
if timestamp:
|
|
@@ -404,4 +415,4 @@ def bc2pg(
|
|
|
404
415
|
|
|
405
416
|
# do not notify of data load completion when no data load has occured
|
|
406
417
|
if not schema_only:
|
|
407
|
-
log.info("Load of {} to {} in {} complete"
|
|
418
|
+
log.info(f"Load of {dataset} to {out_table} in {db_url} complete")
|
|
@@ -9,12 +9,12 @@ from sqlalchemy.dialects.postgresql import DATE, NUMERIC, VARCHAR
|
|
|
9
9
|
log = logging.getLogger(__name__)
|
|
10
10
|
|
|
11
11
|
|
|
12
|
-
class Database
|
|
12
|
+
class Database:
|
|
13
13
|
"""Wrapper around sqlalchemy"""
|
|
14
14
|
|
|
15
|
-
def __init__(self, url=
|
|
16
|
-
self.url = url
|
|
17
|
-
self.engine = create_engine(url)
|
|
15
|
+
def __init__(self, url=None):
|
|
16
|
+
self.url = url or os.environ.get("DATABASE_URL")
|
|
17
|
+
self.engine = create_engine(self.url)
|
|
18
18
|
# make sure postgis is available
|
|
19
19
|
try:
|
|
20
20
|
self.query("SELECT postgis_full_version()")
|
|
@@ -132,7 +132,7 @@ class Database(object):
|
|
|
132
132
|
):
|
|
133
133
|
"""build sqlalchemy table definition from bcdc provided json definitions"""
|
|
134
134
|
# remove columns of unsupported types, redundant columns
|
|
135
|
-
table_details = [c for c in table_details if c["data_type"] in self.supported_types
|
|
135
|
+
table_details = [c for c in table_details if c["data_type"] in self.supported_types]
|
|
136
136
|
table_details = [
|
|
137
137
|
c
|
|
138
138
|
for c in table_details
|
|
@@ -148,7 +148,7 @@ class Database(object):
|
|
|
148
148
|
if table_details[i]["data_type"] == "VARCHAR2":
|
|
149
149
|
column_type = column_type(int(table_details[i]["data_precision"]))
|
|
150
150
|
# check that comments are present
|
|
151
|
-
if "column_comments" in table_details[i]
|
|
151
|
+
if "column_comments" in table_details[i]:
|
|
152
152
|
column_comments = table_details[i]["column_comments"]
|
|
153
153
|
else:
|
|
154
154
|
column_comments = None
|
|
@@ -5,7 +5,7 @@ import os
|
|
|
5
5
|
import sys
|
|
6
6
|
import warnings
|
|
7
7
|
import xml.etree.ElementTree as ET
|
|
8
|
-
from datetime import datetime, timedelta
|
|
8
|
+
from datetime import datetime, timedelta, timezone
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
from urllib.parse import urlencode
|
|
11
11
|
|
|
@@ -51,7 +51,51 @@ class ServiceException(Exception):
|
|
|
51
51
|
pass
|
|
52
52
|
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
# status codes worth retrying - 5xx are presumed transient service errors,
|
|
55
|
+
# 429 is DataBC's WFS telling us we're rate limited
|
|
56
|
+
RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _rate_limit_backoff(response):
|
|
60
|
+
"""Return the server-specified backoff (seconds) for a 429 response, if given.
|
|
61
|
+
|
|
62
|
+
Checks the standard Retry-After header as well as RateLimit-Reset, which
|
|
63
|
+
is what DataBC's Kong gateway sends. Returns None if neither is present
|
|
64
|
+
or parseable, in which case the caller should fall back to the default
|
|
65
|
+
exponential backoff.
|
|
66
|
+
"""
|
|
67
|
+
for header in ("Retry-After", "RateLimit-Reset"):
|
|
68
|
+
value = response.headers.get(header)
|
|
69
|
+
if value is not None:
|
|
70
|
+
try:
|
|
71
|
+
return float(value)
|
|
72
|
+
except ValueError:
|
|
73
|
+
continue
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _is_retryable(exc):
|
|
78
|
+
"""Decide whether a request exception is worth retrying.
|
|
79
|
+
|
|
80
|
+
Used as the ``on=`` hook for @stamina.retry - see
|
|
81
|
+
https://stamina.hynek.me/en/stable/tutorial.html
|
|
82
|
+
|
|
83
|
+
Returning a float instead of True overrides the default backoff, which
|
|
84
|
+
is used to honour a 429 response's server-specified retry delay.
|
|
85
|
+
"""
|
|
86
|
+
if isinstance(exc, requests.HTTPError):
|
|
87
|
+
response = exc.response
|
|
88
|
+
if response is None or response.status_code not in RETRYABLE_STATUS_CODES:
|
|
89
|
+
return False
|
|
90
|
+
if response.status_code == 429:
|
|
91
|
+
backoff = _rate_limit_backoff(response)
|
|
92
|
+
if backoff is not None:
|
|
93
|
+
return backoff
|
|
94
|
+
return True
|
|
95
|
+
return isinstance(exc, (requests.ConnectionError, requests.Timeout))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class BCWFS:
|
|
55
99
|
"""Wrapper around web feature service"""
|
|
56
100
|
|
|
57
101
|
def __init__(self, refresh=False):
|
|
@@ -94,17 +138,16 @@ class BCWFS(object):
|
|
|
94
138
|
if not os.path.exists(os.path.join(cache_file)):
|
|
95
139
|
return True
|
|
96
140
|
else:
|
|
97
|
-
mod_date = datetime.fromtimestamp(os.path.getmtime(cache_file))
|
|
141
|
+
mod_date = datetime.fromtimestamp(os.path.getmtime(cache_file), tz=timezone.utc)
|
|
98
142
|
# if file older than specified days or empty, return true
|
|
99
|
-
|
|
100
|
-
mod_date < (datetime.now() - timedelta(days=self.cache_refresh_days))
|
|
143
|
+
return (
|
|
144
|
+
mod_date < (datetime.now(tz=timezone.utc) - timedelta(days=self.cache_refresh_days))
|
|
101
145
|
or os.stat(cache_file).st_size == 0
|
|
102
|
-
)
|
|
103
|
-
return True
|
|
104
|
-
else:
|
|
105
|
-
return False
|
|
146
|
+
)
|
|
106
147
|
|
|
107
|
-
@stamina.retry(
|
|
148
|
+
@stamina.retry(
|
|
149
|
+
on=(requests.HTTPError, requests.ConnectionError, requests.Timeout), attempts=5, timeout=300
|
|
150
|
+
)
|
|
108
151
|
def _request_schema(self, table):
|
|
109
152
|
schema = wfs_schema.get_schema(
|
|
110
153
|
"https://openmaps.gov.bc.ca/geo/pub/ows",
|
|
@@ -113,7 +156,9 @@ class BCWFS(object):
|
|
|
113
156
|
)
|
|
114
157
|
return schema
|
|
115
158
|
|
|
116
|
-
@stamina.retry(
|
|
159
|
+
@stamina.retry(
|
|
160
|
+
on=(requests.HTTPError, requests.ConnectionError, requests.Timeout), attempts=5, timeout=300
|
|
161
|
+
)
|
|
117
162
|
def _request_capabilities(self):
|
|
118
163
|
capabilities = ET.tostring(
|
|
119
164
|
wfs200.WebFeatureService_2_0_0(self.ows_url, "2.0.0", None, False)._capabilities,
|
|
@@ -121,7 +166,39 @@ class BCWFS(object):
|
|
|
121
166
|
)
|
|
122
167
|
return capabilities
|
|
123
168
|
|
|
124
|
-
@stamina.retry(on=
|
|
169
|
+
@stamina.retry(on=_is_retryable, attempts=5, timeout=300)
|
|
170
|
+
def _request(self, url, params=None, silent=False):
|
|
171
|
+
"""Submit a request to DataBC WFS and return the raw response.
|
|
172
|
+
|
|
173
|
+
The WFS gateway itself can take up to ~60s to fail with a 504, so
|
|
174
|
+
the retry timeout above is sized to fit several such attempts
|
|
175
|
+
rather than being exhausted by a single one.
|
|
176
|
+
"""
|
|
177
|
+
r = requests.get(url, params=params, headers=self.request_headers, timeout=(10, 65))
|
|
178
|
+
if not silent:
|
|
179
|
+
log.info(r.url)
|
|
180
|
+
else:
|
|
181
|
+
log.debug(r.url)
|
|
182
|
+
if r.status_code in [400, 401, 404]:
|
|
183
|
+
log.error(f"HTTP error {r.status_code}")
|
|
184
|
+
log.error(f"Response headers: {r.headers}")
|
|
185
|
+
log.error(f"Response text: {r.text}")
|
|
186
|
+
raise ServiceException(r.text) # presumed request error
|
|
187
|
+
if r.status_code == 429:
|
|
188
|
+
backoff = _rate_limit_backoff(r)
|
|
189
|
+
if backoff is not None:
|
|
190
|
+
log.warning(
|
|
191
|
+
f"Rate limited by DataBC WFS, backing off for {backoff}s before retrying"
|
|
192
|
+
)
|
|
193
|
+
else:
|
|
194
|
+
log.warning("Rate limited by DataBC WFS, retrying")
|
|
195
|
+
elif r.status_code in RETRYABLE_STATUS_CODES:
|
|
196
|
+
log.warning(f"HTTP error: {r.status_code}, retrying")
|
|
197
|
+
log.warning(f"Response headers: {r.headers}")
|
|
198
|
+
log.warning(f"Response text: {r.text}")
|
|
199
|
+
r.raise_for_status()
|
|
200
|
+
return r
|
|
201
|
+
|
|
125
202
|
def _request_count(self, table, query=None, bounds=None, bounds_crs=None, geom_column=None):
|
|
126
203
|
payload = {
|
|
127
204
|
"service": "WFS",
|
|
@@ -138,60 +215,16 @@ class BCWFS(object):
|
|
|
138
215
|
bounds_crs=bounds_crs,
|
|
139
216
|
geom_column=geom_column,
|
|
140
217
|
)
|
|
141
|
-
|
|
142
|
-
r = requests.get(self.wfs_url, params=payload, headers=self.request_headers)
|
|
143
|
-
log.debug(r.url)
|
|
144
|
-
if r.status_code in [400, 401, 404]:
|
|
145
|
-
log.error(f"HTTP error {r.status_code}")
|
|
146
|
-
log.error(f"Response headers: {r.headers}")
|
|
147
|
-
log.error(f"Response text: {r.text}")
|
|
148
|
-
raise ServiceException(r.text) # presumed request error
|
|
149
|
-
elif r.status_code in [500, 502, 503, 504]: # presumed serivce error, retry
|
|
150
|
-
log.warning(f"HTTP error: {r.status_code}, retrying")
|
|
151
|
-
log.warning(f"Response headers: {r.headers}")
|
|
152
|
-
log.warning(f"Response text: {r.text}")
|
|
153
|
-
r.raise_for_status()
|
|
218
|
+
r = self._request(self.wfs_url, params=payload, silent=True)
|
|
154
219
|
return int(ET.fromstring(r.text).attrib["numberMatched"])
|
|
155
220
|
|
|
156
|
-
@stamina.retry(on=requests.HTTPError, timeout=60)
|
|
157
221
|
def _request_features(self, url, silent=False):
|
|
158
|
-
"""Submit a getfeature request to DataBC WFS and return
|
|
159
|
-
|
|
160
|
-
if not silent:
|
|
161
|
-
log.info(r.url)
|
|
162
|
-
else:
|
|
163
|
-
log.debug(r.url)
|
|
164
|
-
if r.status_code in [400, 401, 404]:
|
|
165
|
-
log.error(f"HTTP error {r.status_code}")
|
|
166
|
-
log.error(f"Response headers: {r.headers}")
|
|
167
|
-
log.error(f"Response text: {r.text}")
|
|
168
|
-
raise ServiceException(r.text) # presumed request error
|
|
169
|
-
elif r.status_code in [500, 502, 503, 504]: # presumed serivce error, retry
|
|
170
|
-
log.warning(f"HTTP error: {r.status_code}")
|
|
171
|
-
log.warning(f"Response headers: {r.headers}")
|
|
172
|
-
log.warning(f"Response text: {r.text}")
|
|
173
|
-
r.raise_for_status()
|
|
174
|
-
return r.json()["features"]
|
|
222
|
+
"""Submit a getfeature request to DataBC WFS and return features"""
|
|
223
|
+
return self._request(url, silent=silent).json()["features"]
|
|
175
224
|
|
|
176
|
-
@stamina.retry(on=requests.HTTPError, timeout=60)
|
|
177
225
|
def _request_featurecollection(self, url, silent=False):
|
|
178
226
|
"""Submit a getfeature request to DataBC WFS and return feature collection"""
|
|
179
|
-
|
|
180
|
-
if not silent:
|
|
181
|
-
log.info(r.url)
|
|
182
|
-
else:
|
|
183
|
-
log.debug(r.url)
|
|
184
|
-
if r.status_code in [400, 401, 404]:
|
|
185
|
-
log.error(f"HTTP error {r.status_code}")
|
|
186
|
-
log.error(f"Response headers: {r.headers}")
|
|
187
|
-
log.error(f"Response text: {r.text}")
|
|
188
|
-
raise ServiceException(r.text) # presumed request error
|
|
189
|
-
elif r.status_code in [500, 502, 503, 504]: # presumed serivce error, retry
|
|
190
|
-
log.warning(f"HTTP error: {r.status_code}")
|
|
191
|
-
log.warning(f"Response headers: {r.headers}")
|
|
192
|
-
log.warning(f"Response text: {r.text}")
|
|
193
|
-
r.raise_for_status()
|
|
194
|
-
return r.json()
|
|
227
|
+
return self._request(url, silent=silent).json()
|
|
195
228
|
|
|
196
229
|
def build_bounds_filter(self, query, bounds, bounds_crs, geom_column):
|
|
197
230
|
"""The bbox param shortcut is mutually exclusive with CQL_FILTER,
|
|
@@ -337,8 +370,7 @@ class BCWFS(object):
|
|
|
337
370
|
bounds_crs=bounds_crs,
|
|
338
371
|
geom_column=geom_column,
|
|
339
372
|
)
|
|
340
|
-
|
|
341
|
-
count = n
|
|
373
|
+
count = min(count, n)
|
|
342
374
|
|
|
343
375
|
log.info(f"Total features requested: {count}")
|
|
344
376
|
|
|
@@ -443,8 +475,10 @@ def get_data(
|
|
|
443
475
|
gdf = pd.concat(results)
|
|
444
476
|
elif len(results) == 1:
|
|
445
477
|
gdf = results[0]
|
|
478
|
+
# generally all datasets should have records, but handle the case of zero results
|
|
479
|
+
# by creating an empty geodataframe
|
|
446
480
|
else:
|
|
447
|
-
gdf = gpd.GeoDataFrame()
|
|
481
|
+
gdf = gpd.GeoDataFrame(geometry=[])
|
|
448
482
|
if as_gdf:
|
|
449
483
|
return gdf
|
|
450
484
|
else:
|
|
@@ -1,27 +1,26 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: bcdata
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.17.0
|
|
4
4
|
Summary: Download open data, monitor and report on changes
|
|
5
5
|
Author-email: Simon Norris <snorris@hillcrestgeo.ca>
|
|
6
6
|
Project-URL: Homepage, https://github.com/smnorris/bcdata
|
|
7
7
|
Project-URL: Issues, https://github.com/smnorris/bcdata
|
|
8
8
|
Classifier: Development Status :: 4 - Beta
|
|
9
|
-
Classifier: Programming Language :: Python :: 3.9
|
|
10
9
|
Classifier: Programming Language :: Python :: 3.10
|
|
11
10
|
Classifier: Programming Language :: Python :: 3.11
|
|
12
11
|
Classifier: Programming Language :: Python :: 3.12
|
|
13
12
|
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
-
Classifier: License :: OSI Approved ::
|
|
13
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
15
14
|
Classifier: Topic :: Scientific/Engineering :: GIS
|
|
16
15
|
Classifier: Operating System :: OS Independent
|
|
17
|
-
Requires-Python: >=3.
|
|
16
|
+
Requires-Python: >=3.10
|
|
18
17
|
Description-Content-Type: text/markdown
|
|
19
18
|
License-File: LICENSE.txt
|
|
19
|
+
Requires-Dist: click
|
|
20
20
|
Requires-Dist: geoalchemy2
|
|
21
21
|
Requires-Dist: geopandas
|
|
22
22
|
Requires-Dist: owslib
|
|
23
23
|
Requires-Dist: psycopg2-binary
|
|
24
|
-
Requires-Dist: rasterio
|
|
25
24
|
Requires-Dist: requests
|
|
26
25
|
Requires-Dist: sqlalchemy
|
|
27
26
|
Requires-Dist: stamina
|
|
@@ -42,7 +41,7 @@ There is a [wealth of British Columbia geographic information available as open
|
|
|
42
41
|
data](https://catalogue.data.gov.bc.ca/dataset?download_audience=Public),
|
|
43
42
|
but most sources are available only via WFS - and the syntax to download WFS data via `ogr2ogr` and/or `curl/wget` can be awkward.
|
|
44
43
|
|
|
45
|
-
This tool attempts to simplify downloads of BC geographic data and smoothly integrate with PostGIS and Python GIS tools like `geopandas
|
|
44
|
+
This tool attempts to simplify downloads of BC geographic data and smoothly integrate with PostGIS and Python GIS tools like `geopandas` and `fiona`. The tool only accesses geographic data [avialable via WFS](https://bcgov.github.io/data-publication/pages/tips_tricks_webservices.html) - for other BC open data, download the files directly with `requests` / `curl` / `ogr2ogr` / etc (or the [bcdata R package](https://github.com/bcgov/bcdata)).
|
|
46
45
|
|
|
47
46
|
If downloads are failing, consult the [BCGov Data Systems and Services uptime monitor](https://uptime.com/statuspage/bcgov-dss) - this tool depends on BC Open Geospatial Web Services API at [openmaps.gov.bc.ca](https://openmaps.gov.bc.ca/geo/pub/ows?service=WFS&request=Getcapabilities) and the [BC Data Catalogue API](https://catalogue.data.gov.bc.ca/dataset/bc-data-catalogue-api).
|
|
48
47
|
|
|
@@ -359,19 +358,20 @@ Use some other tool to reproject the data as required.
|
|
|
359
358
|
|
|
360
359
|
## Development and testing
|
|
361
360
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
psql -c "create database test_bcdata"
|
|
365
|
-
psql test_bcdata -c "create extension postgis"
|
|
366
|
-
|
|
367
|
-
Create virtualenv and install `bcdata` in development mode:
|
|
361
|
+
Clone the repo:
|
|
368
362
|
|
|
369
363
|
$ git clone git@github.com:bcgov/bcdata_py.git
|
|
370
364
|
$ cd bcdata_py
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
365
|
+
|
|
366
|
+
`bc2pg` tests require a PostGIS database, referenced via the `DATABASE_URL` environment variable (matching the image used in CI, see `.github/workflows/tests.yml`). Start one locally with `docker compose`:
|
|
367
|
+
|
|
368
|
+
$ docker compose up -d
|
|
369
|
+
$ export DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres
|
|
370
|
+
|
|
371
|
+
install `bcdata` in development mode with [`uv`](https://docs.astral.sh/uv/):
|
|
372
|
+
|
|
373
|
+
$ uv sync --extra test
|
|
374
|
+
$ uv run pytest
|
|
375
375
|
|
|
376
376
|
|
|
377
377
|
## Other implementations
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import os
|
|
2
2
|
|
|
3
3
|
import pytest
|
|
4
|
+
from psycopg2 import errors as pg_errors
|
|
4
5
|
|
|
5
6
|
import bcdata
|
|
6
7
|
from bcdata.database import Database
|
|
@@ -83,17 +84,17 @@ def test_bc2pg_geometry_type():
|
|
|
83
84
|
|
|
84
85
|
|
|
85
86
|
def test_bc2pg_geometry_type_mismatch():
|
|
86
|
-
with pytest.raises(
|
|
87
|
+
with pytest.raises(pg_errors.InvalidParameterValue):
|
|
87
88
|
bcdata.bc2pg(AIRPORTS_TABLE, DB_URL, count=10, geometry_type="LINESTRING")
|
|
88
89
|
|
|
89
90
|
|
|
90
91
|
def test_bc2pg_geometry_type_invalid():
|
|
91
|
-
with pytest.raises(
|
|
92
|
+
with pytest.raises(ValueError):
|
|
92
93
|
bcdata.bc2pg(AIRPORTS_TABLE, DB_URL, count=10, geometry_type="MULTIPOLYGONZ")
|
|
93
94
|
|
|
94
95
|
|
|
95
96
|
def test_bc2pg_primary_key_invalid():
|
|
96
|
-
with pytest.raises(
|
|
97
|
+
with pytest.raises(ValueError):
|
|
97
98
|
bcdata.bc2pg(AIRPORTS_TABLE, DB_URL, count=10, primary_key="airport_primary_key")
|
|
98
99
|
|
|
99
100
|
|
|
@@ -3,6 +3,7 @@ import os
|
|
|
3
3
|
|
|
4
4
|
from click.testing import CliRunner
|
|
5
5
|
|
|
6
|
+
import bcdata
|
|
6
7
|
from bcdata.cli import cli
|
|
7
8
|
from bcdata.database import Database
|
|
8
9
|
|
|
@@ -16,24 +17,23 @@ BBOX_LL = "-123.396104,48.404465,-123.342588,48.425401"
|
|
|
16
17
|
RIVERS_TABLE = "whse_basemapping.fwa_rivers_poly"
|
|
17
18
|
ASSESSMENTS_TABLE = "whse_fish.pscis_assessment_svw"
|
|
18
19
|
|
|
19
|
-
# Note that these tests depend on airport counts.
|
|
20
|
-
# If airports are added to or removed from source layer, tests will fail
|
|
21
|
-
|
|
22
20
|
|
|
23
21
|
def test_info_table():
|
|
24
22
|
runner = CliRunner()
|
|
23
|
+
expected_count = bcdata.get_count(AIRPORTS_TABLE)
|
|
25
24
|
result = runner.invoke(cli, ["info", AIRPORTS_TABLE])
|
|
26
25
|
assert result.exit_code == 0
|
|
27
|
-
assert 'name": "{}"'
|
|
28
|
-
assert '"count":
|
|
26
|
+
assert f'name": "{AIRPORTS_TABLE}"' in result.output
|
|
27
|
+
assert f'"count": {expected_count}' in result.output
|
|
29
28
|
|
|
30
29
|
|
|
31
30
|
def test_info_package():
|
|
32
31
|
runner = CliRunner()
|
|
32
|
+
expected_count = bcdata.get_count(AIRPORTS_TABLE)
|
|
33
33
|
result = runner.invoke(cli, ["info", AIRPORTS_PACKAGE])
|
|
34
34
|
assert result.exit_code == 0
|
|
35
|
-
assert 'name": "{}"'
|
|
36
|
-
assert '"count":
|
|
35
|
+
assert f'name": "{AIRPORTS_TABLE}"' in result.output
|
|
36
|
+
assert f'"count": {expected_count}' in result.output
|
|
37
37
|
|
|
38
38
|
|
|
39
39
|
def test_list():
|
|
@@ -45,9 +45,10 @@ def test_list():
|
|
|
45
45
|
|
|
46
46
|
def test_cat():
|
|
47
47
|
runner = CliRunner()
|
|
48
|
+
expected_count = bcdata.get_count(AIRPORTS_TABLE)
|
|
48
49
|
result = runner.invoke(cli, ["cat", AIRPORTS_TABLE])
|
|
49
50
|
assert result.exit_code == 0
|
|
50
|
-
assert len(result.output.split("\n")) ==
|
|
51
|
+
assert len(result.output.split("\n")) == expected_count + 1
|
|
51
52
|
|
|
52
53
|
|
|
53
54
|
def test_cat_query():
|
|
@@ -35,6 +35,61 @@ def test_http_error_404():
|
|
|
35
35
|
bcdata.get_data(AIRPORTS_TABLE)
|
|
36
36
|
|
|
37
37
|
|
|
38
|
+
def _http_error(status_code):
|
|
39
|
+
response = requests.Response()
|
|
40
|
+
response.status_code = status_code
|
|
41
|
+
return requests.HTTPError(response=response)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_is_retryable_5xx_and_429():
|
|
45
|
+
"""5xx (presumed transient service errors) and 429 (rate limited) are retried"""
|
|
46
|
+
for code in (429, 500, 502, 503, 504):
|
|
47
|
+
assert bcdata.wfs._is_retryable(_http_error(code)) is True
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_is_retryable_4xx_not_retried():
|
|
51
|
+
"""4xx client errors (other than 429) are not retried"""
|
|
52
|
+
for code in (400, 401, 404):
|
|
53
|
+
assert bcdata.wfs._is_retryable(_http_error(code)) is False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_is_retryable_connection_and_timeout_errors():
|
|
57
|
+
"""Connection/timeout failures are also worth retrying, not just bad HTTP responses"""
|
|
58
|
+
assert bcdata.wfs._is_retryable(requests.ConnectionError()) is True
|
|
59
|
+
assert bcdata.wfs._is_retryable(requests.Timeout()) is True
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_is_retryable_429_without_headers_uses_default_backoff():
|
|
63
|
+
assert bcdata.wfs._is_retryable(_http_error(429)) is True
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_is_retryable_429_honours_retry_after_header():
|
|
67
|
+
response = requests.Response()
|
|
68
|
+
response.status_code = 429
|
|
69
|
+
response.headers["Retry-After"] = "12"
|
|
70
|
+
backoff = bcdata.wfs._is_retryable(requests.HTTPError(response=response))
|
|
71
|
+
assert backoff == 12.0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_is_retryable_429_honours_ratelimit_reset_header():
|
|
75
|
+
"""DataBC's Kong gateway sends RateLimit-Reset rather than Retry-After"""
|
|
76
|
+
response = requests.Response()
|
|
77
|
+
response.status_code = 429
|
|
78
|
+
response.headers["RateLimit-Reset"] = "5"
|
|
79
|
+
backoff = bcdata.wfs._is_retryable(requests.HTTPError(response=response))
|
|
80
|
+
assert backoff == 5.0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_rate_limit_backoff_unparseable_header_falls_back_to_none():
|
|
84
|
+
response = requests.Response()
|
|
85
|
+
response.headers["Retry-After"] = "Wed, 21 Oct 2026 07:28:00 GMT"
|
|
86
|
+
assert bcdata.wfs._rate_limit_backoff(response) is None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_is_retryable_other_exceptions_not_retried():
|
|
90
|
+
assert bcdata.wfs._is_retryable(ValueError("nope")) is False
|
|
91
|
+
|
|
92
|
+
|
|
38
93
|
def test_validate_table_lowercase():
|
|
39
94
|
table = bcdata.validate_name(AIRPORTS_TABLE.lower())
|
|
40
95
|
assert table == AIRPORTS_TABLE
|
|
@@ -81,6 +136,11 @@ def test_get_null_gdf():
|
|
|
81
136
|
assert type(gdf) is GeoDataFrame
|
|
82
137
|
|
|
83
138
|
|
|
139
|
+
def test_get_null_geojson():
|
|
140
|
+
data = bcdata.get_data(UTMZONES_KEY, query="UTM_ZONE=9999")
|
|
141
|
+
assert data == {"type": "FeatureCollection", "features": []}
|
|
142
|
+
|
|
143
|
+
|
|
84
144
|
def test_get_data_small():
|
|
85
145
|
data = bcdata.get_data(AIRPORTS_TABLE)
|
|
86
146
|
assert data["type"] == "FeatureCollection"
|
|
@@ -93,7 +153,7 @@ def test_get_data_count():
|
|
|
93
153
|
|
|
94
154
|
def test_get_data_lowercase():
|
|
95
155
|
data = bcdata.get_data(AIRPORTS_TABLE, lowercase=True, count=1)
|
|
96
|
-
assert "airport_name" in data["features"][0]["properties"]
|
|
156
|
+
assert "airport_name" in data["features"][0]["properties"]
|
|
97
157
|
|
|
98
158
|
|
|
99
159
|
def test_get_data_crs():
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|