diff --git a/.env.example b/.env.example index 650808b..43d2a03 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,4 @@ PULSE_ECO_USERNAME= PULSE_ECO_PASSWORD= +PULSE_ECO_SKOPJE_USERNAME= +PULSE_ECO_SKOPJE_PASSWORD= diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2cf2258..b505d8c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -45,9 +45,6 @@ jobs: run: hatch run lint - name: Run tests - env: - PULSE_ECO_USERNAME: ${{ secrets.PULSE_ECO_USERNAME }} - PULSE_ECO_PASSWORD: ${{ secrets.PULSE_ECO_PASSWORD }} run: hatch run cov - name: Upload coverage reports to Codecov diff --git a/mkdocs/environment-variables.md b/mkdocs/environment-variables.md new file mode 100644 index 0000000..2af966e --- /dev/null +++ b/mkdocs/environment-variables.md @@ -0,0 +1,34 @@ +# Environment variables + +## Base URL format + +Environment variable: `PULSE_ECO_BASE_URL_FORMAT` + +The default base URL format is `https://{city_name}.pulse.eco/rest/{end_point}`. + +## Authentication + +Authentication is not required for fetching data. But if provided, it has to be valid for the city. + +Credentials can also be provided as environment variables. To provide credentials for a city, use the following format: + +```txt +PULSE_ECO_{city_name}_USERNAME +PULSE_ECO_{city_name}_PASSWORD +``` + +Example environmtent variables in priority order: + +```txt +PULSE_ECO_SKOPJE_USERNAME +PULSE_ECO_SKOPJE_PASSWORD + +PULSE_ECO_skopje_USERNAME +PULSE_ECO_skopje_PASSWORD + +PULSE_ECO_USERNAME +PULSE_ECO_PASSWORD +``` + +Only use the generic `PULSE_ECO_USERNAME` and `PULSE_ECO_PASSWORD` environment variables +if your application requests data from a single city. diff --git a/mkdocs/example-usage.md b/mkdocs/example-usage.md index 96a23a6..a5d73d2 100644 --- a/mkdocs/example-usage.md +++ b/mkdocs/example-usage.md @@ -2,6 +2,8 @@ ## Initialize client +Authentication is not required for fetching data. But if provided, it has to be valid. Authentication is per city. + ```python from pulseeco import PulseEcoClient diff --git a/pulseeco/api/pulse_eco_api.py b/pulseeco/api/pulse_eco_api.py index 10eaeae..960b39a 100644 --- a/pulseeco/api/pulse_eco_api.py +++ b/pulseeco/api/pulse_eco_api.py @@ -6,7 +6,16 @@ import requests -from pulseeco.constants import AVG_DATA_MAX_SPAN, DATA_RAW_MAX_SPAN, PULSE_ECO_BASE_URL +from pulseeco.constants import ( + AVG_DATA_MAX_SPAN, + DATA_RAW_MAX_SPAN, + PULSE_ECO_BASE_URL_FORMAT, + PULSE_ECO_BASE_URL_FORMAT_ENV_KEY, + PULSE_ECO_CITY_PASSWORD_ENV_KEY_FORMAT, + PULSE_ECO_CITY_USERNAME_ENV_KEY_FORMAT, + PULSE_ECO_PASSWORD_ENV_KEY, + PULSE_ECO_USERNAME_ENV_KEY, +) from pulseeco.utils import convert_datetime_to_str, split_datetime_span from .base import PulseEcoAPIBase @@ -16,6 +25,47 @@ import datetime +def get_auth_from_env(city_name: str) -> tuple[str, str] | None: + """Get the auth tuple from the environment variables. + + :param city_name: the city name + :return: a tuple of (email, password) or None + """ + city_upper_username_env_key = PULSE_ECO_CITY_USERNAME_ENV_KEY_FORMAT.format( + city_name=city_name.upper() + ) + city_upper_password_env_key = PULSE_ECO_CITY_PASSWORD_ENV_KEY_FORMAT.format( + city_name=city_name.upper() + ) + + city_username_env_key = PULSE_ECO_CITY_USERNAME_ENV_KEY_FORMAT.format( + city_name=city_name + ) + city_password_env_key = PULSE_ECO_CITY_PASSWORD_ENV_KEY_FORMAT.format( + city_name=city_name + ) + + for username_env_key in ( + city_upper_username_env_key, + city_username_env_key, + PULSE_ECO_USERNAME_ENV_KEY, + ): + if username_env_key in os.environ: + username = os.environ[username_env_key] + break + else: + return None + + for password_env_key in ( + city_upper_password_env_key, + city_password_env_key, + PULSE_ECO_PASSWORD_ENV_KEY, + ): + if password_env_key in os.environ: + return username, os.environ[password_env_key] + return None + + class PulseEcoAPI(PulseEcoAPIBase): """Low level unsafe pulse.eco API wrapper.""" @@ -23,7 +73,7 @@ def __init__( self, city_name: str, auth: tuple[str, str] | None = None, - base_url: str = PULSE_ECO_BASE_URL, + base_url: str = PULSE_ECO_BASE_URL_FORMAT, session: requests.Session | None = None, ) -> None: """Initialize the pulse.eco API wrapper. @@ -37,23 +87,16 @@ def __init__( """ self.city_name = city_name - if base_url is None and "PULSE_ECO_BASE_URL" in os.environ: - base_url = os.environ["PULSE_ECO_BASE_URL"] + if base_url is not None and PULSE_ECO_BASE_URL_FORMAT_ENV_KEY in os.environ: + base_url = os.environ[PULSE_ECO_BASE_URL_FORMAT_ENV_KEY] if session is not None: self._session = session else: self._session = requests.Session() - if ( - auth is None - and "PULSE_ECO_USERNAME" in os.environ - and "PULSE_ECO_PASSWORD" in os.environ - ): - auth = ( - os.environ["PULSE_ECO_USERNAME"], - os.environ["PULSE_ECO_PASSWORD"], - ) + if auth is None: + auth = get_auth_from_env(city_name=city_name) if auth is not None: self._session.auth = auth diff --git a/pulseeco/client.py b/pulseeco/client.py index 4b84e65..497adc0 100644 --- a/pulseeco/client.py +++ b/pulseeco/client.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING from .api import PulseEcoAPI -from .constants import PULSE_ECO_BASE_URL +from .constants import PULSE_ECO_BASE_URL_FORMAT from .models import DataValue, Overall, Sensor if TYPE_CHECKING: @@ -22,7 +22,7 @@ def __init__( self, city_name: str, auth: tuple[str, str] | None = None, - base_url: str = PULSE_ECO_BASE_URL, + base_url: str = PULSE_ECO_BASE_URL_FORMAT, session: requests.Session | None = None, pulse_eco_api: PulseEcoAPIBase | None = None, ) -> None: diff --git a/pulseeco/constants.py b/pulseeco/constants.py index 3f015d7..b72e02c 100644 --- a/pulseeco/constants.py +++ b/pulseeco/constants.py @@ -1,5 +1,11 @@ import datetime -PULSE_ECO_BASE_URL = "https://{city_name}.pulse.eco/rest/{end_point}" +PULSE_ECO_BASE_URL_FORMAT_ENV_KEY = "PULSE_ECO_BASE_URL_FORMAT" +PULSE_ECO_USERNAME_ENV_KEY = "PULSE_ECO_USERNAME" +PULSE_ECO_PASSWORD_ENV_KEY = "PULSE_ECO_PASSWORD" # noqa: S105 +PULSE_ECO_CITY_USERNAME_ENV_KEY_FORMAT = "PULSE_ECO_{city_name}_USERNAME" +PULSE_ECO_CITY_PASSWORD_ENV_KEY_FORMAT = "PULSE_ECO_{city_name}_PASSWORD" # noqa: S105 + +PULSE_ECO_BASE_URL_FORMAT = "https://{city_name}.pulse.eco/rest/{end_point}" DATA_RAW_MAX_SPAN = datetime.timedelta(days=7) AVG_DATA_MAX_SPAN = datetime.timedelta(days=365) diff --git a/pulseeco/enums.py b/pulseeco/enums.py index a8757b7..d78d53b 100644 --- a/pulseeco/enums.py +++ b/pulseeco/enums.py @@ -12,6 +12,8 @@ def __repr__(self) -> str: class SensorType(StrEnum): + # unknown type + TYPE_NEG_1 = "-1" # MOEPP measurement station TYPE_0 = "0" # SkopjePulse LoRaWAN based sensor, version 1 @@ -30,6 +32,10 @@ class SensorType(StrEnum): TYPE_20003 = "20003" # sensor.community crowdsourced device TYPE_20004 = "20004" + # unknown type + TYPE_20005 = "20005" + # unknown type + TYPE_20006 = "20006" class SensorStatus(StrEnum): @@ -51,11 +57,17 @@ class SensorStatus(StrEnum): class DataValueType(StrEnum): NO2 = "no2" + NO2_PPB = "no2_ppb" O3 = "o3" SO2 = "so2" CO = "co" + CO_PPB = "co_ppb" + NH3 = "nh3" + NH3_PPM = "nh3_ppm" + NH3_PPB = "nh3_ppb" PM25 = "pm25" PM10 = "pm10" + PM1 = "pm1" TEMPERATURE = "temperature" HUMIDITY = "humidity" PRESSURE = "pressure" diff --git a/pulseeco/models.py b/pulseeco/models.py index 017dfa3..39c8930 100644 --- a/pulseeco/models.py +++ b/pulseeco/models.py @@ -51,11 +51,17 @@ class OverallValues(BaseModel): model_config = ConfigDict(extra="allow") no2: OverallValue = None + no2_ppb: OverallValue = None o3: OverallValue = None so2: OverallValue = None co: OverallValue = None + co_ppb: OverallValue = None + nh3: OverallValue = None + nh3_ppm: OverallValue = None + nh3_ppb: OverallValue = None pm25: OverallValue = None pm10: OverallValue = None + pm1: OverallValue = None temperature: OverallValue = None humidity: OverallValue = None pressure: OverallValue = None diff --git a/tests/test_pulseeco.py b/tests/test_pulseeco.py index b8a88b2..d6e0dd0 100644 --- a/tests/test_pulseeco.py +++ b/tests/test_pulseeco.py @@ -2,25 +2,79 @@ import datetime -import dotenv import pytest +import requests from pulseeco import AveragePeriod, PulseEcoClient -from pulseeco.constants import DATA_RAW_MAX_SPAN +from pulseeco.api.pulse_eco_api import PulseEcoAPI +from pulseeco.constants import ( + DATA_RAW_MAX_SPAN, + PULSE_ECO_BASE_URL_FORMAT_ENV_KEY, + PULSE_ECO_PASSWORD_ENV_KEY, + PULSE_ECO_USERNAME_ENV_KEY, +) from pulseeco.enums import DataValueType from pulseeco.models import OverallValues, Sensor from pulseeco.utils import split_datetime_span @pytest.fixture(scope="session") -def pulse_eco() -> PulseEcoClient: - dotenv.load_dotenv(override=True) +def cities() -> list[str]: + return [ + "tirana", + "sofia", + "yambol", + "zagreb", + "nicosia", + "copenhagen", + "berlin", + "berlin", + "syros", + "thessaloniki", + "cork", + "novoselo", + "struga", + "bitola", + "shtip", + "skopje", + "tetovo", + "gostivar", + "ohrid", + "resen", + "kumanovo", + "strumica", + "bogdanci", + "kichevo", + "delft", + "amsterdam", + "bucharest", + "targumures", + "sacele", + "codlea", + "cluj-napoca", + "oradea", + "iasi", + "brasov", + "nis", + "lausanne", + "zuchwil", + "bern", + "luzern", + "grenchen", + "zurich", + "grand-rapids", + "portland", + ] + + +@pytest.fixture(scope="session") +def pulse_eco_skopje() -> PulseEcoClient: return PulseEcoClient(city_name="skopje") @pytest.fixture(scope="session") -def sensors(pulse_eco: PulseEcoClient) -> list[Sensor]: - return pulse_eco.sensors() +def sensors_skopje(pulse_eco_skopje: PulseEcoClient) -> list[Sensor]: + return pulse_eco_skopje.sensors() @pytest.fixture(scope="session") @@ -33,15 +87,68 @@ def data_raw_max_span_ago(now: datetime.datetime) -> datetime.datetime: return now - DATA_RAW_MAX_SPAN -def test_sensors(pulse_eco: PulseEcoClient) -> None: - pulse_eco.sensors() +def test_env_vars(monkeypatch: pytest.MonkeyPatch) -> None: + custom_pulse_eco_base_url_format = "custom_{city_name}_{end_point}" + monkeypatch.setenv( + PULSE_ECO_BASE_URL_FORMAT_ENV_KEY, custom_pulse_eco_base_url_format + ) + custom_pulse_eco_username = "username" + monkeypatch.setenv(PULSE_ECO_USERNAME_ENV_KEY, custom_pulse_eco_username) + custom_pulse_eco_password = "password" # noqa: S105 + monkeypatch.setenv(PULSE_ECO_PASSWORD_ENV_KEY, custom_pulse_eco_password) + pulse_eco_api = PulseEcoAPI(city_name="skopje") + assert ( + pulse_eco_api._base_url == custom_pulse_eco_base_url_format # noqa: SLF001 + ), "`_base_url` should be the same as the one from env vars" + assert pulse_eco_api._session.auth == ( # noqa: SLF001 + custom_pulse_eco_username, + custom_pulse_eco_password, + ), "`_session.auth` should be the same as the credentials from env vars" + + +def test_auth() -> None: + pulse_eco_api = PulseEcoAPI(city_name="skopje", auth=("username", "password")) + assert pulse_eco_api._session.auth == ( # noqa: SLF001 + "username", + "password", + ), "`_session.auth` should be the same as the passed credentials" + + +def test_custom_session() -> None: + with requests.Session() as session: + session.proxies["protocol"] = "proxy" + pulse_eco_api = PulseEcoAPI(city_name="skopje", session=session) + assert ( + pulse_eco_api._session.proxies["protocol"] == "proxy" # noqa: SLF001 + ), "`_session` should be the same as the one from the session parameter" + +def test_custom_pulse_eco_api() -> None: + class CustomPulseEcoAPI(PulseEcoAPI): + pass + + custom_pulse_eco_api = CustomPulseEcoAPI("skopje") + pulse_eco = PulseEcoClient("skopje", pulse_eco_api=custom_pulse_eco_api) + assert ( + pulse_eco._pulse_eco_api == custom_pulse_eco_api # noqa: SLF001 + ), "`_pulse_eco_api` should be the same as the passed object" + + +def test_sensor_skopje( + pulse_eco_skopje: PulseEcoClient, sensors_skopje: list[Sensor] +) -> None: + assert len(sensors_skopje) > 0, "there should be at least one sensor" + sensor_id = sensors_skopje[0].sensor_id + sensor = pulse_eco_skopje.sensor(sensor_id) + assert ( + sensor == sensors_skopje[0] + ), "sensor should be the same as the one from sensors" -def test_sensor(pulse_eco: PulseEcoClient, sensors: list[Sensor]) -> None: - assert len(sensors) > 0, "there should be at least one sensor" - sensor_id = sensors[0].sensor_id - sensor = pulse_eco.sensor(sensor_id) - assert sensor == sensors[0], "sensor should be the same as the one from sensors" + +def test_sensors_all_cities(cities: list[str]) -> None: + for city_name in cities: + pulse_eco = PulseEcoClient(city_name=city_name) + pulse_eco.sensors() def test_split_datetime_span() -> None: @@ -66,10 +173,10 @@ def test_split_datetime_span() -> None: assert datetimes == expected_datetimes, "datetime split should be consistent" -def test_data_raw(pulse_eco: PulseEcoClient) -> None: +def test_data_raw_skopje(pulse_eco_skopje: PulseEcoClient) -> None: from_ = "2017-03-15T02:00:00+01:00" to = "2017-04-19T12:00:00+01:00" - data_raw = pulse_eco.data_raw( + data_raw = pulse_eco_skopje.data_raw( from_=from_, to=to, type=DataValueType.PM10, @@ -79,24 +186,27 @@ def test_data_raw(pulse_eco: PulseEcoClient) -> None: def test_data_raw_past_span( - pulse_eco: PulseEcoClient, - sensors: list[Sensor], + pulse_eco_skopje: PulseEcoClient, + cities: list[str], data_raw_max_span_ago: datetime.datetime, now: datetime.datetime, ) -> None: - for sensor in sensors: - pulse_eco.data_raw( - from_=data_raw_max_span_ago, - to=now, - sensor_id=sensor.sensor_id, - ) - - -def test_avg_data(pulse_eco: PulseEcoClient) -> None: + for city in cities: + pulse_eco = PulseEcoClient(city_name=city) + sensors = pulse_eco.sensors() + for sensor in sensors: + pulse_eco.data_raw( + from_=data_raw_max_span_ago, + to=now, + sensor_id=sensor.sensor_id, + ) + + +def test_avg_data(pulse_eco_skopje: PulseEcoClient) -> None: from_ = "2019-03-01T12:00:00+00:00" to = "2020-05-01T12:00:00+00:00" for period in (AveragePeriod.DAY, AveragePeriod.WEEK, AveragePeriod.MONTH): - avg_data = pulse_eco.avg_data( + avg_data = pulse_eco_skopje.avg_data( period=period, from_=from_, to=to, @@ -106,13 +216,13 @@ def test_avg_data(pulse_eco: PulseEcoClient) -> None: assert len(avg_data) > 0, "there should be at least one data value" -def test_data24h(pulse_eco: PulseEcoClient) -> None: - data24h = pulse_eco.data24h() +def test_data24h(pulse_eco_skopje: PulseEcoClient) -> None: + data24h = pulse_eco_skopje.data24h() assert len(data24h) > 0, "there should be at least one data value" -def test_current(pulse_eco: PulseEcoClient) -> None: - current = pulse_eco.current() +def test_current(pulse_eco_skopje: PulseEcoClient) -> None: + current = pulse_eco_skopje.current() assert len(current) > 0, "there should be at least one data value" @@ -120,9 +230,11 @@ def test_overall_values_type() -> None: assert OverallValues(pm10="N/A").pm10 is None, "`N/A` should validate to None" -def test_overall(pulse_eco: PulseEcoClient) -> None: - overall = pulse_eco.overall() - model_extra = overall.values.model_extra - assert ( - model_extra is None or len(model_extra) == 0 - ), "there shouldn't be any extra values" +def test_overall(cities: list[str]) -> None: + for city in cities: + pulse_eco = PulseEcoClient(city_name=city) + overall = pulse_eco.overall() + model_extra = overall.values.model_extra + assert ( + model_extra is None or len(model_extra) == 0 + ), "there shouldn't be any extra values"