From a487f75ee30bb974d220ffb8fa6592343db28509 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Fri, 27 Aug 2021 15:29:17 +0200 Subject: [PATCH] Pass data as dataclass object (#1) * Use dataclass * Use dataclass * Next step * Add translations * Improve translations * Improve translations * Add py.typed file * Add new trend strings * Bump version * Clean parsing logic * Fix flake8 error * Secure translate logic * Bump version * Add stale action * Remove codeql action * Use encoding parameter with open function * Bump version --- .github/workflows/codeql.yml | 37 --------------- .github/workflows/stale.yml | 22 +++++++++ example.py | 8 ++-- requirements.txt | 3 +- setup.py | 3 +- tests/test_init.py | 90 +++++++++--------------------------- zadnegoale/__init__.py | 89 +++++++++++++++++++---------------- zadnegoale/const.py | 47 +++++++++++++++++-- zadnegoale/model.py | 41 ++++++++++++++++ zadnegoale/py.typed | 0 10 files changed, 186 insertions(+), 154 deletions(-) delete mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/stale.yml create mode 100644 zadnegoale/model.py create mode 100644 zadnegoale/py.typed diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml deleted file mode 100644 index 3673002..0000000 --- a/.github/workflows/codeql.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: "CodeQL" - -on: - push: - branches: [main] - pull_request: - # The branches below must be a subset of the branches above - branches: [main] - schedule: - - cron: '0 19 * * 6' - -jobs: - analyze: - name: Analyze - runs-on: ubuntu-latest - - strategy: - fail-fast: false - matrix: - language: ['python'] - - steps: - - name: Checkout repository - uses: actions/checkout@v2 - with: - fetch-depth: 2 - - - name: Initialize CodeQL - uses: github/codeql-action/init@v1 - with: - languages: ${{ matrix.language }} - - - name: Autobuild - uses: github/codeql-action/autobuild@v1 - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 0000000..bae30c1 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,22 @@ +name: Mark stale issues and pull requests + +on: + schedule: + - cron: '25 8 * * *' + +jobs: + stale: + + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + + steps: + - uses: actions/stale@v3 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + stale-issue-message: 'Stale issue message' + stale-pr-message: 'Stale pull request message' + stale-issue-label: 'no-issue-activity' + stale-pr-label: 'no-pr-activity' \ No newline at end of file diff --git a/example.py b/example.py index 5afadf2..d8ff9c1 100644 --- a/example.py +++ b/example.py @@ -13,14 +13,16 @@ async def main(): async with ClientSession() as websession: try: - zadnegoale = ZadnegoAle(websession, REGION) - data = await zadnegoale.async_update(alerts=True) + zadnegoale = ZadnegoAle(websession, REGION, debug=False) + dusts = await zadnegoale.async_get_dusts() + alerts = await zadnegoale.async_get_alerts() except (ApiError, ClientError, InvalidRegionError) as error: print(f"Error: {error}") else: print(f"Region: {zadnegoale.region_name}") - print(f"Data: {data}") + print(f"Dusts: {dusts}") + print(f"Alerts: {alerts}") loop = asyncio.get_event_loop() diff --git a/requirements.txt b/requirements.txt index ce23571..3e4ba39 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ -aiohttp \ No newline at end of file +aiohttp +dacite \ No newline at end of file diff --git a/setup.py b/setup.py index b4bbe96..b0edb93 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name="zadnegoale", - version="0.3.0", + version="0.5.0", author="Maciej Bieniek", description="Python wrapper for getting allergen concentration data from Żadnego Ale servers.", long_description=long_description, @@ -16,6 +16,7 @@ url="https://github.com/bieniu/zadnegoale", license="Apache-2.0 License", packages=["zadnegoale"], + package_data={"nettigo_air_monitor": ["py.typed"]}, python_requires=">=3.6", install_requires=list(val.strip() for val in open("requirements.txt")), classifiers=[ diff --git a/tests/test_init.py b/tests/test_init.py index 72f9666..2e8338e 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -17,9 +17,9 @@ @pytest.mark.asyncio async def test_dusts_and_alerts(): """Test with valid dusts and alerts data.""" - with open("tests/fixtures/dusts.json") as file: + with open("tests/fixtures/dusts.json", encoding="utf-8") as file: dusts = json.load(file) - with open("tests/fixtures/alerts.json") as file: + with open("tests/fixtures/alerts.json", encoding="utf-8") as file: alerts = json.load(file) session = aiohttp.ClientSession() @@ -37,75 +37,27 @@ async def test_dusts_and_alerts(): ) zadnegoale = ZadnegoAle(session, VALID_REGION, debug=True) - result = await zadnegoale.async_update(alerts=True) + result_dusts = await zadnegoale.async_get_dusts() + result_alerts = await zadnegoale.async_get_alerts() await session.close() assert zadnegoale.region_name == "Karpaty" - assert len(result.sensors) == 8 - assert result.sensors.cladosporium["value"] == 5 - assert result.sensors.cladosporium["trend"] == "bez zmian" - assert result.sensors.cladosporium["level"] == "bardzo niskie" - assert result.sensors.cis["value"] == 1 - assert result.sensors.cis["trend"] == "wzrost" - assert result.sensors.cis["level"] == "brak" - assert result.sensors.leszczyna["value"] == 5 - assert result.sensors.leszczyna["trend"] == "bez zmian" - assert result.sensors.leszczyna["level"] == "bardzo niskie" - assert result.sensors.wiąz["value"] == 1 - assert result.sensors.wiąz["trend"] == "bez zmian" - assert result.sensors.wiąz["level"] == "brak" - assert result.sensors.wierzba["value"] == 1 - assert result.sensors.wierzba["trend"] == "bez zmian" - assert result.sensors.wierzba["level"] == "brak" - assert ( - result.alerts["value"] - == "Wysokie stężenie pyłku olszy, bardzo niskie leszczyny." - ) - try: - result.sensors.unknown - except AttributeError as error: - assert str(error) == "No such attribute: unknown" - - -@pytest.mark.asyncio -async def test_dusts(): - """Test with valid dusts data.""" - with open("tests/fixtures/dusts.json") as file: - dusts = json.load(file) - - session = aiohttp.ClientSession() - - with aioresponses() as session_mock, patch( - "zadnegoale.date", today=Mock(return_value=TEST_DATE) - ): - session_mock.get( - f"http://api.zadnegoale.pl/dusts/public/date/20210101/region/{VALID_REGION}", - payload=dusts, - ) - - zadnegoale = ZadnegoAle(session, VALID_REGION) - result = await zadnegoale.async_update() - - await session.close() - - assert zadnegoale.region_name == "Karpaty" - assert len(result.sensors) == 8 - assert result.sensors.cladosporium["value"] == 5 - assert result.sensors.cladosporium["trend"] == "bez zmian" - assert result.sensors.cladosporium["level"] == "bardzo niskie" - assert result.sensors.cis["value"] == 1 - assert result.sensors.cis["trend"] == "wzrost" - assert result.sensors.cis["level"] == "brak" - assert result.sensors.leszczyna["value"] == 5 - assert result.sensors.leszczyna["trend"] == "bez zmian" - assert result.sensors.leszczyna["level"] == "bardzo niskie" - assert result.sensors.wiąz["value"] == 1 - assert result.sensors.wiąz["trend"] == "bez zmian" - assert result.sensors.wiąz["level"] == "brak" - assert result.sensors.wierzba["value"] == 1 - assert result.sensors.wierzba["trend"] == "bez zmian" - assert result.sensors.wierzba["level"] == "brak" + assert result_dusts.cladosporium.value == 5 + assert result_dusts.cladosporium.trend == "steady" + assert result_dusts.cladosporium.level == "very low" + assert result_dusts.yew.value == 1 + assert result_dusts.yew.trend == "rising" + assert result_dusts.yew.level == "lack" + assert result_dusts.hazel.value == 5 + assert result_dusts.hazel.trend == "steady" + assert result_dusts.hazel.level == "very low" + assert result_dusts.elm.value == 1 + assert result_dusts.elm.trend == "steady" + assert result_dusts.elm.level == "lack" + assert result_dusts.willow.trend == "steady" + assert result_dusts.willow.level == "lack" + assert result_alerts[0] == "Wysokie stężenie pyłku olszy, bardzo niskie leszczyny." @pytest.mark.asyncio @@ -135,7 +87,7 @@ async def test_api_error(): ) zadnegoale = ZadnegoAle(session, VALID_REGION) try: - await zadnegoale.async_update() + await zadnegoale.async_get_dusts() except ApiError as error: assert str(error.status) == "Invalid response from Zadnego Ale API: 404" @@ -156,7 +108,7 @@ async def test_invalid_data(): ) zadnegoale = ZadnegoAle(session, VALID_REGION) try: - await zadnegoale.async_update() + await zadnegoale.async_get_dusts() except ApiError as error: assert str(error.status) == "Invalid response from Zadnego Ale API: null" diff --git a/zadnegoale/__init__.py b/zadnegoale/__init__.py index e04549a..83541e0 100644 --- a/zadnegoale/__init__.py +++ b/zadnegoale/__init__.py @@ -3,24 +3,28 @@ """ import logging from datetime import date -from typing import Any, Dict, Optional +from typing import Any, List, Optional from aiohttp import ClientSession - -from .const import ATTR_ALERTS, ATTR_DUSTS, ENDPOINT, HTTP_OK, URLS +from dacite import from_dict + +from .const import ( + ATTR_ALERTS, + ATTR_DUSTS, + ATTR_LEVEL, + ATTR_TREND, + ATTR_VALUE, + ENDPOINT, + HTTP_OK, + TRANSLATE_ALLERGENS_MAP, + TRANSLATE_STATES_MAP, + URL, +) +from .model import Allergens _LOGGER = logging.getLogger(__name__) -class DictToObj(dict): - """Dictionary to object class.""" - - def __getattr__(self, name: str) -> Any: - if name in self: - return self[name] - raise AttributeError("No such attribute: " + name) - - class ZadnegoAle: """Main class to perform Zadnego Ale API requests""" @@ -36,31 +40,38 @@ def __init__( self._debug = debug @staticmethod - def _construct_url(arg: str, **kwargs: Any) -> str: + def _construct_url(data_type: str, region: int) -> str: """Construct Zadnego Ale API URL.""" - url = ENDPOINT + URLS[arg].format(**kwargs) + date_str = date.today().strftime("%Y%m%d") + url = ENDPOINT + URL.format(data_type, date_str, region) return url @staticmethod - def _parse_dusts(data: list) -> Dict[str, Any]: + def _parse_dusts(data: list) -> Allergens: """Parse and clean dusts API response.""" - parsed = DictToObj( - { - item["allergen"]["name"].lower(): { - "value": item["value"], - "trend": item["trend"].lower(), - "level": item["level"].lower(), - } - for item in data + parsed = { + item["allergen"]["name"].lower(): { + ATTR_VALUE: item[ATTR_VALUE], + ATTR_TREND: TRANSLATE_STATES_MAP.get( + item[ATTR_TREND], item[ATTR_TREND] + ), + ATTR_LEVEL: TRANSLATE_STATES_MAP.get( + item[ATTR_LEVEL], item[ATTR_LEVEL] + ), } - ) - - return {"sensors": parsed} + for item in data + } + for pol_name, eng_name in TRANSLATE_ALLERGENS_MAP: + if pol_name in parsed: + parsed[eng_name] = parsed.pop(pol_name) + else: + parsed[eng_name] = {} + return from_dict(data_class=Allergens, data=parsed) @staticmethod - def _parse_alerts(data: Any) -> Dict[str, Any]: + def _parse_alerts(data: Any) -> List[str]: """Parse and clean alerts API response.""" - return {"alerts": {"value": data[0]["text"]}} + return [data[index]["text"] for index in range(len(data))] async def _async_get_data(self, url: str) -> Any: """Retreive data from Zadnego Ale API.""" @@ -73,10 +84,9 @@ async def _async_get_data(self, url: str) -> Any: raise ApiError(f"Invalid response from Zadnego Ale API: {data}") return data - async def async_update(self, alerts: bool = False) -> DictToObj: - """Retreive data from Zadnego Ale.""" - date_str = date.today().strftime("%Y%m%d") - url = self._construct_url(ATTR_DUSTS, date=date_str, region=self._region) + async def async_get_dusts(self) -> Allergens: + """Retreive dusts data from Zadnego Ale.""" + url = self._construct_url(ATTR_DUSTS, self._region) dusts = await self._async_get_data(url) if self._debug: @@ -85,16 +95,17 @@ async def async_update(self, alerts: bool = False) -> DictToObj: if not self._region_name: self._region_name = dusts[0]["region"]["name"] - if alerts: - url = self._construct_url(ATTR_ALERTS, date=date_str, region=self._region) - alerts = await self._async_get_data(url) + return self._parse_dusts(dusts) - if self._debug: - _LOGGER.debug(alerts) + async def async_get_alerts(self) -> List[str]: + """Retreive dusts data from Zadnego Ale.""" + url = self._construct_url(ATTR_ALERTS, self._region) + alerts = await self._async_get_data(url) - return DictToObj({**self._parse_dusts(dusts), **self._parse_alerts(alerts)}) + if self._debug: + _LOGGER.debug(alerts) - return DictToObj(self._parse_dusts(dusts)) + return self._parse_alerts(alerts) @property def region_name(self) -> Optional[str]: diff --git a/zadnegoale/const.py b/zadnegoale/const.py index 11c530d..c3e6446 100644 --- a/zadnegoale/const.py +++ b/zadnegoale/const.py @@ -1,5 +1,5 @@ """Constants for Zadnego Ale library.""" -from typing import Dict +from typing import Dict, List, Tuple ATTR_DUSTS: str = "dusts" ATTR_ALERTS: str = "alerts" @@ -8,7 +8,46 @@ HTTP_OK: int = 200 -URLS: Dict[str, str] = { - ATTR_DUSTS: "dusts/public/date/{date}/region/{region}", - ATTR_ALERTS: "alerts/public/date/{date}/region/{region}", +URL: str = "{}/public/date/{}/region/{}" + +ATTR_LEVEL: str = "level" +ATTR_TREND: str = "trend" +ATTR_VALUE: str = "value" + +TRANSLATE_STATES_MAP: Dict[str, str] = { + "Bardzo niskie": "very low", + "Bardzo wysokie": "very high", + "Bez zmian": "steady", + "Brak": "lack", + "Niskie": "low", + "Silny spadek": "strong falling", + "Silny wzrost": "strong rising", + "Spadek": "falling", + "Wysokie": "high", + "Wzrost": "rising", + "Średnie": "medium", } + +TRANSLATE_ALLERGENS_MAP: List[Tuple[str, str]] = [ + ("ambrozja", "ragweed"), + ("babka", "plantain"), + ("brzoza", "birch_tree"), + ("buk", "beech"), + ("bylica", "mugwort"), + ("cis", "yew"), + ("dąb", "oak"), + ("grab", "hornbeam"), + ("jesion", "ash_tree"), + ("klon", "maple"), + ("komosa", "pigweed"), + ("leszczyna", "hazel"), + ("olsza", "alder"), + ("platan", "plane_tree"), + ("pokrzywa", "nettle"), + ("sosna", "pine"), + ("szczaw", "sorrel"), + ("topola", "poplar"), + ("trawy", "grass"), + ("wierzba", "willow"), + ("wiąz", "elm"), +] diff --git a/zadnegoale/model.py b/zadnegoale/model.py new file mode 100644 index 0000000..e957327 --- /dev/null +++ b/zadnegoale/model.py @@ -0,0 +1,41 @@ +"""Type definitions for ZadnegoAle.""" +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class Allergen: + """Data class for allergen.""" + + value: int = 0 + trend: Optional[str] = None + level: str = "lack" + + +@dataclass +class Allergens: # pylint: disable=too-many-instance-attributes + """Data class for allergens.""" + + alder: Allergen + alternaria: Allergen + ash_tree: Allergen + beech: Allergen + birch_tree: Allergen + cladosporium: Allergen + elm: Allergen + grass: Allergen + hazel: Allergen + hornbeam: Allergen + maple: Allergen + mugwort: Allergen + nettle: Allergen + oak: Allergen + pigweed: Allergen + pine: Allergen + plane_tree: Allergen + plantain: Allergen + poplar: Allergen + ragweed: Allergen + sorrel: Allergen + willow: Allergen + yew: Allergen diff --git a/zadnegoale/py.typed b/zadnegoale/py.typed new file mode 100644 index 0000000..e69de29