Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: tests

on:
pull_request:

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]

env:
API_KEY: ${{ secrets.DID_API_KEY }}
PYTHONPATH: ${{ github.workspace }}/examples

steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}

- name: Install deps
run: |
python -m pip install --upgrade pip
pip install -r examples/requirements.txt
pip install pytest pytest-cov

- name: Run pytests
run: |
pytest --cov=examples --cov-report=term-missing
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
[![tests](https://github.com/abc-rp/asset-api-docs/actions/workflows/tests.yaml/badge.svg)](https://github.com/abc-rp/asset-api-docs/actions/workflows/tests.yaml) [![format](https://github.com/abc-rp/asset-api-docs/actions/workflows/format.yaml/badge.svg)](https://github.com/abc-rp/asset-api-docs/actions/workflows/format.yaml)

# xRI Asset API (didapi.io)

Expand Down
Empty file added examples/__init__.py
Empty file.
18 changes: 10 additions & 8 deletions examples/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
anyio==4.9.0
Brotli==1.1.0
certifi==2025.1.31
exceptiongroup==1.2.2
h11==0.14.0
httpcore==1.0.7
certifi==2025.6.15
h11==0.16.0
httpcore==1.0.9
httpx==0.28.1
idna==3.10
isodate==0.7.2
oxrdflib==0.4.0
pyoxigraph==0.4.9
iniconfig==2.1.0
logging==0.4.9.6
packaging==25.0
pluggy==1.6.0
Pygments==2.19.2
pyparsing==3.2.3
pytest==8.4.1
rdflib==7.1.4
sniffio==1.3.1
typing_extensions==4.13.2
typing_extensions==4.14.0
73 changes: 73 additions & 0 deletions examples/tests/test_br_decompression.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import br_decompress as br
import brotli
import query_assist as qa


def _make_compressed_pair():
"""Return (raw_bytes, brotli_compressed_bytes)."""
raw = b"FOR UNIT TEST ONLY - pretend this is a PCD header\n"
return raw, brotli.compress(raw)


class _DummyResponse:
def __init__(self, data):
self.status_code = 200
self.headers = {"Content-Disposition": 'attachment; filename="cloud.pcd.br"'}
self.content = data

def raise_for_status(self):
pass


class _DummyStore:
"""Fake rdflib SPARQLStore that yields exactly one result row."""

def __init__(self, *_, **__):
pass

def query(self, *_):
return [
{
"uprnValue": "999",
"contentUrl": "https://example.com/cloud.pcd.br",
"enum": "did:lidar-pointcloud-merged",
}
]


def test_download_and_decompress_brotli(tmp_path, monkeypatch):
raw, compressed = _make_compressed_pair()

monkeypatch.setattr(qa, "SPARQLStore", _DummyStore)

monkeypatch.setattr(qa.httpx, "get", lambda *a, **k: _DummyResponse(compressed))

monkeypatch.setenv("API_KEY", "DUMMY")

monkeypatch.setattr(
qa,
"parse_args",
lambda: qa.argparse.Namespace(
uprn=["999"],
ods=None,
sensor=None,
types=None,
output_area=None,
db_url="http://dummy",
download_dir=str(tmp_path),
api_key_env="API_KEY",
),
)

qa.main()

p_br = tmp_path / "999" / "lidar-pointcloud-merged" / "cloud.pcd.br"
assert p_br.is_file(), "compressed asset should have been saved by query_assist"

br.find_and_replace_pcd_br(str(tmp_path))

p_raw = p_br.with_suffix("")
assert p_raw.is_file(), "decompressed .pcd should exist"
assert not p_br.exists(), ".pcd.br should have been removed"

assert p_raw.read_bytes() == raw, "decompressed bytes should match original"
96 changes: 96 additions & 0 deletions examples/tests/test_download_scripts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import importlib
from pathlib import Path

import pytest


class _DummyRow(dict):
"""row['uprnValue'] / row['contentUrl'] lookup just like ResultRow"""

def __getitem__(self, key):
return super().get(key)


class _DummyEndpoint:
"""Replaces SPARQLStore instance inside each script."""

def __init__(self, rows):
self._rows = rows

def query(self, *_):
return self._rows


def _fake_response():
class _R:
status_code = 200
headers = {"Content-Disposition": 'attachment; filename="file.bin"'}
content = b"DUMMY"

def raise_for_status(self):
pass

return _R()


@pytest.mark.parametrize(
"mod_name, expects_uprn_subfolder",
[
("examples.get_all_assets_for_a_list_of_uprns", True),
("examples.get_all_assets_for_a_uprn", False),
("examples.get_all_assets_for_a_uprn_made_by_a_sensor", True),
("examples.get_all_assets_of_type_for_list_of_uprns", True),
],
)
def test_script_downloads(tmp_path, monkeypatch, mod_name, expects_uprn_subfolder):
"""Import the script as a module, monkey-patch, run main(), check the file."""

import httpx

monkeypatch.setattr(httpx, "get", lambda *a, **k: _fake_response())

monkeypatch.setenv("API_KEY", "UNIT-TEST-KEY")

mod = importlib.import_module(mod_name)

monkeypatch.setattr(mod, "DOWNLOAD_DIR", str(tmp_path))

monkeypatch.setattr(mod, "ResultRow", _DummyRow)

dummy_rows = [_DummyRow({"uprnValue": "999", "contentUrl": "https://x/y.bin"})]
if hasattr(mod, "endpoint"):
monkeypatch.setattr(mod, "endpoint", _DummyEndpoint(dummy_rows))
else:
monkeypatch.setattr(mod, "endpoint", _DummyEndpoint(dummy_rows))

mod.main()

if expects_uprn_subfolder:
expected = Path(tmp_path) / "999" / "file.bin"
else:
expected = Path(tmp_path) / "file.bin"

assert expected.is_file(), f"{mod_name}: expected {expected} to exist"


@pytest.mark.parametrize(
"mod_name, substrings",
[
("examples.get_all_assets_for_a_list_of_uprns", ["200003455212", "5045394"]),
("examples.get_all_assets_for_a_uprn", ["5045394"]),
(
"examples.get_all_assets_for_a_uprn_made_by_a_sensor",
["5045394", "bess:OusterLidarSensor"],
),
(
"examples.get_all_assets_of_type_for_list_of_uprns",
["did:rgb-image", "lidar-pointcloud-merged"],
),
],
)
def test_query_contains_expected_literals(mod_name, substrings):
"""Make sure the hard-coded constants really appear in the QUERY string."""
mod = importlib.import_module(mod_name)
q = mod.QUERY
for s in substrings:
assert s in q, f"{mod_name}: missing {s} in QUERY"
129 changes: 129 additions & 0 deletions examples/tests/test_query_assist.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
from types import SimpleNamespace

import pytest
import query_assist as qa


@pytest.mark.parametrize(
"iri, expected",
[
("did:lidar-pointcloud-merged", "lidar-pointcloud-merged"),
("https://w3id.org/foo/did:rgb-image", "rgb-image"),
("did:ir-false-color-image", "ir-false-color-image"),
("did:weird chars!*£$", "weird_chars____"), # sanitised
],
)
def test_asset_subdir(iri, expected):
assert qa.asset_subdir(iri) == expected


def test_load_column_from_csv_good(tmp_path):
csv_file = tmp_path / "uprns.csv"
csv_file.write_text("uprn\n1\n2\n\n3\n")
assert qa.load_column_from_csv(csv_file, "uprn") == ["1", "2", "3"]


def test_load_column_from_csv_missing_col(tmp_path):
csv_file = tmp_path / "bad.csv"
csv_file.write_text("not_uprn\n123\n")
with pytest.raises(RuntimeError, match="missing required 'uprn'"):
qa.load_column_from_csv(csv_file, "uprn")


def test_build_asset_query_injects_everything():
args = SimpleNamespace(sensor="bess:OusterLidarSensor", types="did:rgb-image")
q = qa.build_asset_query(["123"], args)
assert "bess:OusterLidarSensor" in q
assert "did:rgb-image" in q
assert '"123"' in q
# should ALWAYS bind ?enum
assert "?enum" in q and "dob:typeQualifier" in q


def test_build_output_area_query_formats_values():
q = qa.build_output_area_query(["sid:E0001"])
assert "VALUES ?outputArea { sid:E0001 }" in q


def test_build_ods_to_uprn_query_values_clause():
q = qa.build_ods_to_uprn_query(["00ABC", "99XYZ"])
assert 'VALUES ?odsValue { "00ABC" "99XYZ" }' in q


def _dummy_http_response():
class _R:
status_code = 200
headers = {"Content-Disposition": 'attachment; filename="file.bin"'}
content = b"PSEUDO-BINARY"

def raise_for_status(self):
pass

return _R()


class _DummyStore:
"""Stand-in for rdflib SPARQLStore; returns a synthetic row list."""

def __init__(self, *a, **k):
pass

def query(self, *_):
return [
{
"uprnValue": "42",
"contentUrl": "https://example.com/file.bin",
"enum": "did:rgb-image",
}
]


def test_cli_download_creates_nested_dir(tmp_path, monkeypatch):
"""Full happy-path run – ensures <download-dir>/<uprn>/<type>/file.bin is created."""
monkeypatch.setattr(qa, "SPARQLStore", _DummyStore)
monkeypatch.setattr(qa.httpx, "get", lambda *a, **k: _dummy_http_response())
monkeypatch.setenv("API_KEY", "FAKE-KEY")

argv = ["query_assist", "--uprn", "42", "--download-dir", str(tmp_path)]
monkeypatch.setattr(
qa,
"parse_args",
lambda: qa.argparse.Namespace(
uprn=["42"],
ods=None,
sensor=None,
types=None,
output_area=None,
db_url="http://dummy",
download_dir=str(tmp_path),
api_key_env="API_KEY",
),
)

qa.main()

expected = tmp_path / "42" / "rgb-image" / "file.bin"
assert expected.is_file(), f"expected {expected} to exist"


def test_cli_fails_without_api_key(monkeypatch):
"""Main should raise RuntimeError if API_KEY env var is missing."""
monkeypatch.setattr(qa, "SPARQLStore", _DummyStore)
monkeypatch.delenv("API_KEY", raising=False)

monkeypatch.setattr(
qa,
"parse_args",
lambda: qa.argparse.Namespace(
uprn=["1"],
ods=None,
sensor=None,
types=None,
output_area=None,
db_url="http://dummy",
download_dir=None,
api_key_env="API_KEY",
),
)
with pytest.raises(RuntimeError, match="Env var 'API_KEY' is not set"):
qa.main()