diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a2f0964..3e70947 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -371,16 +371,15 @@ jobs: scheming.dataset_schemas = ckanext.datapusher_plus:dataset-druf.yaml scheming.presets = ckanext.scheming:presets.json scheming.dataset_fallback = false - ckanext.datapusher_plus.use_proxy = false ckanext.datapusher_plus.download_proxy = ckanext.datapusher_plus.ssl_verify = false ckanext.datapusher_plus.upload_log_level = INFO - ckanext.datapusher_plus.formats = csv tsv tab ssv xls xlsx xlsxb xlsm ods geojson shp qgis zip + ckanext.datapusher_plus.formats = csv tsv tab ssv xls xlsx xlsb xlsm ods geojson shp qgis zip # CKAN's bundled test-core.ini sets the legacy `ckan.datapusher.formats` key, which DPP's # _submit_to_datapusher checks FIRST (`tk.config.get("ckan.datapusher.formats") or ...`). # Without overriding it here, the legacy list (csv/xls/xlsx/tsv only) wins and DPP silently # refuses to auto-submit ssv/tab/ods/geojson/shp/qgis/zip resources. - ckan.datapusher.formats = csv tsv tab ssv xls xlsx xlsxb xlsm ods geojson shp qgis zip + ckan.datapusher.formats = csv tsv tab ssv xls xlsx xlsb xlsm ods geojson shp qgis zip ckanext.datapusher_plus.pii_screening = false ckanext.datapusher_plus.pii_found_abort = false ckanext.datapusher_plus.pii_regex_resource_id_or_alias = @@ -717,7 +716,7 @@ jobs: ssv) echo "$name|$file_url|SSV|text/csv|SSV: $filename" >> /tmp/test_files.txt ;; xls) echo "$name|$file_url|XLS|application/vnd.ms-excel|XLS: $filename" >> /tmp/test_files.txt ;; xlsx) echo "$name|$file_url|XLSX|application/vnd.openxmlformats-officedocument.spreadsheetml.sheet|XLSX: $filename" >> /tmp/test_files.txt ;; - xlsxb) echo "$name|$file_url|XLSXB|application/vnd.ms-excel.sheet.binary.macroEnabled.12|XLSXB: $filename" >> /tmp/test_files.txt ;; + xlsb) echo "$name|$file_url|XLSB|application/vnd.ms-excel.sheet.binary.macroEnabled.12|XLSB: $filename" >> /tmp/test_files.txt ;; xlsm) echo "$name|$file_url|XLSM|application/vnd.ms-excel.sheet.macroEnabled.12|XLSM: $filename" >> /tmp/test_files.txt ;; ods) echo "$name|$file_url|ODS|application/vnd.oasis.opendocument.spreadsheet|ODS: $filename" >> /tmp/test_files.txt ;; geojson) echo "$name|$file_url|GEOJSON|application/geo+json|GeoJSON: $filename" >> /tmp/test_files.txt ;; diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 27caa83..c5ccc36 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -260,12 +260,11 @@ jobs: scheming.dataset_schemas = ckanext.datapusher_plus:dataset-druf.yaml scheming.presets = ckanext.scheming:presets.json scheming.dataset_fallback = false - ckanext.datapusher_plus.use_proxy = false - ckanext.datapusher_plus.download_proxy = + ckanext.datapusher_plus.download_proxy = ckanext.datapusher_plus.ssl_verify = false # supports INFO, DEBUG, TRACE - use DEBUG or TRACE when debugging scheming Formulas ckanext.datapusher_plus.upload_log_level = INFO - ckanext.datapusher_plus.formats = csv tsv tab ssv xls xlsx xlsxb xlsm ods geojson shp qgis zip + ckanext.datapusher_plus.formats = csv tsv tab ssv xls xlsx xlsb xlsm ods geojson shp qgis zip ckanext.datapusher_plus.pii_screening = false ckanext.datapusher_plus.pii_found_abort = false ckanext.datapusher_plus.pii_regex_resource_id_or_alias = diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a52c552..1ea9543 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,27 +1,86 @@ -name: Tests -on: [push, pull_request] +name: Unit Tests + +on: + push: + branches: [main, dev] + pull_request: + branches: [main] + workflow_dispatch: + +concurrency: + group: dp-unit-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + QSV_VER: "20.1.0" + jobs: - test: - strategy: - matrix: - python-version: [2.7, 3.6, 3.7, 3.8, 3.9] - fail-fast: false - name: Python ${{ matrix.python-version }} + unit-tests: + name: Unit suite (Python 3.10, CKAN 2.11) runs-on: ubuntu-latest + timeout-minutes: 30 + # The unit suite imports ckan.plugins.toolkit and reads test-core.ini, + # so it needs a full CKAN install. The ckan/ckan-dev:2.11 image ships + # that (Python 3.10). A 3.11-3.13 matrix would mean building CKAN from + # source per version — tracked as a separate follow-up; pyproject + # already classifies 3.10-3.13 support. + container: + image: ckan/ckan-dev:2.11 + options: --user root steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - name: Install requirements (Python 2) - if: ${{ matrix.python-version == '2.7' }} - run: pip install -r requirements-dev-py2.txt - - name: Install requirements (Python 3) - if: ${{ matrix.python-version != '2.7' }} - run: pip install -r requirements-dev.txt - - name: Run tests - run: pytest --cov=datapusher --cov-append --cov-report=xml --disable-warnings tests - - name: Upload coverage report to codecov - uses: codecov/codecov-action@v1 - with: - file: ./coverage.xml + - name: Install system dependencies + run: | + apt-get update -y + apt-get install -y \ + build-essential git wget unzip \ + gdal-bin libgdal-dev libspatialindex-dev libgeos-dev libproj-dev \ + libxslt1-dev libxml2-dev libffi-dev libpq-dev zlib1g-dev + + - name: Checkout datapusher-plus + uses: actions/checkout@v4 + + - name: Install Python dependencies and datapusher-plus + run: | + set -eu + pip install --upgrade pip + # GDAL python binding must match the system libgdal version. + pip install "GDAL==$(gdal-config --version)" + pip install -r requirements.txt -r requirements-dev.txt + pip install -e . + + - name: Install qsv + run: | + set -eu + cd /tmp + QSV_ZIP="qsv-${QSV_VER}-x86_64-unknown-linux-musl.zip" + wget -q "https://github.com/dathere/qsv/releases/download/${QSV_VER}/${QSV_ZIP}" -O qsv.zip + unzip -qo qsv.zip + # Install as /usr/local/bin/qsvdp so QSV_BIN is stable regardless + # of whether the archive ships `qsvdp` or `qsv`. + if [ -f qsvdp ]; then SRC_BIN=qsvdp + elif [ -f qsv ]; then SRC_BIN=qsv + else echo "ERROR: no qsv binary in archive"; ls -la; exit 1; fi + chmod +x "$SRC_BIN" + mv -f "$SRC_BIN" /usr/local/bin/qsvdp + /usr/local/bin/qsvdp --version + + - name: Run unit test suite + env: + # The ckan-dev image's site-packages registers a pytest plugin + # (ckan.tests.pytest_ckan) that calls make_app() in + # pytest_sessionstart — needs a fully-configured CKAN. Disable + # plugin autoload so plain pytest runs. + PYTEST_DISABLE_PLUGIN_AUTOLOAD: "1" + QSV_BIN: /usr/local/bin/qsvdp + CKAN_INI: /srv/app/src/ckan/test-core.ini + run: | + set -eu + # -o addopts= overrides pyproject.toml's + # [tool.pytest.ini_options] --pdbcls IPython addopt. + # tests/integration is excluded — it needs the full + # docker-compose stack (Prefect, Postgres, Redis, Solr). + python3 -m pytest tests/ --ignore=tests/integration \ + -o addopts= -p no:cacheprovider -q diff --git a/README.md b/README.md index 2dfa1bf..8413dfa 100644 --- a/README.md +++ b/README.md @@ -303,23 +303,22 @@ ckan config-tool /etc/ckan/default/ckan.ini "ckanext.datapusher_plus.api_token=$ 7. Add the rest of the DP+ config to your CKAN config (e.g. `/etc/ckan/default/ckan.ini`): -> **Note:** The block below is an illustrative example, not a list of defaults. Several values (e.g. `preview_rows`, `chunk_size`, `dedup`, `auto_index_threshold`, `ignore_file_hash`) differ from DP+'s actual defaults. The authoritative defaults live in [`config_declaration.yaml`](ckanext/datapusher_plus/config_declaration.yaml). Only set the keys you actually want to override. +> **Note:** The block below lists DP+'s settings at their current defaults, for reference. Set only the keys you actually want to change — pasting the whole block pins every value as an explicit override, so a later change to a DP+ default wouldn't reach you. Authoritative defaults live in [`config_declaration.yaml`](ckanext/datapusher_plus/config_declaration.yaml) and [`config.py`](ckanext/datapusher_plus/config.py). ```ini # datapusher-plus settings -ckanext.datapusher_plus.use_proxy = false ckanext.datapusher_plus.download_proxy = -ckanext.datapusher_plus.ssl_verify = false +ckanext.datapusher_plus.ssl_verify = true # supports INFO, DEBUG, TRACE - use DEBUG or TRACE when debugging scheming Formulas ckanext.datapusher_plus.upload_log_level = INFO -ckanext.datapusher_plus.formats = csv tsv tab ssv xls xlsx ods geojson shp qgis zip +ckanext.datapusher_plus.formats = csv tsv tab ssv xls xlsx xlsm xlsb ods geojson shp qgis zip ckanext.datapusher_plus.pii_screening = false ckanext.datapusher_plus.pii_found_abort = false ckanext.datapusher_plus.pii_regex_resource_id_or_alias = ckanext.datapusher_plus.pii_show_candidates = false ckanext.datapusher_plus.pii_quick_screen = false ckanext.datapusher_plus.qsv_bin = /usr/local/bin/qsvdp -ckanext.datapusher_plus.preview_rows = 100 +ckanext.datapusher_plus.preview_rows = 0 ckanext.datapusher_plus.download_timeout = 300 ckanext.datapusher_plus.max_content_length = 1256000000000 ckanext.datapusher_plus.chunk_size = 16384 @@ -330,7 +329,7 @@ ckanext.datapusher_plus.unsafe_prefix = unsafe_ ckanext.datapusher_plus.reserved_colnames = _id ckanext.datapusher_plus.prefer_dmy = false ckanext.datapusher_plus.ignore_file_hash = true -ckanext.datapusher_plus.auto_index_threshold = 3 +ckanext.datapusher_plus.auto_index_threshold = 10 ckanext.datapusher_plus.auto_index_dates = true ckanext.datapusher_plus.auto_unique_index = true ckanext.datapusher_plus.summary_stats_options = @@ -343,11 +342,11 @@ ckanext.datapusher_plus.auto_alias = true ckanext.datapusher_plus.auto_alias_unique = false ckanext.datapusher_plus.copy_readbuffer_size = 1048576 ckanext.datapusher_plus.type_mapping = {"String": "text", "Integer": "numeric","Float": "numeric","DateTime": "timestamp","Date": "date","NULL": "text"} -ckanext.datapusher_plus.auto_spatial_simplication = true -ckanext.datapusher_plus.spatial_simplication_relative_tolerance = 0.1 +ckanext.datapusher_plus.auto_spatial_simplification = true +ckanext.datapusher_plus.spatial_simplification_relative_tolerance = 0.1 ckanext.datapusher_plus.latitude_fields = latitude,lat -ckanext.datapusher_plus.longitude_fields = longitude,long,lon -ckanext.datapusher_plus.jinja2_bytecode_cache_dir = /tmp/jinja2_butecode_cache +ckanext.datapusher_plus.longitude_fields = longitude,lon +ckanext.datapusher_plus.jinja2_bytecode_cache_dir = /tmp/jinja2_bytecode_cache ckanext.datapusher_plus.auto_unzip_one_file = true ``` @@ -412,22 +411,21 @@ scheming.dataset_schemas = ckanext.datapusher_plus:dataset-druf.yaml Configure DP+ numerous settings. See [config.py](ckanext/datapusher_plus/config.py) and [`config_declaration.yaml`](ckanext/datapusher_plus/config_declaration.yaml) for details. -> **Note:** The block below is an illustrative example, not a list of defaults. Several values differ from DP+'s actual defaults — see [`config_declaration.yaml`](ckanext/datapusher_plus/config_declaration.yaml) for the authoritative defaults. Only set the keys you actually want to override. +> **Note:** The block below lists DP+'s settings at their current defaults, for reference. Set only the keys you actually want to change — pasting the whole block pins every value as an explicit override, so a later change to a DP+ default wouldn't reach you. Authoritative defaults live in [`config_declaration.yaml`](ckanext/datapusher_plus/config_declaration.yaml) and [`config.py`](ckanext/datapusher_plus/config.py). >```ini -> ckanext.datapusher_plus.use_proxy = false > ckanext.datapusher_plus.download_proxy = -> ckanext.datapusher_plus.ssl_verify = false +> ckanext.datapusher_plus.ssl_verify = true > # supports INFO, DEBUG, TRACE - use DEBUG or TRACE when debugging scheming Formulas > ckanext.datapusher_plus.upload_log_level = INFO -> ckanext.datapusher_plus.formats = csv tsv tab ssv xls xlsx ods geojson shp qgis zip +> ckanext.datapusher_plus.formats = csv tsv tab ssv xls xlsx xlsm xlsb ods geojson shp qgis zip > ckanext.datapusher_plus.pii_screening = false > ckanext.datapusher_plus.pii_found_abort = false > ckanext.datapusher_plus.pii_regex_resource_id_or_alias = > ckanext.datapusher_plus.pii_show_candidates = false > ckanext.datapusher_plus.pii_quick_screen = false > ckanext.datapusher_plus.qsv_bin = /usr/local/bin/qsvdp -> ckanext.datapusher_plus.preview_rows = 100 +> ckanext.datapusher_plus.preview_rows = 0 > ckanext.datapusher_plus.download_timeout = 300 > ckanext.datapusher_plus.max_content_length = 1256000000000 > ckanext.datapusher_plus.chunk_size = 16384 @@ -438,7 +436,7 @@ Configure DP+ numerous settings. See [config.py](ckanext/datapusher_plus/config. > ckanext.datapusher_plus.reserved_colnames = _id > ckanext.datapusher_plus.prefer_dmy = false > ckanext.datapusher_plus.ignore_file_hash = true -> ckanext.datapusher_plus.auto_index_threshold = 3 +> ckanext.datapusher_plus.auto_index_threshold = 10 > ckanext.datapusher_plus.auto_index_dates = true > ckanext.datapusher_plus.auto_unique_index = true > ckanext.datapusher_plus.summary_stats_options = @@ -451,11 +449,11 @@ Configure DP+ numerous settings. See [config.py](ckanext/datapusher_plus/config. > ckanext.datapusher_plus.auto_alias_unique = false > ckanext.datapusher_plus.copy_readbuffer_size = 1048576 > ckanext.datapusher_plus.type_mapping = {"String": "text", "Integer": "numeric","Float": "numeric","DateTime": "timestamp","Date": "date","NULL": "text"} -> ckanext.datapusher_plus.auto_spatial_simplication = true -> ckanext.datapusher_plus.spatial_simplication_relative_tolerance = 0.1 +> ckanext.datapusher_plus.auto_spatial_simplification = true +> ckanext.datapusher_plus.spatial_simplification_relative_tolerance = 0.1 > ckanext.datapusher_plus.latitude_fields = latitude,lat -> ckanext.datapusher_plus.longitude_fields = longitude,long,lon -> ckanext.datapusher_plus.jinja2_bytecode_cache_dir = /tmp/jinja2_butecode_cache +> ckanext.datapusher_plus.longitude_fields = longitude,lon +> ckanext.datapusher_plus.jinja2_bytecode_cache_dir = /tmp/jinja2_bytecode_cache > ckanext.datapusher_plus.auto_unzip_one_file = true > ckanext.datapusher_plus.api_token = >``` diff --git a/ckanext/datapusher_plus/config.py b/ckanext/datapusher_plus/config.py index 93bab8a..d7231bd 100644 --- a/ckanext/datapusher_plus/config.py +++ b/ckanext/datapusher_plus/config.py @@ -40,7 +40,7 @@ # Supported formats FORMATS = tk.config.get( "ckanext.datapusher_plus.formats", - ["csv", "tsv", "tab", "ssv", "xls", "xlsx", "ods", "geojson", "shp", "qgis", "zip"], + ["csv", "tsv", "tab", "ssv", "xls", "xlsx", "xlsm", "xlsb", "ods", "geojson", "shp", "qgis", "zip"], ) if isinstance(FORMATS, str): FORMATS = FORMATS.split() @@ -253,8 +253,16 @@ AUTO_SPATIAL_SIMPLIFICATION = tk.asbool( tk.config.get("ckanext.datapusher_plus.auto_spatial_simplification", True) ) +# Lowercase key is canonical. The legacy UPPERCASE key +# (``...SPATIAL_SIMPLIFICATION_RELATIVE_TOLERANCE``) is still honoured +# as a fallback so deployments that set it don't silently lose the +# setting — same backward-compat shape as ``SSL_VERIFY`` above. SPATIAL_SIMPLIFICATION_RELATIVE_TOLERANCE = tk.config.get( - "ckanext.datapusher_plus.SPATIAL_SIMPLIFICATION_RELATIVE_TOLERANCE", "0.1" + "ckanext.datapusher_plus.spatial_simplification_relative_tolerance", + tk.config.get( + "ckanext.datapusher_plus.SPATIAL_SIMPLIFICATION_RELATIVE_TOLERANCE", + "0.1", + ), ) # Latitude and longitude column names diff --git a/ckanext/datapusher_plus/plugin.py b/ckanext/datapusher_plus/plugin.py index 5c8e166..268c6e5 100644 --- a/ckanext/datapusher_plus/plugin.py +++ b/ckanext/datapusher_plus/plugin.py @@ -18,6 +18,7 @@ import ckanext.datapusher_plus.logic.action as action import ckanext.datapusher_plus.logic.auth as auth import ckanext.datapusher_plus.cli as cli +import ckanext.datapusher_plus.config as conf tk = p.toolkit @@ -139,22 +140,13 @@ def _submit_to_datapusher(self, resource_dict: dict[str, Any]): ) if not supported_formats: log.debug( - "No supported formats configured,\ - using DataPusher Plus internals" + "No supported formats configured; " + "using the DataPusher+ FORMATS default." ) - supported_formats = [ - "csv", - "xls", - "xlsx", - "tsv", - "ssv", - "tab", - "ods", - "geojson", - "shp", - "qgis", - "zip", - ] + # Reuse the single canonical default from config.py rather + # than a second hardcoded list that would silently drift + # (e.g. miss xlsm/xlsb the way this list once did). + supported_formats = conf.FORMATS submit = ( resource_format diff --git a/tests/test_formats_config.py b/tests/test_formats_config.py new file mode 100644 index 0000000..92786b3 --- /dev/null +++ b/tests/test_formats_config.py @@ -0,0 +1,157 @@ +# -*- coding: utf-8 -*- +""" +Coverage for two config.py correctness fixes (FORMATS gate + the +spatial-tolerance config key). + +``format_converter.py`` can convert ``.xlsm`` and ``.xlsb`` workbooks +(both are in ``FormatConverterStage.SPREADSHEET_EXTENSIONS``), but the +``FORMATS`` default in ``config.py`` historically omitted them — so a +``.xlsm`` / ``.xlsb`` resource was silently skipped before it ever +reached the converter. + +Separately, ``config.py`` read the spatial-simplification relative +tolerance from an UPPERCASE ckan.ini key, inconsistent with every +other DP+ setting. + +These tests are AST-parsed against the source files — no CKAN import, +no runtime config — so they are deterministic config-drift guards in +the same style as ``test_pii_screening_config_key`` / +``test_issue_142_auto_index_threshold``. They pin: + +1. ``xlsm`` and ``xlsb`` are in the ``FORMATS`` default list literal. +2. Every spreadsheet extension the converter advertises + (``SPREADSHEET_EXTENSIONS``) is present in the ``FORMATS`` default + — a consistency guard so a future "the converter now also handles + ``.xyz``" change can't silently leave ``.xyz`` ungated. +3. The spatial-tolerance setting's *primary* (outermost) config key + is the all-lowercase name — robust to the legacy-uppercase-key + fallback nested as a second ``tk.config.get`` argument. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parent.parent +CONFIG_PY = REPO_ROOT / "ckanext" / "datapusher_plus" / "config.py" +FORMAT_CONVERTER_PY = ( + REPO_ROOT + / "ckanext" + / "datapusher_plus" + / "jobs" + / "stages" + / "format_converter.py" +) + + +def _find_assignment(source_path, var_name): + """Return the ``ast.Assign`` node that assigns ``var_name`` at any + depth in ``source_path``, or ``None``.""" + tree = ast.parse(source_path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if isinstance(node, ast.Assign) and any( + getattr(t, "id", None) == var_name for t in node.targets + ): + return node + return None + + +def _string_list(list_node): + """Extract the Python string values from an ``ast.List`` of string + constants.""" + assert isinstance(list_node, ast.List), ( + f"expected a list literal, got {type(list_node).__name__}" + ) + return [ + elt.value + for elt in list_node.elts + if isinstance(elt, ast.Constant) and isinstance(elt.value, str) + ] + + +def _formats_default_list(): + """The ``FORMATS`` default list literal from ``config.py``. + + ``FORMATS = tk.config.get("...formats", [])`` — the + default is the call's second positional argument. + """ + assign = _find_assignment(CONFIG_PY, "FORMATS") + assert assign is not None, "FORMATS assignment not found in config.py" + call = assign.value + assert isinstance(call, ast.Call) and len(call.args) >= 2, ( + "expected `FORMATS = tk.config.get(, [])`" + ) + return _string_list(call.args[1]) + + +def _converter_spreadsheet_extensions(): + """The ``SPREADSHEET_EXTENSIONS`` list literal from + ``format_converter.py``.""" + assign = _find_assignment(FORMAT_CONVERTER_PY, "SPREADSHEET_EXTENSIONS") + assert assign is not None, ( + "SPREADSHEET_EXTENSIONS not found in format_converter.py" + ) + return _string_list(assign.value) + + +def test_xlsm_and_xlsb_are_in_formats_default(): + # format_converter can convert both; the FORMATS default must let + # them in or DP+ skips the file before the converter sees it. + formats = _formats_default_list() + assert "xlsm" in formats + assert "xlsb" in formats + + +def test_formats_default_covers_every_converter_spreadsheet_extension(): + # Consistency guard: anything FormatConverterStage advertises in + # SPREADSHEET_EXTENSIONS must be gated in by the FORMATS default, + # or DP+ would reject a file format it actually knows how to + # convert. + formats_lower = {f.lower() for f in _formats_default_list()} + for ext in _converter_spreadsheet_extensions(): + assert ext.lower() in formats_lower, ( + f"{ext} is in FormatConverterStage.SPREADSHEET_EXTENSIONS but " + f"not in config.FORMATS — DP+ would skip .{ext.lower()} files " + "before the converter ever sees them." + ) + + +def test_spatial_tolerance_primary_config_key_is_lowercase(): + """The spatial-simplification relative-tolerance setting's primary + key must be all-lowercase. + + config.py originally read it from + ``ckanext.datapusher_plus.SPATIAL_SIMPLIFICATION_RELATIVE_TOLERANCE`` + — UPPERCASE, unlike every other ~50 DP+ keys — so an operator + setting the documented lowercase form had no effect. + + The assertion targets the assignment's ``.value`` directly — the + *outermost* ``tk.config.get`` — so it stays correct even though a + legacy-uppercase-key fallback is nested as that call's second + argument. (Walking for "the first ``.get``" would be fragile here.) + """ + assign = _find_assignment( + CONFIG_PY, "SPATIAL_SIMPLIFICATION_RELATIVE_TOLERANCE" + ) + assert assign is not None, ( + "SPATIAL_SIMPLIFICATION_RELATIVE_TOLERANCE assignment not found " + "in config.py" + ) + primary = assign.value + assert ( + isinstance(primary, ast.Call) + and getattr(primary.func, "attr", None) == "get" + and primary.args + and isinstance(primary.args[0], ast.Constant) + ), "expected the setting to be read via tk.config.get(, ...)" + primary_key = primary.args[0].value + assert primary_key == ( + "ckanext.datapusher_plus.spatial_simplification_relative_tolerance" + ), ( + f"config.py's primary key for the spatial tolerance is " + f"{primary_key!r}; it must be the all-lowercase name to match " + "the rest of DP+'s config namespace and the README. (A legacy " + "UPPERCASE key may still be honoured as a nested fallback.)" + )