From 75dac1db2e25c57a9f45080fa7f03ab82ff84bc6 Mon Sep 17 00:00:00 2001 From: Sassan Haradji Date: Tue, 19 Mar 2024 19:35:26 +0400 Subject: [PATCH] chore(test): improve snapshot tests to detect extra/less snapshots too chore(test): better organize snapshot results in sub-directories chore(test): collect mismatching snapshots (store and window) in GitHub workflow chore(test): add `--override-window-snapshots` to `pytest` to intentionally override window snapshots when they have changed chore(test): add `--make-screenshots` to `pytest` to create window screenshots to help find the differences visually refactor: general improvements in the codebase to address issues found during writing tests chore: add badges to `README.md` --- .github/workflows/integration_delivery.yml | 28 +- .gitignore | 2 +- CHANGELOG.md | 16 + README.md | 7 + poetry.lock | 106 ++--- pyproject.toml | 21 +- scripts/Dockerfile.dev | 9 + tests/conftest.py | 100 ++++- .../all_services_register/store:000.jsonc | 383 ++++++++++++++++++ .../all_services_register/window:000.hash} | 3 +- tests/snapshot.py | 154 +++++-- tests/test_general_health.py | 10 +- tests/test_services.py | 37 +- ubo_app/.test.env | 3 + ubo_app/__init__.py | 110 ----- ubo_app/constants.py | 1 - ubo_app/load_services.py | 1 + ubo_app/main.py | 115 ++++++ ubo_app/menu.py | 1 + ubo_app/menu_central.py | 35 +- ubo_app/menu_footer.py | 23 +- ubo_app/services/010-ip/setup.py | 5 +- ubo_app/services/010-wifi/pages/main.py | 2 +- ubo_app/services/080-docker/image.py | 14 +- ubo_app/services/080-docker/setup.py | 1 + ubo_app/side_effects.py | 27 +- ubo_app/store/__init__.py | 41 +- ubo_app/store/main/__init__.py | 15 +- ubo_app/store/main/reducer.py | 11 +- ubo_app/store/services/docker.py | 6 +- ubo_app/store/services/sensors.py | 4 +- ubo_app/store/update_manager/__init__.py | 4 +- ubo_app/store/update_manager/utils.py | 13 +- ubo_app/utils/async_.py | 9 +- ubo_app/utils/fake.py | 12 +- ubo_app/utils/loop.py | 45 +- 36 files changed, 1011 insertions(+), 363 deletions(-) create mode 100644 scripts/Dockerfile.dev create mode 100644 tests/results/test_services/all_services_register/store:000.jsonc rename tests/results/{test_all_services_register-000.hash => test_services/all_services_register/window:000.hash} (81%) create mode 100644 ubo_app/.test.env create mode 100644 ubo_app/main.py diff --git a/.github/workflows/integration_delivery.yml b/.github/workflows/integration_delivery.yml index fcfce35d..fd92c229 100644 --- a/.github/workflows/integration_delivery.yml +++ b/.github/workflows/integration_delivery.yml @@ -124,7 +124,9 @@ jobs: key: poetry-${{ hashFiles('poetry.lock') }} - name: Test - run: poetry run poe test --cov-report=xml --cov-report=html + run: + poetry run poe test --cov-report=xml --cov-report=html + --make-screenshots - name: Collect Mismatching Screenshots if: failure() @@ -133,16 +135,34 @@ jobs: name: mismatching-screenshots path: tests/**/*.mismatch.png + - name: Prepare list of JSON files with mismatching pairs + if: failure() + run: | + mkdir -p artifacts + for file in $(find tests/ -name "*.mismatch.jsonc"); do + base=${file%.mismatch.jsonc}.jsonc + if [[ -f "$base" ]]; then + echo "$file" >> artifacts/files_to_upload.txt + echo "$base" >> artifacts/files_to_upload.txt + fi + done + + - name: Collect Mismatching Store Snapshots + if: failure() + uses: actions/upload-artifact@v4 + with: + name: mismatching-snapshots + path: | + @artifacts/files_to_upload.txt + - name: Collect HTML Coverage Report - if: always() uses: actions/upload-artifact@v4 with: name: coverage-report path: htmlcov - name: Upload Coverage to Codecov - if: always() - uses: codecov/codecov-action@v4.0.1 + uses: codecov/codecov-action@v4 with: file: ./coverage.xml flags: integration diff --git a/.gitignore b/.gitignore index 0116701d..8a63a5b4 100644 --- a/.gitignore +++ b/.gitignore @@ -52,7 +52,7 @@ coverage.xml .hypothesis/ .pytest_cache/ cover/ -tests/**/results/*png +tests/results/**/*png # Translations *.mo diff --git a/CHANGELOG.md b/CHANGELOG.md index 98918cfa..c4decbe0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## Version 0.11.2 + +- chore(test): improve snapshot tests to detect extra/less snapshots too +- chore(test): better organize snapshot results in sub-directories +- chore(test): collect mismatching snapshots (store and window) in GitHub workflow +- chore(test): add `--override-window-snapshots` to `pytest` to intentionally override + window snapshots when they have changed +- chore(test): add `--make-screenshots` to `pytest` to create window screenshots + to help find the differences visually +- chore(test): monkeypatchings for dynamic parts of the app to make tests consistent +- refactor: general improvements in the codebase to address issues found during + writing tests +- chore: add badges to `README.md` +- chore: add `Dockerfile.dev` for development, it helps to build consistent screenshots + in macOS + ## Version 0.11.1 - chore(test): set up testing framework with initial examples diff --git a/README.md b/README.md index 79a84af1..30d6115f 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,11 @@ # 🚀 Ubo App +[![image](https://img.shields.io/pypi/v/ubo-app.svg)](https://pypi.python.org/pypi/ubo-app) +[![image](https://img.shields.io/pypi/l/ubo-app.svg)](https://github.com/ubopod/ubo-app/LICENSE) +[![image](https://img.shields.io/pypi/pyversions/ubo-app.svg)](https://pypi.python.org/pypi/ubo-app) +[![Actions status](https://github.com/ubopod/ubo-app/workflows/CI/CD/badge.svg)](https://github.com/ubopod/ubo-app/actions) +[![codecov](https://codecov.io/gh/ubopod/ubo-app/graph/badge.svg?token=KUI1KRDDY0)](https://codecov.io/gh/ubopod/ubo-app) + ## 🌟 Overview Ubo App is a Python application for managing Raspberry Pi utilities and UBo-specific @@ -28,6 +34,7 @@ Note that as part of the installation process, these debian packages are install - libgl1 - libmtdev1 - libzbar0 +- python3 - python3-dev - python3-libcamera - python3-alsaaudio diff --git a/poetry.lock b/poetry.lock index 36c36446..bab736f3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -126,13 +126,13 @@ typing-extensions = ">=4.0,<5.0" [[package]] name = "adafruit-circuitpython-requests" -version = "3.2.0" +version = "3.2.2" description = "A requests-like library for web interfacing" optional = false python-versions = "*" files = [ - {file = "adafruit-circuitpython-requests-3.2.0.tar.gz", hash = "sha256:d1e7d1c203a011c109a58681814a0c66c89e1471cc0e1c3333788ef6b30d3650"}, - {file = "adafruit_circuitpython_requests-3.2.0-py3-none-any.whl", hash = "sha256:493df4937943b35bf027b9f8a3e59b7364b06b6f01ddcc55f0ef936c809d210c"}, + {file = "adafruit-circuitpython-requests-3.2.2.tar.gz", hash = "sha256:57fabbeebed2f4dd9a7a860341a70d94d748030f9dc1b4d5df35a1b599cfb3ad"}, + {file = "adafruit_circuitpython_requests-3.2.2-py3-none-any.whl", hash = "sha256:474efb9349e9e7d6100cbc78bfbd88ca0aca4f630eff2bd4808db8b05512a859"}, ] [package.dependencies] @@ -1197,38 +1197,38 @@ windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pyobjc-core" -version = "10.1" +version = "10.2" description = "Python<->ObjC Interoperability Module" optional = true python-versions = ">=3.8" files = [ - {file = "pyobjc-core-10.1.tar.gz", hash = "sha256:1844f1c8e282839e6fdcb9a9722396c1c12fb1e9331eb68828a26f28a3b2b2b1"}, - {file = "pyobjc_core-10.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2a72a88222539ad07b5c8be411edc52ff9147d7cef311a2c849869d7bb9603fd"}, - {file = "pyobjc_core-10.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:fe1b9987b7b0437685fb529832876c2a8463500114960d4e76bb8ae96b6bf208"}, - {file = "pyobjc_core-10.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:9f628779c345d3abd0e20048fb0e256d894c22254577a81a6dcfdb92c3647682"}, - {file = "pyobjc_core-10.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:25a9e5a2de19238787d24cfa7def6b7fbb94bbe89c0e3109f71c1cb108e8ab44"}, - {file = "pyobjc_core-10.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:2d43205d3a784aa87055b84c0ec0dfa76498e5f18d1ad16bdc58a3dcf5a7d5d0"}, - {file = "pyobjc_core-10.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:0aa9799b5996a893944999a2f1afcf1de119cab3551c169ad9f54d12e1d38c99"}, + {file = "pyobjc-core-10.2.tar.gz", hash = "sha256:0153206e15d0e0d7abd53ee8a7fbaf5606602a032e177a028fc8589516a8771c"}, + {file = "pyobjc_core-10.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b8eab50ce7f17017a0f1d68c3b7e88bb1bb033415fdff62b8e0a9ee4ab72f242"}, + {file = "pyobjc_core-10.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f2115971463073426ab926416e17e5c16de5b90d1a1f2a2d8724637eb1c21308"}, + {file = "pyobjc_core-10.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:a70546246177c23acb323c9324330e37638f1a0a3d13664abcba3bb75e43012c"}, + {file = "pyobjc_core-10.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a9b5a215080d13bd7526031d21d5eb27a410780878d863f486053a0eba7ca9a5"}, + {file = "pyobjc_core-10.2-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:eb1ab700a44bcc4ceb125091dfaae0b998b767b49990df5fdc83eb58158d8e3f"}, + {file = "pyobjc_core-10.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c9a7163aff9c47d654f835f80361c1b112886ec754800d34e75d1e02ff52c3d7"}, ] [[package]] name = "pyobjc-framework-cocoa" -version = "10.1" +version = "10.2" description = "Wrappers for the Cocoa frameworks on macOS" optional = true python-versions = ">=3.8" files = [ - {file = "pyobjc-framework-Cocoa-10.1.tar.gz", hash = "sha256:8faaf1292a112e488b777d0c19862d993f3f384f3927dc6eca0d8d2221906a14"}, - {file = "pyobjc_framework_Cocoa-10.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2e82c2e20b89811d92a7e6e487b6980f360b7c142e2576e90f0e7569caf8202b"}, - {file = "pyobjc_framework_Cocoa-10.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0860a9beb7e5c72a1f575679a6d1428a398fa19ad710fb116df899972912e304"}, - {file = "pyobjc_framework_Cocoa-10.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:34b791ea740e1afce211f19334e45469fea9a48d8fce5072e146199fd19ff49f"}, - {file = "pyobjc_framework_Cocoa-10.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1398c1a9bebad1a0f2549980e20f4aade00c341b9bac56b4493095a65917d34a"}, - {file = "pyobjc_framework_Cocoa-10.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:22be21226e223d26c9e77645564225787f2b12a750dd17c7ad99c36f428eda14"}, - {file = "pyobjc_framework_Cocoa-10.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:0280561f4fb98a864bd23f2c480d907b0edbffe1048654f5dfab160cea8198e6"}, + {file = "pyobjc-framework-Cocoa-10.2.tar.gz", hash = "sha256:6383141379636b13855dca1b39c032752862b829f93a49d7ddb35046abfdc035"}, + {file = "pyobjc_framework_Cocoa-10.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f9227b4f271fda2250f5a88cbc686ff30ae02c0f923bb7854bb47972397496b2"}, + {file = "pyobjc_framework_Cocoa-10.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6a6042b7703bdc33b7491959c715c1e810a3f8c7a560c94b36e00ef321480797"}, + {file = "pyobjc_framework_Cocoa-10.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:18886d5013cd7dc7ecd6e0df5134c767569b5247fc10a5e293c72ee3937b217b"}, + {file = "pyobjc_framework_Cocoa-10.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1ecf01400ee698d2e0ff4c907bcf9608d9d710e97203fbb97b37d208507a9362"}, + {file = "pyobjc_framework_Cocoa-10.2-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:0def036a7b24e3ae37a244c77bec96b7c9c8384bf6bb4d33369f0a0c8807a70d"}, + {file = "pyobjc_framework_Cocoa-10.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5f47ecc393bc1019c4b47e8653207188df784ac006ad54d8c2eb528906ff7013"}, ] [package.dependencies] -pyobjc-core = ">=10.1" +pyobjc-core = ">=10.2" [[package]] name = "pypiwin32" @@ -1343,6 +1343,20 @@ pytest = ">=4.6" [package.extras] testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] +[[package]] +name = "pytest-timeout" +version = "2.3.1" +description = "pytest plugin to abort hanging tests" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-timeout-2.3.1.tar.gz", hash = "sha256:12397729125c6ecbdaca01035b9e5239d4db97352320af155b3f5de1ba5165d9"}, + {file = "pytest_timeout-2.3.1-py3-none-any.whl", hash = "sha256:68188cb703edfc6a18fad98dc25a3c61e9f24d644b0b70f33af545219fc7813e"}, +] + +[package.dependencies] +pytest = ">=7.0.0" + [[package]] name = "pytest-xdist" version = "3.5.0" @@ -1394,13 +1408,13 @@ cli = ["click (>=5.0)"] [[package]] name = "python-immutable" -version = "1.0.4" +version = "1.0.5" description = "Immutable implementation for Python using dataclasses" optional = false python-versions = ">=3.9,<4.0" files = [ - {file = "python_immutable-1.0.4-py3-none-any.whl", hash = "sha256:cd260cc92983815ff807fece3664e361592691f66373743ac5d6f96b8df5144b"}, - {file = "python_immutable-1.0.4.tar.gz", hash = "sha256:80cd10a7c4b7a871151a7584a2578bd5b89d478e6ff9f6af521d93fe2a32e5fa"}, + {file = "python_immutable-1.0.5-py3-none-any.whl", hash = "sha256:ea1309539a954ff2b527e4d175261ceddd96dd97a4bec436f5825ebb2d4f2818"}, + {file = "python_immutable-1.0.5.tar.gz", hash = "sha256:e2a30d8b4b1fe8dfe3aedc02392b0e567915b3aabbb6f57abb1689780e2ec1a4"}, ] [package.dependencies] @@ -1408,17 +1422,17 @@ typing-extensions = ">=4.10.0,<5.0.0" [[package]] name = "python-redux" -version = "0.11.0" +version = "0.12.5" description = "Redux implementation for Python" optional = false python-versions = ">=3.11,<4.0" files = [ - {file = "python_redux-0.11.0-py3-none-any.whl", hash = "sha256:8cfb2eb8b74f0b538589b92bf270c9b3611e66174388df61050413725ef73081"}, - {file = "python_redux-0.11.0.tar.gz", hash = "sha256:030669b6211984e3fceb8150e769f9f53d15a483a8e4865d889dc94648607bad"}, + {file = "python_redux-0.12.5-py3-none-any.whl", hash = "sha256:219739c771db0d79620b1f689e60d1de4fdc33cab179ad5075d8bbf286351cbc"}, + {file = "python_redux-0.12.5.tar.gz", hash = "sha256:1b0fb64f1a57d7939bfb6153c66949ec7b490b287d2e3ded81ff9d950d538907"}, ] [package.dependencies] -python-immutable = ">=1.0.2,<2.0.0" +python-immutable = ">=1.0.5,<2.0.0" typing-extensions = ">=4.9.0,<5.0.0" [[package]] @@ -1526,28 +1540,28 @@ files = [ [[package]] name = "ruff" -version = "0.3.2" +version = "0.3.3" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.3.2-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77f2612752e25f730da7421ca5e3147b213dca4f9a0f7e0b534e9562c5441f01"}, - {file = "ruff-0.3.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9966b964b2dd1107797be9ca7195002b874424d1d5472097701ae8f43eadef5d"}, - {file = "ruff-0.3.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b83d17ff166aa0659d1e1deaf9f2f14cbe387293a906de09bc4860717eb2e2da"}, - {file = "ruff-0.3.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb875c6cc87b3703aeda85f01c9aebdce3d217aeaca3c2e52e38077383f7268a"}, - {file = "ruff-0.3.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:be75e468a6a86426430373d81c041b7605137a28f7014a72d2fc749e47f572aa"}, - {file = "ruff-0.3.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:967978ac2d4506255e2f52afe70dda023fc602b283e97685c8447d036863a302"}, - {file = "ruff-0.3.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1231eacd4510f73222940727ac927bc5d07667a86b0cbe822024dd00343e77e9"}, - {file = "ruff-0.3.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c6d613b19e9a8021be2ee1d0e27710208d1603b56f47203d0abbde906929a9b"}, - {file = "ruff-0.3.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8439338a6303585d27b66b4626cbde89bb3e50fa3cae86ce52c1db7449330a7"}, - {file = "ruff-0.3.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:de8b480d8379620cbb5ea466a9e53bb467d2fb07c7eca54a4aa8576483c35d36"}, - {file = "ruff-0.3.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b74c3de9103bd35df2bb05d8b2899bf2dbe4efda6474ea9681280648ec4d237d"}, - {file = "ruff-0.3.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f380be9fc15a99765c9cf316b40b9da1f6ad2ab9639e551703e581a5e6da6745"}, - {file = "ruff-0.3.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:0ac06a3759c3ab9ef86bbeca665d31ad3aa9a4b1c17684aadb7e61c10baa0df4"}, - {file = "ruff-0.3.2-py3-none-win32.whl", hash = "sha256:9bd640a8f7dd07a0b6901fcebccedadeb1a705a50350fb86b4003b805c81385a"}, - {file = "ruff-0.3.2-py3-none-win_amd64.whl", hash = "sha256:0c1bdd9920cab5707c26c8b3bf33a064a4ca7842d91a99ec0634fec68f9f4037"}, - {file = "ruff-0.3.2-py3-none-win_arm64.whl", hash = "sha256:5f65103b1d76e0d600cabd577b04179ff592064eaa451a70a81085930e907d0b"}, - {file = "ruff-0.3.2.tar.gz", hash = "sha256:fa78ec9418eb1ca3db392811df3376b46471ae93792a81af2d1cbb0e5dcb5142"}, + {file = "ruff-0.3.3-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:973a0e388b7bc2e9148c7f9be8b8c6ae7471b9be37e1cc732f8f44a6f6d7720d"}, + {file = "ruff-0.3.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:cfa60d23269d6e2031129b053fdb4e5a7b0637fc6c9c0586737b962b2f834493"}, + {file = "ruff-0.3.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1eca7ff7a47043cf6ce5c7f45f603b09121a7cc047447744b029d1b719278eb5"}, + {file = "ruff-0.3.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7d3f6762217c1da954de24b4a1a70515630d29f71e268ec5000afe81377642d"}, + {file = "ruff-0.3.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b24c19e8598916d9c6f5a5437671f55ee93c212a2c4c569605dc3842b6820386"}, + {file = "ruff-0.3.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:5a6cbf216b69c7090f0fe4669501a27326c34e119068c1494f35aaf4cc683778"}, + {file = "ruff-0.3.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:352e95ead6964974b234e16ba8a66dad102ec7bf8ac064a23f95371d8b198aab"}, + {file = "ruff-0.3.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d6ab88c81c4040a817aa432484e838aaddf8bfd7ca70e4e615482757acb64f8"}, + {file = "ruff-0.3.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79bca3a03a759cc773fca69e0bdeac8abd1c13c31b798d5bb3c9da4a03144a9f"}, + {file = "ruff-0.3.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2700a804d5336bcffe063fd789ca2c7b02b552d2e323a336700abb8ae9e6a3f8"}, + {file = "ruff-0.3.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fd66469f1a18fdb9d32e22b79f486223052ddf057dc56dea0caaf1a47bdfaf4e"}, + {file = "ruff-0.3.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:45817af234605525cdf6317005923bf532514e1ea3d9270acf61ca2440691376"}, + {file = "ruff-0.3.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:0da458989ce0159555ef224d5b7c24d3d2e4bf4c300b85467b08c3261c6bc6a8"}, + {file = "ruff-0.3.3-py3-none-win32.whl", hash = "sha256:f2831ec6a580a97f1ea82ea1eda0401c3cdf512cf2045fa3c85e8ef109e87de0"}, + {file = "ruff-0.3.3-py3-none-win_amd64.whl", hash = "sha256:be90bcae57c24d9f9d023b12d627e958eb55f595428bafcb7fec0791ad25ddfc"}, + {file = "ruff-0.3.3-py3-none-win_arm64.whl", hash = "sha256:0171aab5fecdc54383993389710a3d1227f2da124d76a2784a7098e818f92d61"}, + {file = "ruff-0.3.3.tar.gz", hash = "sha256:38671be06f57a2f8aba957d9f701ea889aa5736be806f18c0cd03d6ff0cbca8d"}, ] [[package]] @@ -1815,4 +1829,4 @@ dev = ["ubo-gui", "ubo-gui"] [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "002ab898ba34f1e2566383fc92da243c57eeba5f68b0ff829456bbdf2a654469" +content-hash = "6f892f524d1dabefd5a0abdc517a69a5a668dc35c906d4adfdeed065e026290a" diff --git a/pyproject.toml b/pyproject.toml index b7145234..89f5afd8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "ubo-app" -version = "0.11.1" +version = "0.11.2" description = "Ubo main app, running on device initialization. A platform for running other apps." authors = ["Sassan Haradji "] license = "Apache-2.0" @@ -26,7 +26,7 @@ ubo-gui = [ 'dev', ] }, ] -python-redux = "^0.11.0" +python-redux = "^0.12.5" pyzbar = "^0.1.9" sdbus-networkmanager = { version = "^2.0.0", markers = "platform_machine=='aarch64'" } rpi_ws281x = { version = "^5.0.0", markers = "platform_machine=='aarch64'" } @@ -45,21 +45,22 @@ optional = true [tool.poetry.group.dev.dependencies] poethepoet = "^0.24.4" -pyright = "^1.1.349" -ruff = "^0.3.2" -toml = "^0.10.2" +pyright = "^1.1.354" pytest = "^8.0.0" +pytest-asyncio = "^0.23.5.post1" pytest-cov = "^4.1.0" +pytest-timeout = "^2.3.1" pytest-xdist = "^3.5.0" +ruff = "^0.3.3" tenacity = "^8.2.3" -pytest-asyncio = "^0.23.5.post1" +toml = "^0.10.2" [tool.poetry.extras] default = ['ubo-gui'] dev = ['ubo-gui'] [tool.poetry.scripts] -ubo = "ubo_app:main" +ubo = "ubo_app.main:main" [build-system] requires = ["poetry-core"] @@ -68,7 +69,7 @@ build-backend = "poetry.core.masonry.api" [tool.poe.tasks] lint = "ruff check . --unsafe-fixes" typecheck = "pyright -p pyproject.toml ." -test = "pytest --cov=ubo_app --cov-report=term-missing" +test = "pytest --cov=ubo_app" sanity = ["typecheck", "lint", "test"] [tool.poe.tasks.deploy_to_device] @@ -110,3 +111,7 @@ asyncio_mode = 'auto' filterwarnings = "ignore:'imghdr' is deprecated:DeprecationWarning" log_cli = 1 log_cli_level = 'ERROR' +timeout = 30 + +[tool.coverage.report] +exclude_also = ["if TYPE_CHECKING:"] diff --git a/scripts/Dockerfile.dev b/scripts/Dockerfile.dev new file mode 100644 index 00000000..eddc6ee4 --- /dev/null +++ b/scripts/Dockerfile.dev @@ -0,0 +1,9 @@ +FROM ubuntu:mantic + +ARG DEBIAN_FRONTEND=noninteractive +RUN apt -y update +RUN apt -y install curl git libcap-dev libegl1 libgl1 libmtdev1 libzbar0 python3 python3-dev +RUN curl -sSL https://install.python-poetry.org | python3 - +ENV PATH="${PATH}:/root/.local/bin" +WORKDIR /ubo-app +CMD tail -f /dev/null diff --git a/tests/conftest.py b/tests/conftest.py index fa59672d..e9552106 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,33 +1,49 @@ """Pytest configuration file for the tests.""" + from __future__ import annotations import asyncio import atexit import datetime import gc +import random +import socket import sys import threading +import tracemalloc +import uuid import weakref +from pathlib import Path from typing import TYPE_CHECKING, AsyncGenerator, Callable, Generator +import dotenv import pytest -from redux import FinishAction -from snapshot import snapshot -from tenacity import retry, stop_after_delay, wait_exponential -from ubo_app.utils.garbage_collection import examine +pytest.register_assert_rewrite('redux.test') + +dotenv.load_dotenv(Path(__file__).parent / '.test.env') + +import redux.test # noqa: E402 +from tenacity import retry, stop_after_delay, wait_exponential # noqa: E402 + +from tests.snapshot import ubo_store_snapshot, window_snapshot # noqa: E402 +from ubo_app.utils.garbage_collection import examine # noqa: E402 if TYPE_CHECKING: from logging import Logger + from _pytest.fixtures import SubRequest + from ubo_app.menu import MenuApp -__all__ = ('app_context', 'snapshot') +store_snapshot = redux.test.store_snapshot +__all__ = ('app_context', 'ubo_store_snapshot', 'window_snapshot') -@pytest.fixture(autouse=True, name='monkeypatch_atexit') -def _(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(atexit, 'register', lambda _: None) +def pytest_addoption(parser: pytest.Parser) -> None: + redux.test.pytest_addoption(parser) + parser.addoption('--override-window-snapshots', action='store_true') + parser.addoption('--make-screenshots', action='store_true') modules_snapshot = set(sys.modules) @@ -36,11 +52,40 @@ def _(monkeypatch: pytest.MonkeyPatch) -> None: @pytest.fixture(autouse=True) def _(monkeypatch: pytest.MonkeyPatch) -> None: """Mock external resources.""" - monkeypatch.setattr('psutil.cpu_percent', lambda **_: 50) + random.seed(0) + tracemalloc.start() + + monkeypatch.setattr(atexit, 'register', lambda _: None) + + import psutil + + monkeypatch.setattr(psutil, 'cpu_percent', lambda **_: 50) monkeypatch.setattr( - 'psutil.virtual_memory', + psutil, + 'virtual_memory', lambda *_: type('', (object,), {'percent': 50}), ) + monkeypatch.setattr( + psutil, + 'net_if_addrs', + lambda: { + 'eth0': [ + psutil._common.snicaddr( # noqa: SLF001 # pyright: ignore [reportAttributeAccessIssue] + family=socket.AddressFamily.AF_INET, + address='192.168.1.1', + netmask='255.255.255.0', + broadcast='192.168.1.255', + ptp=None, + ), + ], + }, + ) + + class FakeDockerClient: + def ping(self: FakeDockerClient) -> bool: + return False + + monkeypatch.setattr('docker.from_env', lambda: FakeDockerClient()) class DateTime(datetime.datetime): @classmethod @@ -49,6 +94,28 @@ def now(cls: type[DateTime], tz: datetime.tzinfo | None = None) -> DateTime: return DateTime(2023, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc) monkeypatch.setattr(datetime, 'datetime', DateTime) + monkeypatch.setattr(uuid, 'uuid4', lambda: uuid.UUID(int=random.getrandbits(128))) + + monkeypatch.setattr('importlib.metadata.version', lambda _: '0.0.0') + + from ubo_app.utils.fake import Fake + + class FakeUpdateResponse(Fake): + async def json(self: FakeUpdateResponse) -> dict[str, object]: + return { + 'info': { + 'version': '0.0.0', + }, + } + + class FakeAiohttp(Fake): + def get(self: FakeAiohttp, url: str, **kwargs: dict[str, object]) -> Fake: + if url == 'https://pypi.org/pypi/ubo-app/json': + return FakeUpdateResponse() + parent = super() + return parent.get(url, **kwargs) + + sys.modules['aiohttp'] = FakeAiohttp() @pytest.fixture() @@ -85,7 +152,10 @@ def set_app(self: AppContext, app: MenuApp) -> None: @pytest.fixture() -async def app_context(logger: Logger) -> AsyncGenerator[AppContext, None]: +async def app_context( + logger: Logger, + request: SubRequest, +) -> AsyncGenerator[AppContext, None]: """Create the application.""" import os @@ -100,7 +170,7 @@ async def app_context(logger: Logger) -> AsyncGenerator[AppContext, None]: yield context - assert context.task is not None, 'App not set for test' + assert hasattr(context, 'task'), 'App not set for test' await context.task @@ -113,7 +183,7 @@ async def app_context(logger: Logger) -> AsyncGenerator[AppContext, None]: gc.collect() app = app_ref() - if app is not None: + if app is not None and request.session.testsfailed == 0: logger.debug( 'Memory leak: failed to release app for test.', extra={ @@ -128,7 +198,7 @@ async def app_context(logger: Logger) -> AsyncGenerator[AppContext, None]: if type(cell).__name__ == 'cell': logger.debug('CELL EXAMINATION', extra={'cell': cell}) examine(cell, depth_limit=2) - assert app is None, 'Memory leak: failed to release app for test' + assert app is None, 'Memory leak: failed to release app for test' from kivy.core.window import Window @@ -144,6 +214,8 @@ async def app_context(logger: Logger) -> AsyncGenerator[AppContext, None]: def needs_finish() -> Generator: yield None + from redux import FinishAction + from ubo_app.store import dispatch dispatch(FinishAction()) diff --git a/tests/results/test_services/all_services_register/store:000.jsonc b/tests/results/test_services/all_services_register/store:000.jsonc new file mode 100644 index 00000000..5d968e90 --- /dev/null +++ b/tests/results/test_services/all_services_register/store:000.jsonc @@ -0,0 +1,383 @@ +// store:000 +{ + "_id": "e3e70682c2094cac629f6fbed82c07cd", + "camera": { + "is_viewfinder_active": false + }, + "docker": { + "_id": "5a92118719c78df48f4ff31e78de5857", + "home_assistant": { + "container_ip": null, + "docker_id": null, + "icon": "home", + "id": "home_assistant", + "ip_addresses": [ + "192.168.1.1" + ], + "label": "Home Assistant", + "path": "homeassistant/home-assistant:stable", + "ports": [], + "status": "not_available" + }, + "home_bridge": { + "container_ip": null, + "docker_id": null, + "icon": "home_work", + "id": "home_bridge", + "ip_addresses": [ + "192.168.1.1" + ], + "label": "Home Bridge", + "path": "homebridge/homebridge:latest", + "ports": [], + "status": "not_available" + }, + "ollama": { + "container_ip": null, + "docker_id": null, + "icon": "smart_toy", + "id": "ollama", + "ip_addresses": [ + "192.168.1.1" + ], + "label": "Ollama", + "path": "ollama/ollama:latest", + "ports": [], + "status": "not_available" + }, + "open_webui": { + "container_ip": null, + "docker_id": null, + "icon": "code", + "id": "open_webui", + "ip_addresses": [ + "192.168.1.1" + ], + "label": "Open WebUI", + "path": "ghcr.io/open-webui/open-webui:main", + "ports": [], + "status": "not_available" + }, + "pi_hole": { + "container_ip": null, + "docker_id": null, + "icon": "dns", + "id": "pi_hole", + "ip_addresses": [ + "192.168.1.1" + ], + "label": "Pi-hole", + "path": "pihole/pihole:latest", + "ports": [], + "status": "not_available" + }, + "portainer": { + "container_ip": null, + "docker_id": null, + "icon": "settings_applications", + "id": "portainer", + "ip_addresses": [ + "192.168.1.1" + ], + "label": "Portainer", + "path": "portainer/portainer-ce:latest", + "ports": [], + "status": "not_available" + }, + "service": { + "status": "not_running" + } + }, + "ip": { + "interfaces": [ + { + "ip_addresses": [ + "192.168.1.1" + ], + "name": "eth0" + } + ] + }, + "main": { + "menu": { + "_id": null, + "items": [ + { + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": "menu", + "is_short": true, + "label": "", + "sub_menu": { + "_id": null, + "items": [ + { + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": "apps", + "is_short": false, + "label": "Apps", + "sub_menu": { + "_id": null, + "items": [ + { + "action": "", + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": "D", + "is_short": false, + "label": "Docker" + } + ], + "title": "Apps" + } + }, + { + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": "settings", + "is_short": false, + "label": "Settings", + "sub_menu": { + "_id": null, + "items": [ + { + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": "lan", + "is_short": false, + "label": "IP Addresses", + "sub_menu": { + "_id": null, + "items": [ + { + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": "cable", + "is_short": false, + "label": "eth0", + "sub_menu": { + "_id": null, + "items": [ + { + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": "lan", + "is_short": false, + "label": "192.168.1.1" + } + ], + "title": "IP Addresses - eth0" + } + } + ], + "title": "IP Addresses" + } + }, + { + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": "wifi", + "is_short": false, + "label": "WiFi", + "sub_menu": { + "_id": null, + "items": [ + { + "application": "ubo_app/services/010-wifi/pages/create_wireless_connection.py:CreateWirelessConnectionPage", + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": "wifi_add", + "is_short": false, + "label": "Add" + }, + { + "action": "", + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": "list", + "is_short": false, + "label": "Select" + } + ], + "title": "WiFi Settings" + } + } + ], + "title": "Settings" + } + }, + { + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": "info", + "is_short": false, + "label": "About", + "sub_menu": { + "_id": null, + "heading": "Ubo v0.0.0", + "items": [ + { + "background_color": "#03F7AE", + "color": "#000000", + "icon": "security_update_good", + "is_short": false, + "label": "Already up to date!" + } + ], + "sub_heading": "A universal dashboard for your Raspberry Pi", + "title": "About" + } + } + ], + "title": "Main" + } + }, + { + "background_color": "#68B7FF", + "color": "yellow", + "icon": "info", + "is_short": true, + "label": "", + "sub_menu": { + "_id": null, + "items": [], + "title": "Notifications (0)" + } + }, + { + "action": "", + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": "power_settings_new", + "is_short": true, + "label": "Turn off" + } + ], + "title": "Dashboard" + }, + "path": [ + "Dashboard" + ] + }, + "notifications": { + "notifications": [], + "unread_count": 0 + }, + "rgb_ring": { + "is_busy": false, + "is_connected": false + }, + "sensors": { + "light": { + "value": 0.0 + }, + "temperature": { + "value": 0.0 + } + }, + "sound": { + "capture_volume": 0.5, + "is_capture_mute": false, + "is_playback_mute": false, + "playback_volume": 0.5 + }, + "status_icons": { + "icons": [ + { + "color": "white", + "id": "sound:mic-state", + "priority": -20, + "symbol": "mic_off" + }, + { + "color": "white", + "id": "ethernet:state", + "priority": -13, + "symbol": "link_off" + }, + { + "color": "white", + "id": "wifi:state", + "priority": -12, + "symbol": "signal_wifi_off" + }, + { + "color": "white", + "id": "ip:internet-state", + "priority": -11, + "symbol": "public" + } + ] + }, + "update_manager": { + "current_version": "0.0.0", + "latest_version": "0.0.0", + "update_status": "up_to_date" + }, + "wifi": { + "connections": [], + "current_connection": null, + "state": "Disconnected" + } +} diff --git a/tests/results/test_all_services_register-000.hash b/tests/results/test_services/all_services_register/window:000.hash similarity index 81% rename from tests/results/test_all_services_register-000.hash rename to tests/results/test_services/all_services_register/window:000.hash index 1416cd17..4bb4291e 100644 --- a/tests/results/test_all_services_register-000.hash +++ b/tests/results/test_services/all_services_register/window:000.hash @@ -1 +1,2 @@ -a4173e6761d21df059d9865602afbe8482c959c68a8a9426e3c3270374d77471 \ No newline at end of file +// window:000 +a4173e6761d21df059d9865602afbe8482c959c68a8a9426e3c3270374d77471 diff --git a/tests/snapshot.py b/tests/snapshot.py index 77d00140..75c38a04 100644 --- a/tests/snapshot.py +++ b/tests/snapshot.py @@ -1,8 +1,10 @@ """Let the test check snapshots of the window during execution.""" + from __future__ import annotations import os -from typing import TYPE_CHECKING +from collections import defaultdict +from typing import TYPE_CHECKING, Any, Generator, cast import pytest @@ -11,11 +13,9 @@ from pathlib import Path from _pytest.fixtures import SubRequest + from _pytest.nodes import Node from numpy._typing import NDArray - - -make_screenshots = os.environ.get('UBO_TEST_MAKE_SCREENSHOTS', '0') == '1' -override_snapshots = os.environ.get('UBO_TEST_OVERRIDE_SNAPSHOTS', '0') == '1' + from redux.test import StoreSnapshotContext def write_image(image_path: Path, array: NDArray) -> None: @@ -33,24 +33,40 @@ def write_image(image_path: Path, array: NDArray) -> None: ) -class SnapshotContext: +class WindowSnapshotContext: """Context object for tests taking snapshots of the window.""" - def __init__(self: SnapshotContext, id: str, path: Path, logger: Logger) -> None: - """Create a new snapshot context.""" - self.test_counter = 0 - self.id = id - self.results_dir = path.parent / 'results' + def __init__( + self: WindowSnapshotContext, + *, + test_node: Node, + logger: Logger, + override: bool, + make_screenshots: bool, + ) -> None: + """Create a new window snapshot context.""" + self.closed = False + self.override = override + self.make_screenshots = make_screenshots + self.test_counter: dict[str | None, int] = defaultdict(int) + file = test_node.path.with_suffix('').name + self.results_dir = ( + test_node.path.parent + / 'results' + / file + / test_node.nodeid.split('::')[-1][5:] + ) + if self.results_dir.exists(): + for file in self.results_dir.glob( + 'window:*' if override else 'window:*.mismatch.*', + ): + file.unlink() + self.results_dir.mkdir(parents=True, exist_ok=True) self.logger = logger - if make_screenshots: - self.logger.info( - f'Snapshot will be saved in "{self.results_dir}" for test id {id}', # noqa: G004 - ) - self.results_dir.mkdir(exist_ok=True) @property - def hash(self: SnapshotContext) -> str: - """Return the hash of the current window.""" + def hash(self: WindowSnapshotContext) -> str: + """Return the hash of the content of the window.""" import hashlib from headless_kivy_pi.config import _display @@ -61,31 +77,99 @@ def hash(self: SnapshotContext) -> str: sha256.update(data) return sha256.hexdigest() - def take(self: SnapshotContext) -> None: - """Take a snapshot of the current window.""" - filename = f'{"_".join(self.id.split(":")[-1:])}-{self.test_counter:03d}' + def get_filename(self: WindowSnapshotContext, title: str | None) -> str: + """Get the filename for the snapshot.""" + if title: + return f"""window:{title}:{self.test_counter[title]:03d}""" + return f"""window:{self.test_counter[title]:03d}""" + + def take(self: WindowSnapshotContext, title: str | None = None) -> None: + """Take a snapshot of the content of the window.""" + if self.closed: + msg = ( + 'Snapshot context is closed, make sure `window_snapshot` is before any ' + 'fixture dispatching actions in the fixtures list' + ) + raise RuntimeError(msg) from headless_kivy_pi.config import _display + filename = self.get_filename(title) path = self.results_dir / filename hash_path = path.with_suffix('.hash') + image_path = path.with_suffix('.png') + hash_mismatch_path = path.with_suffix('.mismatch.hash') + image_mismatch_path = path.with_suffix('.mismatch.png') - hash_ = self.hash array = _display.raw_data - data = array.tobytes() - if hash_path.exists() and not override_snapshots: - old_hash = hash_path.read_text() - if old_hash != hash_: - write_image(path.with_suffix('.mismatch.png'), array) - assert old_hash == hash_, f'Hash mismatch: {old_hash} != {hash_}' - hash_path.write_text(hash_) - if data is not None and make_screenshots: - write_image(path.with_suffix('.png'), array) - self.test_counter += 1 + new_snapshot = self.hash + if self.override: + hash_path.write_text(f'// {filename}\n{new_snapshot}\n') + if self.make_screenshots: + write_image(image_path, array) + else: + if hash_path.exists(): + old_snapshot = hash_path.read_text().split('\n', 1)[1][:-1] + else: + old_snapshot = None + if old_snapshot != new_snapshot: + hash_mismatch_path.write_text( # pragma: no cover + f'// MISMATCH: {filename}\n{new_snapshot}\n', + ) + write_image(image_mismatch_path, array) + assert new_snapshot == old_snapshot, f'Window snapshot mismatch: {title}' + + self.test_counter[title] += 1 + + def close(self: WindowSnapshotContext) -> None: + """Close the snapshot context.""" + for title in self.test_counter: + filename = self.get_filename(title) + hash_path = (self.results_dir / filename).with_suffix('.hash') + + assert not hash_path.exists(), f'Snapshot {filename} not taken' + self.closed = True @pytest.fixture() -def snapshot(request: SubRequest, logger: Logger) -> SnapshotContext: - """Take a snapshot of the current window.""" - return SnapshotContext(request.node.nodeid, request.node.path, logger) +def window_snapshot( + request: SubRequest, + logger: Logger, +) -> Generator[WindowSnapshotContext, None, None]: + """Take a screenshot of the window.""" + override = ( + request.config.getoption( + '--override-window-snapshots', + default=cast( + Any, + os.environ.get('UBO_TEST_OVERRIDE_SNAPSHOTS', '0') == '1', + ), + ) + is True + ) + make_screenshots = ( + request.config.getoption( + '--make-screenshots', + default=cast(Any, os.environ.get('UBO_TEST_MAKE_SCREENSHOTS', '0') == '1'), + ) + is True + ) + + context = WindowSnapshotContext( + test_node=request.node, + logger=logger, + override=override, + make_screenshots=make_screenshots, + ) + yield context + context.close() + + +@pytest.fixture(name='store_snapshot') +def ubo_store_snapshot(store_snapshot: StoreSnapshotContext) -> StoreSnapshotContext: + """Take a snapshot of the store.""" + from ubo_app.store import store + + store_snapshot.set_store(store) + return store_snapshot diff --git a/tests/test_general_health.py b/tests/test_general_health.py index 7ad01da1..5ea0569f 100644 --- a/tests/test_general_health.py +++ b/tests/test_general_health.py @@ -1,12 +1,9 @@ -# ruff: noqa: S101 """Test the general health of the application.""" + from __future__ import annotations -import asyncio from typing import TYPE_CHECKING -from redux import FinishAction - if TYPE_CHECKING: from tests.conftest import AppContext @@ -18,10 +15,7 @@ async def test_app_runs_and_exits( """Test the application starts, runs and quits.""" _ = needs_finish from ubo_app.menu import MenuApp - from ubo_app.store import dispatch app = MenuApp() - app_context.set_app(app) - dispatch(FinishAction()) - await asyncio.sleep(1) + app_context.set_app(app) diff --git a/tests/test_services.py b/tests/test_services.py index 7ee118cb..11f91f2c 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -1,5 +1,5 @@ -# ruff: noqa: S101 """Test the general health of the application.""" + from __future__ import annotations from typing import TYPE_CHECKING @@ -7,8 +7,10 @@ from tenacity import AsyncRetrying, stop_after_delay, wait_fixed if TYPE_CHECKING: + from redux.test import StoreSnapshotContext + from tests.conftest import AppContext - from tests.snapshot import SnapshotContext + from tests.snapshot import WindowSnapshotContext ALL_SERVICES_LABELS = [ 'RGB Ring', @@ -27,7 +29,8 @@ async def test_all_services_register( app_context: AppContext, - snapshot: SnapshotContext, + window_snapshot: WindowSnapshotContext, + store_snapshot: StoreSnapshotContext, needs_finish: None, ) -> None: """Test all services load.""" @@ -36,15 +39,13 @@ async def test_all_services_register( from ubo_app.menu import MenuApp app = MenuApp() - load_services() app_context.set_app(app) + load_services() - latest_hash = snapshot.hash + latest_window_hash = window_snapshot.hash + latest_store_snapshot = store_snapshot.json_snapshot - async for attempt in AsyncRetrying( - stop=stop_after_delay(15), - wait=wait_fixed(3), - ): + async for attempt in AsyncRetrying(stop=stop_after_delay(80), wait=wait_fixed(5)): with attempt: from ubo_app.load_services import REGISTERED_PATHS @@ -53,8 +54,18 @@ async def test_all_services_register( service.label == service_name and service.is_alive() for service in REGISTERED_PATHS.values() ), f'{service_name} not loaded' - is_stable = latest_hash == snapshot.hash - latest_hash = snapshot.hash - assert is_stable, 'Snapshot changed' - snapshot.take() + new_hash = window_snapshot.hash + new_snapshot = store_snapshot.json_snapshot + + is_window_stable = latest_window_hash == new_hash + is_store_stable = latest_store_snapshot == new_snapshot + + latest_window_hash = new_hash + latest_store_snapshot = new_snapshot + + assert is_window_stable, 'The content of the screen is not stable yet' + assert is_store_stable, 'The content of the store is not stable yet' + + window_snapshot.take() + store_snapshot.take() diff --git a/ubo_app/.test.env b/ubo_app/.test.env new file mode 100644 index 00000000..0d6a0a4d --- /dev/null +++ b/ubo_app/.test.env @@ -0,0 +1,3 @@ +UBO_DEBUG=False +UBO_DEBUG_DOCKER=False +DOCKER_HOST=/var/run/docker.sock diff --git a/ubo_app/__init__.py b/ubo_app/__init__.py index afc3970b..0060216a 100644 --- a/ubo_app/__init__.py +++ b/ubo_app/__init__.py @@ -1,111 +1 @@ # ruff: noqa: D100, D101, D102, D103, D104, D107 -import os -import sys -import threading -import traceback -from types import TracebackType - -from redux import FinishAction - - -def setup_logging() -> None: - from ubo_app.constants import GUI_LOG_LEVEL, LOG_LEVEL - - if LOG_LEVEL: - import logging - - import ubo_app.logging - - level = getattr( - ubo_app.logging, - LOG_LEVEL, - getattr(logging, LOG_LEVEL, logging.INFO), - ) - - ubo_app.logging.logger.setLevel(level) - ubo_app.logging.add_file_handler(ubo_app.logging.logger, level) - ubo_app.logging.add_stdout_handler(ubo_app.logging.logger, level) - if GUI_LOG_LEVEL: - import logging - - import ubo_gui.logger - - level = getattr( - ubo_gui.logger, - GUI_LOG_LEVEL, - getattr(logging, GUI_LOG_LEVEL, logging.INFO), - ) - - ubo_gui.logger.logger.setLevel(level) - ubo_gui.logger.add_file_handler(level) - ubo_gui.logger.add_stdout_handler(level) - - -def main() -> None: - """Instantiate the `MenuApp` and run it.""" - setup_logging() - - if len(sys.argv) > 1 and sys.argv[1] == 'bootstrap': - from ubo_app.system.bootstrap import bootstrap - - bootstrap( - with_docker='--with-docker' in sys.argv, - for_packer='--for-packer' in sys.argv, - ) - sys.exit(0) - - def global_exception_handler( - exception_type: type, - value: int, - tb: TracebackType, - ) -> None: - from ubo_app.logging import logger - - logger.error(f'Uncaught exception: {exception_type}: {value}') - logger.error(''.join(traceback.format_tb(tb))) - - # Set the global exception handler - sys.excepthook = global_exception_handler - - def thread_exception_handler(args: threading.ExceptHookArgs) -> None: - import traceback - - from ubo_app.logging import logger - - logger.error( - f"""Exception in thread {args.thread.name if args.thread else "-"}: { - args.exc_type} {args.exc_value}""", - ) - logger.error(''.join(traceback.format_tb(args.exc_traceback))) - - threading.excepthook = thread_exception_handler - - os.environ['KIVY_NO_CONFIG'] = '1' - os.environ['KIVY_NO_FILELOG'] = '1' - - import headless_kivy_pi.config - - headless_kivy_pi.config.setup_headless_kivy({'automatic_fps': True}) - - from kivy.clock import Clock - - from ubo_app.load_services import load_services - from ubo_app.menu import MenuApp - - load_services() - app = MenuApp() - - try: - app.run() - finally: - from ubo_app.store import dispatch - - dispatch(FinishAction()) - - # Needed since redux is scheduled using Clock scheduler and Clock doesn't run - # after app is stopped. - Clock.tick() - - -if __name__ == '__main__': - main() diff --git a/ubo_app/constants.py b/ubo_app/constants.py index a886e940..a9e47e8f 100644 --- a/ubo_app/constants.py +++ b/ubo_app/constants.py @@ -7,7 +7,6 @@ import dotenv -dotenv.load_dotenv(Path(__file__).parent / '.dev.env') dotenv.load_dotenv(Path(__file__).parent / '.env') USERNAME = os.environ.get('UBO_USERNAME', 'ubo') diff --git a/ubo_app/load_services.py b/ubo_app/load_services.py index c7a897ea..0d27c809 100644 --- a/ubo_app/load_services.py +++ b/ubo_app/load_services.py @@ -61,6 +61,7 @@ def __init__(self: UboServiceLoopLoader, service: UboServiceThread) -> None: self.service = service def exec_module(self: UboServiceLoopLoader, module: ModuleType) -> None: + cast(Any, module).current_loop = lambda: self.service.loop cast(Any, module)._create_task = ( # noqa: SLF001 lambda task: self.service.loop.call_soon_threadsafe( self.service.loop.create_task, diff --git a/ubo_app/main.py b/ubo_app/main.py new file mode 100644 index 00000000..043cfcd7 --- /dev/null +++ b/ubo_app/main.py @@ -0,0 +1,115 @@ +# ruff: noqa: D100, D101, D102, D103, D104, D107 +import os +import sys +import threading +import traceback +from pathlib import Path +from types import TracebackType + +import dotenv +from redux import FinishAction + +dotenv.load_dotenv(Path(__file__).parent / '.dev.env') + + +def setup_logging() -> None: + from ubo_app.constants import GUI_LOG_LEVEL, LOG_LEVEL + + if LOG_LEVEL: + import logging + + import ubo_app.logging + + level = getattr( + ubo_app.logging, + LOG_LEVEL, + getattr(logging, LOG_LEVEL, logging.INFO), + ) + + ubo_app.logging.logger.setLevel(level) + ubo_app.logging.add_file_handler(ubo_app.logging.logger, level) + ubo_app.logging.add_stdout_handler(ubo_app.logging.logger, level) + if GUI_LOG_LEVEL: + import logging + + import ubo_gui.logger + + level = getattr( + ubo_gui.logger, + GUI_LOG_LEVEL, + getattr(logging, GUI_LOG_LEVEL, logging.INFO), + ) + + ubo_gui.logger.logger.setLevel(level) + ubo_gui.logger.add_file_handler(level) + ubo_gui.logger.add_stdout_handler(level) + + +def main() -> None: + """Instantiate the `MenuApp` and run it.""" + setup_logging() + + if len(sys.argv) > 1 and sys.argv[1] == 'bootstrap': + from ubo_app.system.bootstrap import bootstrap + + bootstrap( + with_docker='--with-docker' in sys.argv, + for_packer='--for-packer' in sys.argv, + ) + sys.exit(0) + + def global_exception_handler( + exception_type: type, + value: int, + tb: TracebackType, + ) -> None: + from ubo_app.logging import logger + + logger.error(f'Uncaught exception: {exception_type}: {value}') + logger.error(''.join(traceback.format_tb(tb))) + + # Set the global exception handler + sys.excepthook = global_exception_handler + + def thread_exception_handler(args: threading.ExceptHookArgs) -> None: + import traceback + + from ubo_app.logging import logger + + logger.error( + f"""Exception in thread {args.thread.name if args.thread else "-"}: { + args.exc_type} {args.exc_value}""", + ) + logger.error(''.join(traceback.format_tb(args.exc_traceback))) + + threading.excepthook = thread_exception_handler + + os.environ['KIVY_NO_CONFIG'] = '1' + os.environ['KIVY_NO_FILELOG'] = '1' + + import headless_kivy_pi.config + + headless_kivy_pi.config.setup_headless_kivy({'automatic_fps': True}) + + from kivy.clock import Clock + + from ubo_app.load_services import load_services + from ubo_app.menu import MenuApp + + load_services() + app = MenuApp() + + try: + app.run() + finally: + from ubo_app.store import dispatch + + dispatch(FinishAction()) + + # Needed since redux is scheduled using Clock scheduler and Clock doesn't run + # after app is stopped. + Clock.tick() + + +if __name__ == '__main__': + main() diff --git a/ubo_app/menu.py b/ubo_app/menu.py index 4c684c0b..b85b24e8 100644 --- a/ubo_app/menu.py +++ b/ubo_app/menu.py @@ -1,4 +1,5 @@ """Ubo menu application.""" + from __future__ import annotations from ubo_gui.app import UboApp diff --git a/ubo_app/menu_central.py b/ubo_app/menu_central.py index 374f2742..6ecc9d0c 100644 --- a/ubo_app/menu_central.py +++ b/ubo_app/menu_central.py @@ -9,7 +9,7 @@ from debouncer import DebounceOptions, debounce from kivy.clock import Clock, mainthread from kivy.lang.builder import Builder -from redux import AutorunOptions, EventSubscriptionOptions +from redux import EventSubscriptionOptions from ubo_gui.app import UboApp from ubo_gui.gauge import GaugeWidget from ubo_gui.menu import MenuWidget @@ -28,7 +28,6 @@ from .store import autorun, dispatch, subscribe_event if TYPE_CHECKING: - from kivy.uix.screenmanager import Screen from kivy.uix.widget import Widget from ubo_gui.menu.types import Item, Menu @@ -48,11 +47,7 @@ def __init__( self.volume_widget = VolumeWidget() self.ids.right_column.add_widget(self.volume_widget) - self.sync_output_volume = self._sync_output_volume - autorun( - lambda state: state.sound.playback_volume, - options=AutorunOptions(keep_ref=False), - )(self.sync_output_volume) + autorun(lambda state: state.sound.playback_volume)(self._sync_output_volume) def _sync_output_volume(self: HomePage, selector_result: float) -> None: self.volume_widget.value = selector_result * 100 @@ -93,14 +88,11 @@ def set_value(_: int) -> None: class MenuWidgetWithHomePage(MenuWidget): - @cached_property - def home_page(self: MenuWidgetWithHomePage) -> HomePage: - return HomePage(self.current_menu_items, name='Page 1 0') - - def get_current_screen(self: MenuWidgetWithHomePage) -> Screen | None: + def render_items(self: MenuWidgetWithHomePage, *_: object) -> None: if self.depth == 1: - return self.home_page - return super().get_current_screen() + self.current_screen = HomePage(self.current_menu_items, name='Page 1 0') + else: + super().render_items() def set_path(menu_widget: MenuWidget, _: list[tuple[Menu, int] | PageWidget]) -> None: @@ -114,6 +106,7 @@ def set_path(menu_widget: MenuWidget, _: list[tuple[Menu, int] | PageWidget]) -> class MenuAppCentral(UboApp): def __init__(self: MenuAppCentral, **kwargs: object) -> None: super().__init__(**kwargs) + self.menu_widget = MenuWidgetWithHomePage() _self = weakref.ref(self) @@ -121,7 +114,7 @@ def __init__(self: MenuAppCentral, **kwargs: object) -> None: @debounce(0.1, DebounceOptions(leading=True, trailing=True, time_window=0.1)) async def _(menu: Menu | None) -> None: self = _self() - if not self or not menu or not self: + if not self or not menu: return mainthread(self.menu_widget.set_root_menu)(menu) @@ -131,8 +124,6 @@ def handle_title_change(self: MenuAppCentral, _: MenuWidget, title: str) -> None @cached_property def central(self: MenuAppCentral) -> Widget | None: """Build the main menu and initiate it.""" - self.menu_widget = MenuWidgetWithHomePage() - self.root.title = self.menu_widget.title self.menu_widget.bind(title=self.handle_title_change) self.menu_widget.bind(current_screen=set_path) @@ -140,19 +131,13 @@ def central(self: MenuAppCentral) -> Widget | None: subscribe_event( KeypadKeyPressEvent, self.handle_key_press_event, - options=EventSubscriptionOptions( - immediate_run=True, - keep_ref=False, - ), + options=EventSubscriptionOptions(immediate_run=True), ) subscribe_event( NotificationsDisplayEvent, self.display_notification, - options=EventSubscriptionOptions( - immediate_run=True, - keep_ref=False, - ), + options=EventSubscriptionOptions(immediate_run=True), ) return self.menu_widget diff --git a/ubo_app/menu_footer.py b/ubo_app/menu_footer.py index ac3c626c..97e31af7 100644 --- a/ubo_app/menu_footer.py +++ b/ubo_app/menu_footer.py @@ -11,7 +11,6 @@ from kivy.uix.label import Label from kivy.uix.stencilview import StencilView from kivy.uix.widget import Widget -from redux import AutorunOptions from ubo_gui.app import UboApp from ubo_app.store import autorun @@ -46,10 +45,7 @@ def temperature_widget(self: MenuAppFooter) -> BoxLayout: or setattr(layout, 'width', temperature.width + dp(12)), ) - autorun( - lambda state: state.sensors.temperature.value, - options=AutorunOptions(keep_ref=False), - )( + autorun(lambda state: state.sensors.temperature.value)( self._set_temperature_value, ) @@ -93,10 +89,7 @@ def light_widget(self: MenuAppFooter) -> Label: ), ) - autorun( - lambda state: state.sensors.light.value, - options=AutorunOptions(keep_ref=False), - )(self._set_light_value) + autorun(lambda state: state.sensors.light.value)(self._set_light_value) return self.light @@ -220,17 +213,9 @@ def footer(self: MenuAppFooter) -> Widget | None: x=self.set_icons_layout_x, ) - autorun( - lambda state: state.status_icons.icons, - options=AutorunOptions(keep_ref=False), - )(self._render_icons) + autorun(lambda state: state.status_icons.icons)(self._render_icons) - autorun( - lambda state: state.main.path, - options=AutorunOptions(keep_ref=False), - )( - self._handle_depth_change, - ) + autorun(lambda state: state.main.path)(self._handle_depth_change) self.footer_layout.add_widget(self.home_footer_layout) diff --git a/ubo_app/services/010-ip/setup.py b/ubo_app/services/010-ip/setup.py index c63b8853..97bb5f9d 100644 --- a/ubo_app/services/010-ip/setup.py +++ b/ubo_app/services/010-ip/setup.py @@ -8,7 +8,7 @@ import psutil from constants import INTERNET_STATE_ICON_ID, INTERNET_STATE_ICON_PRIORITY -from ubo_gui.menu.types import ActionItem, HeadlessMenu, SubMenuItem +from ubo_gui.menu.types import HeadlessMenu, Item, SubMenuItem from ubo_app.store import autorun, dispatch, subscribe_event from ubo_app.store.main import RegisterSettingAppAction @@ -39,10 +39,9 @@ def get_ip_addresses(interfaces: Sequence[IpNetworkInterface]) -> list[SubMenuIt sub_menu=HeadlessMenu( title=f'IP Addresses - {interface.name}', items=[ - ActionItem( + Item( label=ip_address, icon='lan', - action=lambda: None, ) for ip_address in interface.ip_addresses ], diff --git a/ubo_app/services/010-wifi/pages/main.py b/ubo_app/services/010-wifi/pages/main.py index f00f645f..a968fd7c 100644 --- a/ubo_app/services/010-wifi/pages/main.py +++ b/ubo_app/services/010-wifi/pages/main.py @@ -133,7 +133,7 @@ def __init__(self: WiFiNetworkPageWithSSID, **kwargs: object) -> None: for connection in wifi_state.connections ] if wifi_state.connections is not None - else [ActionItem(label='Loading...', action=lambda: None)] + else [Item(label='Loading...')] ) diff --git a/ubo_app/services/080-docker/image.py b/ubo_app/services/080-docker/image.py index 5fcd99d1..9c3c3df0 100644 --- a/ubo_app/services/080-docker/image.py +++ b/ubo_app/services/080-docker/image.py @@ -1,9 +1,10 @@ """Docker image menu.""" + from __future__ import annotations import contextlib import pathlib -from typing import TYPE_CHECKING, Callable +from typing import Callable import docker import docker.errors @@ -24,10 +25,7 @@ ImageState, ImageStatus, ) -from ubo_app.utils.async_ import run_in_executor - -if TYPE_CHECKING: - from asyncio import Future +from ubo_app.utils.async_ import create_task, run_in_executor def find_container(client: docker.DockerClient, *, image: str) -> Container | None: @@ -141,11 +139,11 @@ def _monitor_events( # noqa: C901 ) -def check_container(image_id: str) -> Future[None]: +def check_container(image_id: str) -> None: """Check the container status.""" path = IMAGES[image_id].path - def act() -> None: + async def act() -> None: logger.debug('Checking image', extra={'image': image_id, 'path': path}) docker_client = docker.from_env() try: @@ -210,7 +208,7 @@ def get_docker_id(docker_id: str) -> str: _monitor_events(image_id, get_docker_id, docker_client) docker_client.close() - return run_in_executor(None, act) + create_task(act()) def _fetch_image(image: ImageState) -> None: diff --git a/ubo_app/services/080-docker/setup.py b/ubo_app/services/080-docker/setup.py index c01295fb..deeb2f84 100644 --- a/ubo_app/services/080-docker/setup.py +++ b/ubo_app/services/080-docker/setup.py @@ -1,4 +1,5 @@ """Setup the service.""" + from __future__ import annotations import asyncio diff --git a/ubo_app/side_effects.py b/ubo_app/side_effects.py index 44c91cc5..7d46ae6e 100644 --- a/ubo_app/side_effects.py +++ b/ubo_app/side_effects.py @@ -1,4 +1,5 @@ """Application logic.""" + from __future__ import annotations import atexit @@ -8,7 +9,7 @@ from debouncer import DebounceOptions, debounce from kivy.clock import mainthread -from redux import AutorunOptions, EventSubscriptionOptions, FinishAction, FinishEvent +from redux import AutorunOptions, FinishAction, FinishEvent from ubo_app.store import autorun, dispatch, subscribe_event from ubo_app.store.main import PowerOffEvent @@ -46,11 +47,7 @@ def setup(app: MenuApp) -> None: turn_on_screen() initialize_board() - subscribe_event( - PowerOffEvent, - power_off, - options=EventSubscriptionOptions(keep_ref=False), - ) + subscribe_event(PowerOffEvent, power_off) app_ref = weakref.ref(app) @@ -60,21 +57,9 @@ def stop_app() -> None: if app is not None: app.stop() - subscribe_event( - FinishEvent, - stop_app, - options=EventSubscriptionOptions(keep_ref=True), - ) - subscribe_event( - UpdateManagerUpdateEvent, - update, - options=EventSubscriptionOptions(keep_ref=False), - ) - subscribe_event( - UpdateManagerCheckEvent, - check_version, - options=EventSubscriptionOptions(keep_ref=False), - ) + subscribe_event(FinishEvent, stop_app) + subscribe_event(UpdateManagerUpdateEvent, update) + subscribe_event(UpdateManagerCheckEvent, check_version) @debounce( wait=10, diff --git a/ubo_app/store/__init__.py b/ubo_app/store/__init__.py index 97918fb6..a04c5104 100644 --- a/ubo_app/store/__init__.py +++ b/ubo_app/store/__init__.py @@ -1,9 +1,11 @@ # ruff: noqa: D100, D101, D102, D103, D104, D107 from __future__ import annotations -from typing import Callable +import sys +from dataclasses import replace +from pathlib import Path +from typing import TYPE_CHECKING, Callable, Coroutine -from kivy.clock import Clock from redux import ( BaseCombineReducerState, CombineReducerAction, @@ -29,10 +31,14 @@ from ubo_app.store.status_icons.reducer import reducer as status_icons_reducer from ubo_app.store.update_manager import UpdateManagerAction, UpdateManagerState from ubo_app.store.update_manager.reducer import reducer as update_manager_reducer -from ubo_app.utils.async_ import create_task + +if TYPE_CHECKING: + from redux.basic_types import SnapshotAtom, TaskCreatorCallback def scheduler(callback: Callable[[], None], *, interval: bool) -> None: + from kivy.clock import Clock + Clock.create_trigger(lambda _: callback(), 0, interval=interval)() @@ -73,7 +79,34 @@ class RootState(BaseCombineReducerState): ) -store = Store( +class UboStore(Store): + @classmethod + def serialize_value(cls: type[UboStore], obj: object | type) -> SnapshotAtom: + from ubo_gui.menu.types import ActionItem + from ubo_gui.page import PageWidget + + if isinstance(obj, type) and issubclass(obj, PageWidget): + file_path = sys.modules[obj.__module__].__file__ + if file_path: + return f"""{Path(file_path).relative_to(Path().absolute()).as_posix()}:{ + obj.__name__}""" + return f'{obj.__module__}:{obj.__name__}' + if isinstance(obj, ActionItem): + obj = replace(obj, action='') + return super().serialize_value(obj) + + +def create_task( + coro: Coroutine, + *, + callback: TaskCreatorCallback | None = None, +) -> None: + from ubo_app.utils.async_ import create_task + + create_task(coro, callback) + + +store = UboStore( root_reducer, CreateStoreOptions( auto_init=False, diff --git a/ubo_app/store/main/__init__.py b/ubo_app/store/main/__init__.py index 5be8755f..f997a918 100644 --- a/ubo_app/store/main/__init__.py +++ b/ubo_app/store/main/__init__.py @@ -15,28 +15,23 @@ from ubo_gui.menu.types import Item, Menu -class InitEvent(BaseEvent): - ... +class InitEvent(BaseEvent): ... class RegisterAppAction(BaseAction): menu_item: Item -class RegisterRegularAppAction(RegisterAppAction): - ... +class RegisterRegularAppAction(RegisterAppAction): ... -class RegisterSettingAppAction(RegisterAppAction): - ... +class RegisterSettingAppAction(RegisterAppAction): ... -class PowerOffAction(BaseAction): - ... +class PowerOffAction(BaseAction): ... -class PowerOffEvent(BaseEvent): - ... +class PowerOffEvent(BaseEvent): ... class MainState(Immutable): diff --git a/ubo_app/store/main/reducer.py b/ubo_app/store/main/reducer.py index 0919b1a1..82c0dfac 100644 --- a/ubo_app/store/main/reducer.py +++ b/ubo_app/store/main/reducer.py @@ -99,10 +99,13 @@ def reducer( # noqa: C901 msg = f'{menu_title} menu item is not a `SubMenuItem`' raise TypeError(msg) - new_items = [ - *cast(Sequence[Item], cast(Menu, desired_menu_item.sub_menu).items), - action.menu_item, - ] + new_items = sorted( + [ + *cast(Sequence[Item], cast(Menu, desired_menu_item.sub_menu).items), + action.menu_item, + ], + key=lambda item: item.label() if callable(item.label) else item.label, + ) desired_menu_item = replace( desired_menu_item, sub_menu=replace( diff --git a/ubo_app/store/services/docker.py b/ubo_app/store/services/docker.py index 891891b0..6781034e 100644 --- a/ubo_app/store/services/docker.py +++ b/ubo_app/store/services/docker.py @@ -2,13 +2,13 @@ from __future__ import annotations from dataclasses import field -from enum import Enum, auto +from enum import StrEnum, auto from immutable import Immutable from redux import BaseAction, BaseCombineReducerState, BaseEvent -class DockerStatus(Enum): +class DockerStatus(StrEnum): """Docker status.""" UNKNOWN = auto() @@ -19,7 +19,7 @@ class DockerStatus(Enum): ERROR = auto() -class ImageStatus(Enum): +class ImageStatus(StrEnum): """Image status.""" NOT_AVAILABLE = auto() diff --git a/ubo_app/store/services/sensors.py b/ubo_app/store/services/sensors.py index 435cd7e6..51eb2ecf 100644 --- a/ubo_app/store/services/sensors.py +++ b/ubo_app/store/services/sensors.py @@ -1,7 +1,7 @@ # ruff: noqa: D100, D101, D102, D103, D104, D107 from __future__ import annotations -from enum import Enum, auto +from enum import StrEnum, auto from typing import TYPE_CHECKING, Generic, Sequence, TypeVar from immutable import Immutable @@ -19,7 +19,7 @@ class SensorsAction(BaseAction): SensorType = TypeVar('SensorType', bound=Primitive | Sequence[Primitive]) -class Sensor(Enum): +class Sensor(StrEnum): TEMPERATURE = auto() LIGHT = auto() diff --git a/ubo_app/store/update_manager/__init__.py b/ubo_app/store/update_manager/__init__.py index 367b440f..0b8af7b4 100644 --- a/ubo_app/store/update_manager/__init__.py +++ b/ubo_app/store/update_manager/__init__.py @@ -1,7 +1,7 @@ # ruff: noqa: D100, D101, D102, D103, D104, D107 from __future__ import annotations -from enum import Enum, auto +from enum import StrEnum, auto from immutable import Immutable from redux import BaseAction, BaseEvent @@ -35,7 +35,7 @@ class UpdateManagerUpdateEvent(UpdateManagerEvent): ... -class UpdateStatus(Enum): +class UpdateStatus(StrEnum): """Update status enum.""" CHECKING = auto() diff --git a/ubo_app/store/update_manager/utils.py b/ubo_app/store/update_manager/utils.py index ebf63d89..bcf725cd 100644 --- a/ubo_app/store/update_manager/utils.py +++ b/ubo_app/store/update_manager/utils.py @@ -1,4 +1,5 @@ """Update manager module.""" + from __future__ import annotations import asyncio @@ -7,6 +8,7 @@ from pathlib import Path import aiohttp +import requests from ubo_gui.constants import DANGER_COLOR, SUCCESS_COLOR from ubo_gui.menu.types import ActionItem, Item @@ -35,8 +37,6 @@ async def check_version() -> None: logger.info('Checking for updates...') # Check PyPI server for the latest version - import requests - try: async with aiohttp.ClientSession() as session, session.get( 'https://pypi.org/pypi/ubo-app/json', @@ -123,9 +123,8 @@ def about_menu_items(state: UpdateManagerState) -> list[Item]: """Get the update menu items.""" if state.update_status is UpdateStatus.CHECKING: return [ - ActionItem( + Item( label='Checking for updates...', - action=lambda: None, icon='update', background_color='#00000000', ), @@ -143,9 +142,8 @@ def about_menu_items(state: UpdateManagerState) -> list[Item]: ] if state.update_status is UpdateStatus.UP_TO_DATE: return [ - ActionItem( + Item( label='Already up to date!', - action=lambda: None, icon='security_update_good', background_color=SUCCESS_COLOR, color='#000000', @@ -163,9 +161,8 @@ def about_menu_items(state: UpdateManagerState) -> list[Item]: ] if state.update_status is UpdateStatus.UPDATING: return [ - ActionItem( + Item( label='Updating...', - action=lambda: None, icon='update', background_color='#00000000', ), diff --git a/ubo_app/utils/async_.py b/ubo_app/utils/async_.py index 400da2ff..340e566b 100644 --- a/ubo_app/utils/async_.py +++ b/ubo_app/utils/async_.py @@ -12,11 +12,16 @@ if TYPE_CHECKING: from asyncio import Future, Handle + from redux.basic_types import TaskCreatorCallback + background_tasks: set[Handle] = set() -def create_task(awaitable: Awaitable) -> Handle: +def create_task( + awaitable: Awaitable, + callback: TaskCreatorCallback | None = None, +) -> Handle: async def wrapper() -> None: if awaitable is None: return @@ -38,7 +43,7 @@ async def wrapper() -> None: import ubo_app.utils.loop - handle = ubo_app.utils.loop._create_task(wrapper()) # noqa: SLF001 + handle = ubo_app.utils.loop._create_task(wrapper(), callback) # noqa: SLF001 background_tasks.add(handle) return handle diff --git a/ubo_app/utils/fake.py b/ubo_app/utils/fake.py index 5d39c77f..62c5b4ce 100644 --- a/ubo_app/utils/fake.py +++ b/ubo_app/utils/fake.py @@ -2,7 +2,7 @@ from __future__ import annotations from types import ModuleType -from typing import Any, Generator, Iterator, cast +from typing import Any, Generator, Iterator, Self, cast from ubo_app.logging import logger @@ -16,13 +16,13 @@ def __init__(self: Fake, *args: object, **kwargs: object) -> None: def __init_subclass__(cls: type[Fake], **kwargs: dict[str, Any]) -> None: logger.verbose('Subclassing `Fake`', extra={'cls': cls, 'kwargs': kwargs}) - def __getattr__(self: Fake, attr: str) -> Fake | str: + def __getattr__(self: Fake, attr: str) -> Fake: logger.verbose( 'Accessing fake attribute of a `Fake` insta', extra={'attr': attr}, ) if attr == '__file__': - return 'fake' + return cast(Fake, 'fake') return self def __getitem__(self: Fake, key: object) -> Fake: @@ -67,6 +67,12 @@ def __enter__(self: Fake) -> Fake: # noqa: PYI034 def __exit__(self: Fake, *_: object) -> None: pass + async def __aenter__(self: Self) -> Self: + return self + + async def __aexit__(self: Fake, *_: object) -> None: + pass + def __mro_entries__(self: Fake, bases: tuple[type[Fake]]) -> tuple[type[Fake]]: logger.verbose( 'Getting MRO entries of a `Fake` instance', diff --git a/ubo_app/utils/loop.py b/ubo_app/utils/loop.py index 2c2b7270..72e472a3 100644 --- a/ubo_app/utils/loop.py +++ b/ubo_app/utils/loop.py @@ -2,17 +2,20 @@ from __future__ import annotations import asyncio +import contextlib import threading from typing import TYPE_CHECKING, Callable, Coroutine, TypeVarTuple, Unpack -from redux import FinishEvent +from redux.basic_types import FinishEvent from typing_extensions import TypeVar from ubo_app.constants import DEBUG_MODE -from ubo_app.store import subscribe_event if TYPE_CHECKING: from asyncio import Future, Handle + from asyncio.tasks import Task + + from redux.basic_types import TaskCreatorCallback T = TypeVar('T', infer_variance=True) @@ -29,11 +32,22 @@ def __init__(self: WorkerThread) -> None: def run(self: WorkerThread) -> None: asyncio.set_event_loop(self.loop) + from ubo_app.store import subscribe_event + subscribe_event(FinishEvent, self.stop) self.loop.run_forever() - def run_task(self: WorkerThread, task: Coroutine) -> Handle: - return self.loop.call_soon_threadsafe(self.loop.create_task, task) + def run_task( + self: WorkerThread, + task: Coroutine, + callback: Callable[[Task], None] | None = None, + ) -> Handle: + def task_wrapper() -> None: + result = self.loop.create_task(task) + if callback: + callback(result) + + return self.loop.call_soon_threadsafe(task_wrapper) def run_in_executor( self: WorkerThread, @@ -43,16 +57,33 @@ def run_in_executor( ) -> Future[T]: return self.loop.run_in_executor(executor, task, *args) + async def shutdown(self: WorkerThread) -> None: + while True: + tasks = [ + t + for t in asyncio.all_tasks(self.loop) + if t is not asyncio.current_task(self.loop) + ] + if not tasks: + break + for task in tasks: + with contextlib.suppress(asyncio.CancelledError, asyncio.TimeoutError): + await asyncio.wait_for(task, 0.1) + self.loop.stop() + def stop(self: WorkerThread) -> None: - self.loop.call_soon_threadsafe(self.loop.stop) + self.loop.call_soon_threadsafe(lambda: self.loop.create_task(self.shutdown())) thread = WorkerThread() thread.start() -def _create_task(task: Coroutine) -> Handle: - return thread.run_task(task) +def _create_task( + task: Coroutine, + callback: TaskCreatorCallback | None = None, +) -> Handle: + return thread.run_task(task, callback) def _run_in_executor(