diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml new file mode 100644 index 0000000..a2a4578 --- /dev/null +++ b/.github/workflows/pytest.yml @@ -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 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index b539f90..c999cbe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,5 @@ aiohttp==.3.9.5 pyproj==3.6.1 pytest async-timeout==4.0.3 -pytest-asyncio==0.23.7 \ No newline at end of file +pytest-asyncio==0.23.7 +freezegun \ No newline at end of file diff --git a/src/open_irceline/api.py b/src/open_irceline/api.py index 678259a..262b199 100644 --- a/src/open_irceline/api.py +++ b/src/open_irceline/api.py @@ -1,11 +1,11 @@ import asyncio import socket from datetime import datetime, timedelta -from typing import Tuple, List +from typing import Tuple, List, Dict import aiohttp -from aiohttp import ClientResponse import async_timeout +from aiohttp import ClientResponse from . import project_transform, rio_wfs_base_url from .data import RioFeature, FeatureValue @@ -43,12 +43,19 @@ class IrcelineClient: timestamp: datetime, features: List[RioFeature], position: Tuple[float, float] - ) -> dict: - coord = self.epsg_transform(position) + ) -> Dict[RioFeature, FeatureValue]: + """ + 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 # (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) - + coord = self.epsg_transform(position) querystring = {"service": "WFS", "version": "1.3.0", "request": "GetFeature", @@ -58,15 +65,23 @@ class IrcelineClient: f"INTERSECTS(the_geom, POINT ({coord[0]} {coord[1]}))"} 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 - 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): return dict() - features = data.get('features', []) + features_api = data.get('features', []) result = dict() - for f in features: + for f in features_api: if (f.get('id', None) is None or f.get('properties', {}).get('value', None) is None or f.get('properties', {}).get('timestamp', None) is None): @@ -79,12 +94,20 @@ class IrcelineClient: continue 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: result[name] = FeatureValue(timestamp=timestamp, value=value) return result 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'} diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..826ba28 --- /dev/null +++ b/tests/conftest.py @@ -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) diff --git a/tests/fixtures/rio_wfs.json b/tests/fixtures/rio_wfs.json new file mode 100644 index 0000000..82f31b5 --- /dev/null +++ b/tests/fixtures/rio_wfs.json @@ -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" + } + } +} diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..f5bb086 --- /dev/null +++ b/tests/test_api.py @@ -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