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.
@@ -1,27 +1,26 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bcdata
3
- Version: 0.16.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 :: MIT License
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.9
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`, `fiona` and `rasterio`. 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)).
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
- `bc2pg` tests require database `postgresql://postgres@localhost:5432/test_bcdata` exists and has PostGIS installed:
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
- $ python -m venv .venv
372
- $ source .venv/bin/activate
373
- (.venv)$ pip install -e .[test]
374
- (.venv)$ py.test
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`, `fiona` and `rasterio`. 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)).
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
- `bc2pg` tests require database `postgresql://postgres@localhost:5432/test_bcdata` exists and has PostGIS installed:
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
- $ python -m venv .venv
339
- $ source .venv/bin/activate
340
- (.venv)$ pip install -e .[test]
341
- (.venv)$ py.test
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.9"
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 :: MIT License",
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 Exception(f"Failed to download primary key database at {PRIMARY_KEY_DB_URL}")
21
+ raise ConnectionError(f"Failed to download primary key database at {PRIMARY_KEY_DB_URL}")
22
22
  primary_keys = {}
23
23
 
24
- __version__ = "0.16.0"
24
+ __version__ = "0.17.0"
@@ -28,7 +28,7 @@ SUPPORTED_TYPES = [
28
28
  ]
29
29
 
30
30
 
31
- def bc2pg( # noqa: C901
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
- if not urls[-1] == urls[0]:
107
- df_temp = WFS.request_features(
108
- url=urls[-1],
109
- as_gdf=True,
110
- lowercase=True,
111
- silent=True,
112
- )
113
- geometry_type = df_temp.geom_type.unique()[0] # keep only the first type
114
- if numpy.any(
115
- df_temp.has_z.unique()[0]
116
- ): # geopandas does not include Z in geom_type string
117
- geometry_type = geometry_type + "Z"
118
- # drop the last request dataframe to free up memory
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
- if "object_name" in resource.keys() and resource["bcdc_type"] == "geographic":
104
- # confirm that object name matches table name and schema is present
105
- if (
106
- (
107
- table_name == resource["object_name"]
108
- # hack to handle object name / table name mismatch for NR Districts
109
- or (
110
- table_name == "WHSE_ADMIN_BOUNDARIES.ADM_NR_DISTRICTS_SPG"
111
- and resource["object_name"]
112
- == "WHSE_ADMIN_BOUNDARIES.ADM_NR_DISTRICTS_SP"
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
- and "details" in resource.keys()
116
- and resource["details"] != []
117
- ):
118
- table_definition["schema"] = resource["details"]
119
- # look for comments only if details/schema was found
120
- if "object_table_comments" in resource.keys():
121
- table_definition["comments"] = resource["object_table_comments"]
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
- s, table = out_table.split(".")
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".format(dataset, out_table, db_url))
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(object):
12
+ class Database:
13
13
  """Wrapper around sqlalchemy"""
14
14
 
15
- def __init__(self, url=os.environ.get("DATABASE_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.keys()]
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].keys():
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
- class BCWFS(object):
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
- if (
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(on=requests.HTTPError, timeout=60)
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(on=requests.HTTPError, timeout=60)
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=requests.HTTPError, timeout=60)
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 feature collection"""
159
- r = requests.get(url, headers=self.request_headers)
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
- r = requests.get(url, headers=self.request_headers)
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
- if count > n:
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.16.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 :: MIT License
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.9
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`, `fiona` and `rasterio`. 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)).
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
- `bc2pg` tests require database `postgresql://postgres@localhost:5432/test_bcdata` exists and has PostGIS installed:
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
- $ python -m venv .venv
372
- $ source .venv/bin/activate
373
- (.venv)$ pip install -e .[test]
374
- (.venv)$ py.test
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,8 +1,8 @@
1
+ click
1
2
  geoalchemy2
2
3
  geopandas
3
4
  owslib
4
5
  psycopg2-binary
5
- rasterio
6
6
  requests
7
7
  sqlalchemy
8
8
  stamina
@@ -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(Exception):
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(Exception):
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(Exception):
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": "{}"'.format(AIRPORTS_TABLE) in result.output
28
- assert '"count": 455' in result.output
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": "{}"'.format(AIRPORTS_TABLE) in result.output
36
- assert '"count": 455' in result.output
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")) == 456
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"].keys()
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