Add docstring and tests

This commit is contained in:
Jules 2024-06-15 20:52:38 +02:00
parent 8d9532170e
commit 7248caed49
Signed by: jdejaegh
GPG key ID: 99D6D184CA66933A
6 changed files with 339 additions and 10 deletions

32
.github/workflows/pytest.yml vendored Normal file
View file

@ -0,0 +1,32 @@
name: Run Python tests
on:
push:
pull_request:
jobs:
build:
name: Run tests
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.11"]
steps:
- uses: MathRobin/timezone-action@v1.1
with:
timezoneLinux: "Europe/Brussels"
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: pip install pytest pytest-md pytest-emoji
- name: Install requirements
run: pip install -r requirements.txt
- uses: pavelzw/pytest-action@v2
with:
emoji: false
verbose: false
job-summary: true

View file

@ -2,4 +2,5 @@ aiohttp==.3.9.5
pyproj==3.6.1 pyproj==3.6.1
pytest pytest
async-timeout==4.0.3 async-timeout==4.0.3
pytest-asyncio==0.23.7 pytest-asyncio==0.23.7
freezegun

View file

@ -1,11 +1,11 @@
import asyncio import asyncio
import socket import socket
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Tuple, List from typing import Tuple, List, Dict
import aiohttp import aiohttp
from aiohttp import ClientResponse
import async_timeout import async_timeout
from aiohttp import ClientResponse
from . import project_transform, rio_wfs_base_url from . import project_transform, rio_wfs_base_url
from .data import RioFeature, FeatureValue from .data import RioFeature, FeatureValue
@ -43,12 +43,19 @@ class IrcelineClient:
timestamp: datetime, timestamp: datetime,
features: List[RioFeature], features: List[RioFeature],
position: Tuple[float, float] position: Tuple[float, float]
) -> dict: ) -> Dict[RioFeature, FeatureValue]:
coord = self.epsg_transform(position) """
Call the WFS API to get the interpolated level of RioFeature
:param timestamp: datetime for which to get the data for
:param features: list of RioFeature to fetch from the API
:param position: decimal degrees pair of coordinates
:return: dict with the response (key is RioFeature, value is FeatureValue with actual value and timestamp)
"""
# Remove one hour from timestamp to handle case where the hour just passed but the data is not yet there # Remove one hour from timestamp to handle case where the hour just passed but the data is not yet there
# (e.g. 5.01 PM, but the most recent data is for 4.00 PM) # (e.g. 5.01 PM, but the most recent data is for 4.00 PM)
timestamp = timestamp.replace(microsecond=0, second=0, minute=0) - timedelta(hours=1) timestamp = timestamp.replace(microsecond=0, second=0, minute=0) - timedelta(hours=1)
coord = self.epsg_transform(position)
querystring = {"service": "WFS", querystring = {"service": "WFS",
"version": "1.3.0", "version": "1.3.0",
"request": "GetFeature", "request": "GetFeature",
@ -58,15 +65,23 @@ class IrcelineClient:
f"INTERSECTS(the_geom, POINT ({coord[0]} {coord[1]}))"} f"INTERSECTS(the_geom, POINT ({coord[0]} {coord[1]}))"}
r: ClientResponse = await self._api_wrapper(rio_wfs_base_url, querystring) r: ClientResponse = await self._api_wrapper(rio_wfs_base_url, querystring)
return self.format_result('rio', await r.json()) return self.format_result('rio', await r.json(), features)
@staticmethod @staticmethod
def format_result(prefix: str, data: dict) -> dict: def format_result(prefix: str, data: dict, features: List[RioFeature]) -> dict:
"""
Format the JSON dict returned by the WFS service into a more practical dict to use with only the latest measure
for each feature requested
:param prefix: namespace of the feature (e.g. rio), without the colon
:param data: JSON dict value as returned by the API
:param features: RioFeatures wanted in the final dict
:return: reduced dict, key is RioFeature, value is FeatureValue
"""
if data.get('type', None) != 'FeatureCollection' or not isinstance(data.get('features', None), list): if data.get('type', None) != 'FeatureCollection' or not isinstance(data.get('features', None), list):
return dict() return dict()
features = data.get('features', []) features_api = data.get('features', [])
result = dict() result = dict()
for f in features: for f in features_api:
if (f.get('id', None) is None or if (f.get('id', None) is None or
f.get('properties', {}).get('value', None) is None or f.get('properties', {}).get('value', None) is None or
f.get('properties', {}).get('timestamp', None) is None): f.get('properties', {}).get('timestamp', None) is None):
@ -79,12 +94,20 @@ class IrcelineClient:
continue continue
name = f"{prefix}:{f.get('id').split('.')[0]}" name = f"{prefix}:{f.get('id').split('.')[0]}"
if name not in [f'{f}' for f in features]:
continue
if name not in result or result[name]['timestamp'] < timestamp: if name not in result or result[name]['timestamp'] < timestamp:
result[name] = FeatureValue(timestamp=timestamp, value=value) result[name] = FeatureValue(timestamp=timestamp, value=value)
return result return result
async def _api_wrapper(self, url: str, querystring: dict): async def _api_wrapper(self, url: str, querystring: dict):
"""
Call the URL with the specified query string (GET). Raises exception for >= 400 response code
:param url: base URL
:param querystring: dict to build the query string
:return: response from the client
"""
headers = {'User-Agent': 'github.com/jdejaegh/python-irceline'} headers = {'User-Agent': 'github.com/jdejaegh/python-irceline'}

6
tests/conftest.py Normal file
View file

@ -0,0 +1,6 @@
import json
def get_api_data(fixture: str) -> dict:
with open(f'tests/fixtures/{fixture}', 'r') as file:
return json.load(file)

243
tests/fixtures/rio_wfs.json vendored Normal file
View file

@ -0,0 +1,243 @@
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"id": "no2_hmean.fid-280be381_1901cca3e5c_4c81",
"geometry": {
"type": "Polygon",
"coordinates": [
[
[
182000,
128000
],
[
182000,
132000
],
[
186000,
132000
],
[
186000,
128000
],
[
182000,
128000
]
]
]
},
"geometry_name": "the_geom",
"properties": {
"id": 1102,
"timestamp": "2024-06-15T15:00:00Z",
"value": 4,
"network": "Wallonia"
}
},
{
"type": "Feature",
"id": "no2_hmean.fid-280be381_1901cca3e5c_4c82",
"geometry": {
"type": "Polygon",
"coordinates": [
[
[
182000,
128000
],
[
182000,
132000
],
[
186000,
132000
],
[
186000,
128000
],
[
182000,
128000
]
]
]
},
"geometry_name": "the_geom",
"properties": {
"id": 1102,
"timestamp": "2024-06-15T16:00:00Z",
"value": 4,
"network": "Wallonia"
}
},
{
"type": "Feature",
"id": "pm25_hmean.fid-280be381_1901cca3e5c_4c83",
"geometry": {
"type": "Polygon",
"coordinates": [
[
[
182000,
128000
],
[
182000,
132000
],
[
186000,
132000
],
[
186000,
128000
],
[
182000,
128000
]
]
]
},
"geometry_name": "the_geom",
"properties": {
"id": 1102,
"timestamp": "2024-06-15T15:00:00Z",
"value": 1,
"network": "Wallonia"
}
},
{
"type": "Feature",
"id": "pm25_hmean.fid-280be381_1901cca3e5c_4c84",
"geometry": {
"type": "Polygon",
"coordinates": [
[
[
182000,
128000
],
[
182000,
132000
],
[
186000,
132000
],
[
186000,
128000
],
[
182000,
128000
]
]
]
},
"geometry_name": "the_geom",
"properties": {
"id": 1102,
"timestamp": "2024-06-15T16:00:00Z",
"value": 1,
"network": "Wallonia"
}
},
{
"type": "Feature",
"id": "o3_hmean.fid-280be381_1901cca3e5c_4c85",
"geometry": {
"type": "Polygon",
"coordinates": [
[
[
182000,
128000
],
[
182000,
132000
],
[
186000,
132000
],
[
186000,
128000
],
[
182000,
128000
]
]
]
},
"geometry_name": "the_geom",
"properties": {
"id": 1102,
"timestamp": "2024-06-15T15:00:00Z",
"value": 74,
"network": "Wallonia"
}
},
{
"type": "Feature",
"id": "o3_hmean.fid-280be381_1901cca3e5c_4c86",
"geometry": {
"type": "Polygon",
"coordinates": [
[
[
182000,
128000
],
[
182000,
132000
],
[
186000,
132000
],
[
186000,
128000
],
[
182000,
128000
]
]
]
},
"geometry_name": "the_geom",
"properties": {
"id": 1102,
"timestamp": "2024-06-15T16:00:00Z",
"value": 71,
"network": "Wallonia"
}
}
],
"totalFeatures": 6,
"numberMatched": 6,
"numberReturned": 6,
"timeStamp": "2024-06-15T16:55:03.419Z",
"crs": {
"type": "name",
"properties": {
"name": "urn:ogc:def:crs:EPSG::31370"
}
}
}

24
tests/test_api.py Normal file
View file

@ -0,0 +1,24 @@
from src.open_irceline.api import IrcelineClient
from src.open_irceline.data import RioFeature, FeatureValue
from tests.conftest import get_api_data
from datetime import datetime
from freezegun import freeze_time
@freeze_time(datetime.fromisoformat("2024-06-15T16:55:03.419Z"))
async def test_format_result():
data = get_api_data('rio_wfs.json')
result = IrcelineClient.format_result('rio', data, [RioFeature.NO2_HMEAN, RioFeature.O3_HMEAN])
expected = {
str(RioFeature.O3_HMEAN): FeatureValue(
timestamp=datetime.fromisoformat("2024-06-15T16:00:00Z"),
value=71
),
str(RioFeature.NO2_HMEAN): FeatureValue(
timestamp=datetime.fromisoformat("2024-06-15T16:00:00Z"),
value=4
)
}
assert result == expected