diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml new file mode 100644 index 0000000..14e589d --- /dev/null +++ b/.github/workflows/pr.yaml @@ -0,0 +1,26 @@ +name: Check MR + +on: [pull_request] + +jobs: + mypy: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - uses: pkgxdev/setup@v1 + with: + +: task + - name: Install uv + uses: astral-sh/setup-uv@v5 + - name: "Set up Python" + uses: actions/setup-python@v5 + with: + python-version-file: "pyproject.toml" + + - name: Install the project + run: uv sync --all-extras --dev + + - name: Run mypy + # For example, using `pytest` + run: uv run mypy diff --git a/pyproject.toml b/pyproject.toml index 16b7374..0b234b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,20 +7,18 @@ readme = "README.md" keywords = ['python'] requires-python = "==3.12.*" dependencies = [ - "paho-mqtt==2.1.0", - "dynaconf", - "tgtg==0.18.1", - "coloredlogs", - "tenacity", - "arrow", - "croniter", - "google-play-scraper", - "random_user_agent", - "packaging", - "freezegun", - "schedule", - "setuptools", - "click==8.1.8", + "paho-mqtt==2.1.0", + "dynaconf", + "tgtg==0.18.1", + "coloredlogs", + "arrow", + "croniter", + "google-play-scraper", + "random_user_agent", + "packaging", + "freezegun", + "schedule", + "click==8.1.8", ] [project.urls] @@ -30,11 +28,12 @@ Documentation = "https://MaxWinterstein.github.io/toogoodtogo-ha-mqtt-bridge/" [tool.uv] dev-dependencies = [ - "pytest>=7.2.0", - "pre-commit>=2.20.0", - "deptry>=0.20.0", - "mypy>=0.991", - "ruff>=0.6.9", + "pytest>=7.2.0", + "pre-commit>=2.20.0", + "deptry>=0.20.0", + "mypy>=0.991", + "types-croniter", + "ruff>=0.6.9", ] [build-system] @@ -53,6 +52,7 @@ check_untyped_defs = true warn_return_any = true warn_unused_ignores = true show_error_codes = true +ignore_missing_imports = true [tool.pytest.ini_options] testpaths = ["tests"] @@ -64,44 +64,45 @@ fix = true [tool.ruff.lint] select = [ - # flake8-2020 - "YTT", - # flake8-bandit - "S", - # flake8-bugbear - "B", - # flake8-builtins - "A", - # flake8-comprehensions - "C4", - # flake8-debugger - "T10", - # flake8-simplify - "SIM", - # isort - "I", - # mccabe - "C90", - # pycodestyle - "E", "W", - # pyflakes - "F", - # pygrep-hooks - "PGH", - # pyupgrade - "UP", - # ruff - "RUF", - # tryceratops - "TRY", + # flake8-2020 + "YTT", + # flake8-bandit + "S", + # flake8-bugbear + "B", + # flake8-builtins + "A", + # flake8-comprehensions + "C4", + # flake8-debugger + "T10", + # flake8-simplify + "SIM", + # isort + "I", + # mccabe + "C90", + # pycodestyle + "E", + "W", + # pyflakes + "F", + # pygrep-hooks + "PGH", + # pyupgrade + "UP", + # ruff + "RUF", + # tryceratops + "TRY", ] ignore = [ - # LineTooLong - "E501", - # DoNotAssignLambda - "E731", - # i like my unnecessary True if ... else False - "SIM210", + # LineTooLong + "E501", + # DoNotAssignLambda + "E731", + # i like my unnecessary True if ... else False + "SIM210", ] [tool.ruff.lint.per-file-ignores] diff --git a/toogoodtogo_ha_mqtt_bridge/main.py b/toogoodtogo_ha_mqtt_bridge/main.py index aa43c2c..708fd92 100644 --- a/toogoodtogo_ha_mqtt_bridge/main.py +++ b/toogoodtogo_ha_mqtt_bridge/main.py @@ -9,6 +9,7 @@ from datetime import datetime, timedelta from pathlib import Path from time import sleep +from typing import Any import arrow import click @@ -30,20 +31,20 @@ level="DEBUG", logger=logger, fmt="%(asctime)s [%(levelname)s] %(message)s" ) # pretty logging is pretty -mqtt_client: mqtt.Client | None = None +mqtt_client: mqtt.Client = None # type: ignore[assignment] first_run = True -tgtg_client: TgtgClient | None = None +tgtg_client: TgtgClient = None # type: ignore[no-any-unimported] tgtg_version: str | None = None intense_fetch_thread = None -tokens = {} +tokens: dict[Any, Any] = {} tokens_rev = 2 # in case of tokens.json changes, bump this -watchdog: Watchdog | None = None +watchdog: Watchdog = None # type: ignore[assignment] watchdog_timeout = 0 -favourite_ids = [] -scheduled_jobs = [] +favourite_ids: list[int] = [] +scheduled_jobs: list[Any] = [] -def check(): +def check() -> bool: global first_run global favourite_ids favourite_ids.clear() @@ -98,13 +99,13 @@ def check(): pickup_start_date = None if not stock else arrow.get(shop["pickup_interval"]["start"]).to(tz=settings.timezone) pickup_end_date = None if not stock else arrow.get(shop["pickup_interval"]["end"]).to(tz=settings.timezone) - pickup_start_str = ("Unknown" if stock == 0 else pickup_start_date.to(tz=settings.timezone).format(),) - pickup_end_str = ("Unknown" if stock == 0 else pickup_end_date.to(tz=settings.timezone).format(),) + pickup_start_str = ("Unknown" if stock == 0 else pickup_start_date.to(tz=settings.timezone).format(),) # type: ignore[union-attr] + pickup_end_str = ("Unknown" if stock == 0 else pickup_end_date.to(tz=settings.timezone).format(),) # type: ignore[union-attr] pickup_start_human = ( - "Unknown" if stock == 0 else pickup_start_date.humanize(only_distance=False, locale=settings.locale) + "Unknown" if stock == 0 else pickup_start_date.humanize(only_distance=False, locale=settings.locale) # type: ignore[union-attr] ) pickup_end_human = ( - "Unknown" if stock == 0 else pickup_end_date.humanize(only_distance=False, locale=settings.locale) + "Unknown" if stock == 0 else pickup_end_date.humanize(only_distance=False, locale=settings.locale) # type: ignore[union-attr] ) # get company logo @@ -152,7 +153,7 @@ def check(): return True -def build_ua(): +def build_ua() -> Any: global tgtg_version software_names = [SoftwareName.ANDROID.value] user_agent_rotator = UserAgent(software_names=software_names, limit=20) @@ -165,7 +166,7 @@ def build_ua(): return user_agent -def is_latest_version(): +def is_latest_version() -> bool: logger.info("Checking latest tgtg appstore version") try: app_info = app("com.app.tgtg", lang="de", country="de") @@ -188,11 +189,11 @@ def is_latest_version(): return True -def is_latest_token_rev(): +def is_latest_token_rev() -> Any: return tokens["rev"] >= tokens_rev -def write_token_file(): +def write_token_file() -> None: global tokens tgtg_tokens = { "access_token": tgtg_client.access_token, @@ -212,7 +213,7 @@ def write_token_file(): logger.info("Written tokens.json file to filesystem") -def check_existing_token_file(): +def check_existing_token_file() -> bool: if os.path.isfile(settings.get("data_dir") + "/tokens.json"): return read_token_file() else: @@ -220,12 +221,12 @@ def check_existing_token_file(): return False -def nuke_token_file(): +def nuke_token_file() -> None: logger.info("Old tokenfile found. Please login via email again.") os.remove(settings.get("data_dir") + "/tokens.json") -def read_token_file(): +def read_token_file() -> bool: global tokens with open(settings.get("data_dir") + "/tokens.json") as f: tokens = json.load(f) @@ -249,11 +250,11 @@ def read_token_file(): return False -def update_ua(): +def update_ua() -> None: global tokens ua = tokens["ua"] updated_ua = ua.split(" ")[1:] - updated_ua = "TGTG/" + tgtg_version + " " + " ".join(updated_ua) + updated_ua = "TGTG/" + tgtg_version + " " + " ".join(updated_ua) # type: ignore[operator] tokens["ua"] = updated_ua tokens["token_version"] = tgtg_version @@ -261,7 +262,7 @@ def update_ua(): write_token_file() -def rebuild_tgtg_client(): +def rebuild_tgtg_client() -> None: global tgtg_client tgtg_client = TgtgClient( cookie=tokens["cookie"], @@ -273,7 +274,7 @@ def rebuild_tgtg_client(): ) -def check_for_removed_stores(shops: []): +def check_for_removed_stores(shops: list[Any]) -> None: path = settings.get("data_dir") + "/known_shops.json" checked_items = [shop["item"]["item_id"] for shop in shops] @@ -299,7 +300,7 @@ def check_for_removed_stores(shops: []): pass -def fetch_loop(event): +def fetch_loop(event: Any) -> None: logger.info("Starting loop") create_data_dir() @@ -325,7 +326,7 @@ def fetch_loop(event): event.wait(calc_next_run()) -def next_sales_loop(): +def next_sales_loop() -> None: while True: if favourite_ids: for fav_id in favourite_ids: @@ -361,7 +362,7 @@ def next_sales_loop(): sleep(sleep_seconds) -def trigger_intense_fetch(): +def trigger_intense_fetch() -> Any: logger.info("Running automatic intense fetch!") mqtt_client.publish( "homeassistant/switch/toogoodtogo_intense_fetch/set", @@ -370,7 +371,7 @@ def trigger_intense_fetch(): return schedule.CancelJob -def ua_check_loop(): +def ua_check_loop() -> None: while True: now = datetime.now() cron = croniter("0 0,12 * * *", now) @@ -382,7 +383,7 @@ def ua_check_loop(): update_ua() -def calc_next_run(): +def calc_next_run() -> Any: cron_schedule = get_cron_schedule() now = datetime.now() @@ -405,9 +406,10 @@ def calc_next_run(): return sleep_seconds + 1 else: exit_from_thread("Invalid cron schedule", 1) + return # might never be returned -def get_fallback_cron(tgtg): +def get_fallback_cron(tgtg: Any) -> str: # Create fallback cron with old every_n_minutes setting if "every_n_minutes" not in tgtg: exit_from_thread("No interval found in settings, please check your config.", 1) @@ -423,7 +425,7 @@ def get_fallback_cron(tgtg): return "*/" + str(tgtg.every_n_minutes) + " * * * *" -def get_cron_schedule(): +def get_cron_schedule() -> Any: tgtg = settings.get("tgtg") if "polling_schedule" not in tgtg: return get_fallback_cron(tgtg) @@ -431,26 +433,26 @@ def get_cron_schedule(): return tgtg.polling_schedule -def create_data_dir(): +def create_data_dir() -> None: data_dir = settings.get("data_dir") if not os.path.isdir(data_dir): Path(data_dir).mkdir(parents=True) -def exit_from_thread(message, return_code): +def exit_from_thread(message: str, return_code: int) -> None: logger.exception(message) os._exit(return_code) -def watchdog_handler(): +def watchdog_handler() -> None: exit_from_thread("Watchdog handler fired! No pull in the last " + str(watchdog_timeout / 60) + " minutes!", 1) -def on_connect(client, userdata, flags, reason_code, properties): +def on_connect(client, userdata, flags, reason_code, properties) -> None: # type: ignore[no-untyped-def] logger.debug(f"MQTT seems connected. (reason_code: {reason_code})") -def on_disconnect(client, userdata, flags, reason_code, properties): +def on_disconnect(client, userdata, flags, reason_code, properties) -> None: # type: ignore[no-untyped-def] if reason_code != 0: logger.error("Wow, mqtt client lost connection. Will try to reconnect once in 30s.") logger.debug(f"reason_code: {reason_code}") @@ -459,7 +461,7 @@ def on_disconnect(client, userdata, flags, reason_code, properties): client.reconnect() -def calc_timeout(): +def calc_timeout() -> Any: global watchdog_timeout now = datetime.now() cron_schedule = get_cron_schedule() @@ -475,24 +477,25 @@ def calc_timeout(): return watchdog_timeout else: exit_from_thread("Invalid cron schedule", 1) + return # might never be returned -def intense_fetch(): +def intense_fetch() -> None: if ( "intense_fetch" not in settings.tgtg or "period_of_time" not in settings.tgtg.intense_fetch or "interval" not in settings.tgtg.intense_fetch ): logger.error("Incomplete settings file. Please check the sample!") - return None + return if settings.tgtg.intense_fetch.period_of_time > 60: logger.warning("Stopped intense fetch. Maximal intense fetch period time are 60 minutes. Reduce your setting!") - return None + return if settings.tgtg.intense_fetch.interval < 10: logger.warning("Stopped intense fetch. Minimal intense fetch interval are 10 seconds. Increase your setting!") - return None + return mqtt_client.publish( "homeassistant/switch/toogoodtogo_intense_fetch/state", @@ -521,20 +524,20 @@ def intense_fetch(): logger.info("Intense fetch stopped") -def on_message(client, userdata, message): +def on_message(client: Any, userdata: Any, message: Any) -> None: global intense_fetch_thread if message.topic.endswith("toogoodtogo_intense_fetch/set"): if message.payload.decode("utf-8") == "ON": if intense_fetch_thread: logger.error("Intense fetch thread already running. Doing nothing.") - return None + return thread = threading.Thread(target=intense_fetch) intense_fetch_thread = thread thread.start() elif message.payload.decode("utf-8") == "OFF": if intense_fetch_thread: - intense_fetch_thread.do_run = False + intense_fetch_thread.do_run = False # type: ignore[attr-defined] logger.info("Intense fetch is stopped in the next cycle.") mqtt_client.publish( "homeassistant/switch/toogoodtogo_intense_fetch/state", @@ -544,7 +547,7 @@ def on_message(client, userdata, message): logger.info("No running thread found. Doing nothing.") -def register_fetch_sensor(): +def register_fetch_sensor() -> None: mqtt_client.publish( "homeassistant/switch/toogoodtogo_bridge/intense_fetch/config", json.dumps({ @@ -568,7 +571,7 @@ def register_fetch_sensor(): ) -def run_pending_schedules(): +def run_pending_schedules() -> None: while True: schedule.run_pending() time.sleep(1) @@ -576,7 +579,7 @@ def run_pending_schedules(): @click.command() @click.version_option(package_name="toogoodtogo_ha_mqtt_bridge") -def start(): +def start() -> None: global tgtg_client, watchdog, mqtt_client tgtg_client = TgtgClient( email=settings.tgtg.email, language=settings.tgtg.language, timeout=30, user_agent=build_ua() diff --git a/toogoodtogo_ha_mqtt_bridge/tests/test_cron.py b/toogoodtogo_ha_mqtt_bridge/tests/test_cron.py index 486cd11..34700d5 100644 --- a/toogoodtogo_ha_mqtt_bridge/tests/test_cron.py +++ b/toogoodtogo_ha_mqtt_bridge/tests/test_cron.py @@ -16,7 +16,7 @@ ("2022-11-12 20:54:44", "*/10 7-20 * * *", "2022-11-13 07:00:00"), ], ) -def test_calc_next_run(_time, _cron, expected): +def test_calc_next_run(_time: str, _cron: str, expected: str) -> None: expected_date = dt.datetime.strptime(expected, "%Y-%m-%d %H:%M:%S") time_date = dt.datetime.strptime(_time, "%Y-%m-%d %H:%M:%S") diff --git a/toogoodtogo_ha_mqtt_bridge/watchdog.py b/toogoodtogo_ha_mqtt_bridge/watchdog.py index 62ce01f..94371c2 100644 --- a/toogoodtogo_ha_mqtt_bridge/watchdog.py +++ b/toogoodtogo_ha_mqtt_bridge/watchdog.py @@ -1,22 +1,23 @@ from threading import Timer +from typing import Any, Callable class Watchdog(Exception): """Thx https://stackoverflow.com/a/16148744""" - def __init__(self, timeout, user_handler=None): # timeout in seconds + def __init__(self, timeout: float, user_handler: Callable[[], Any] | None = None) -> None: # timeout in seconds self.timeout = timeout - self.handler = user_handler if user_handler is not None else self.default_handler + self.handler: Callable[[], Any] = user_handler if user_handler is not None else self.default_handler self.timer = Timer(self.timeout, self.handler) self.timer.start() - def reset(self): + def reset(self) -> None: self.timer.cancel() self.timer = Timer(self.timeout, self.handler) self.timer.start() - def stop(self): + def stop(self) -> None: self.timer.cancel() - def default_handler(self): + def default_handler(self) -> None: raise self diff --git a/uv.lock b/uv.lock index 8aa2a91..cc4d500 100644 --- a/uv.lock +++ b/uv.lock @@ -430,15 +430,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/a7/84c96b61fd13205f2cafbe263cdb2745965974bdf3e0078f121dfeca5f02/schedule-1.2.2-py3-none-any.whl", hash = "sha256:5bef4a2a0183abf44046ae0d164cadcac21b1db011bdd8102e4a0c1e91e06a7d", size = 12220 }, ] -[[package]] -name = "setuptools" -version = "75.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/54/292f26c208734e9a7f067aea4a7e282c080750c4546559b58e2e45413ca0/setuptools-75.6.0.tar.gz", hash = "sha256:8199222558df7c86216af4f84c30e9b34a61d8ba19366cc914424cdbd28252f6", size = 1337429 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/55/21/47d163f615df1d30c094f6c8bbb353619274edccf0327b185cc2493c2c33/setuptools-75.6.0-py3-none-any.whl", hash = "sha256:ce74b49e8f7110f9bf04883b730f4765b774ef3ef28f722cce7c273d253aaf7d", size = 1224032 }, -] - [[package]] name = "six" version = "1.16.0" @@ -448,15 +439,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254", size = 11053 }, ] -[[package]] -name = "tenacity" -version = "9.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/94/91fccdb4b8110642462e653d5dcb27e7b674742ad68efd146367da7bdb10/tenacity-9.0.0.tar.gz", hash = "sha256:807f37ca97d62aa361264d497b0e31e92b8027044942bfa756160d908320d73b", size = 47421 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b6/cb/b86984bed139586d01532a587464b5805f12e397594f19f931c4c2fbfa61/tenacity-9.0.0-py3-none-any.whl", hash = "sha256:93de0c98785b27fcf659856aa9f54bfbd399e29969b0621bc7f762bd441b4539", size = 28169 }, -] - [[package]] name = "tgtg" version = "0.18.1" @@ -471,7 +453,7 @@ wheels = [ [[package]] name = "toogoodtogo-ha-mqtt-bridge" -version = "3.0.1.dev2+g71de785.d20241221" +version = "3.0.1.dev11+g70503d7.d20241229" source = { editable = "." } dependencies = [ { name = "arrow" }, @@ -485,8 +467,6 @@ dependencies = [ { name = "paho-mqtt" }, { name = "random-user-agent" }, { name = "schedule" }, - { name = "setuptools" }, - { name = "tenacity" }, { name = "tgtg" }, ] @@ -497,6 +477,7 @@ dev = [ { name = "pre-commit" }, { name = "pytest" }, { name = "ruff" }, + { name = "types-croniter" }, ] [package.metadata] @@ -512,8 +493,6 @@ requires-dist = [ { name = "paho-mqtt", specifier = "==2.1.0" }, { name = "random-user-agent" }, { name = "schedule" }, - { name = "setuptools" }, - { name = "tenacity" }, { name = "tgtg", specifier = "==0.18.1" }, ] @@ -524,6 +503,16 @@ dev = [ { name = "pre-commit", specifier = ">=2.20.0" }, { name = "pytest", specifier = ">=7.2.0" }, { name = "ruff", specifier = ">=0.6.9" }, + { name = "types-croniter" }, +] + +[[package]] +name = "types-croniter" +version = "5.0.1.20241205" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/77/2f/f3298f148a7983df2a878d53c4a17ef1dd8654a155e11caa8decb7d79670/types_croniter-5.0.1.20241205.tar.gz", hash = "sha256:8a7cb10aa0b487c654a10c14d4e99098c92a5d68ee55c7a92a3df095831a6933", size = 10928 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/ec/ff0222b912fdff040a1908ea226f64ff5a1a384a4062a8714eb8b80ea5d9/types_croniter-5.0.1.20241205-py3-none-any.whl", hash = "sha256:e36e992d6a4600ddf510991a20d3273b888ecea5d77a19078348e00775ba54d3", size = 9375 }, ] [[package]]