From ee8798b9315f0e27c2d841b0aa884dc63c007462 Mon Sep 17 00:00:00 2001 From: Sassan Haradji Date: Fri, 15 Mar 2024 02:42:48 +0400 Subject: [PATCH] chore(test): set up testing framework with initial examples chore(test): set up snapshot test helper to compare screenshots of different stages of tests with previous successful tests using hashes --- .github/workflows/integration_delivery.yml | 58 ++++ .gitignore | 1 + CHANGELOG.md | 6 + poetry.lock | 270 +++++++++++++++--- pyproject.toml | 29 +- tests/conftest.py | 173 +++++++++++ .../test_all_services_register-000.hash | 1 + tests/snapshot.py | 91 ++++++ tests/test_general_health.py | 27 ++ tests/test_services.py | 60 ++++ tests/utils/import_tracker.py | 62 ++++ ubo_app/__init__.py | 21 +- ubo_app/load_services.py | 14 +- ubo_app/menu_central.py | 85 +++--- ubo_app/menu_footer.py | 188 +++++++----- ubo_app/services/000-sound/setup.py | 6 +- ubo_app/services/010-ethernet/ubo_handle.py | 4 +- ubo_app/services/040-camera/setup.py | 6 +- ubo_app/side_effects.py | 58 +++- ubo_app/store/__init__.py | 8 +- ubo_app/utils/garbage_collection.py | 93 ++++++ 21 files changed, 1061 insertions(+), 200 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/results/test_all_services_register-000.hash create mode 100644 tests/snapshot.py create mode 100644 tests/test_general_health.py create mode 100644 tests/test_services.py create mode 100644 tests/utils/import_tracker.py create mode 100644 ubo_app/utils/garbage_collection.py diff --git a/.github/workflows/integration_delivery.yml b/.github/workflows/integration_delivery.yml index a24683bb..fcfce35d 100644 --- a/.github/workflows/integration_delivery.yml +++ b/.github/workflows/integration_delivery.yml @@ -96,6 +96,59 @@ jobs: - name: Lint run: poetry run poe lint + test: + name: Test + needs: + - dependencies + runs-on: ubuntu-latest + steps: + - name: System Dependencies + run: sudo apt-get install -y libegl1 libgl1 libmtdev1 libzbar0 + + - uses: actions/checkout@v4 + name: Checkout + + - uses: actions/setup-python@v5 + name: Setup Python + with: + python-version: ${{ env.PYTHON_VERSION }} + architecture: x64 + + - name: Load Cached Poetry + id: cached-poetry + uses: actions/cache/restore@v4 + with: + path: | + ~/.cache + ~/.local + key: poetry-${{ hashFiles('poetry.lock') }} + + - name: Test + run: poetry run poe test --cov-report=xml --cov-report=html + + - name: Collect Mismatching Screenshots + if: failure() + uses: actions/upload-artifact@v4 + with: + name: mismatching-screenshots + path: tests/**/*.mismatch.png + + - 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 + with: + file: ./coverage.xml + flags: integration + fail_ci_if_error: true + token: ${{ secrets.CODECOV_TOKEN }} + build: name: Build needs: @@ -155,6 +208,7 @@ jobs: needs: - type-check - lint + - test - build runs-on: ubuntu-latest environment: @@ -183,6 +237,9 @@ jobs: images: name: Create Images needs: + - type-check + - lint + - test - build runs-on: ubuntu-latest container: @@ -271,6 +328,7 @@ jobs: needs: - type-check - lint + - test - build - pypi-publish - images diff --git a/.gitignore b/.gitignore index b9a7ed98..0116701d 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,7 @@ coverage.xml .hypothesis/ .pytest_cache/ cover/ +tests/**/results/*png # Translations *.mo diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e91e327..98918cfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Version 0.11.1 + +- chore(test): set up testing framework with initial examples +- chore(test): set up snapshot test helper to compare screenshots of different stages + of tests with previous successful tests using hashes + ## Version 0.11.0 - feat: add ollama and open-webui docker images diff --git a/poetry.lock b/poetry.lock index f61cd3ac..36c36446 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2,13 +2,13 @@ [[package]] name = "adafruit-blinka" -version = "8.34.0" +version = "8.35.0" description = "CircuitPython APIs for non-CircuitPython versions of Python such as CPython on Linux and MicroPython." optional = false python-versions = ">=3.7.0" files = [ - {file = "Adafruit-Blinka-8.34.0.tar.gz", hash = "sha256:6629bc67d7a025d4c6b6e9d9851c12a86c8cedd4a8ac27f67fd63576010e9dee"}, - {file = "Adafruit_Blinka-8.34.0-py3-none-any.whl", hash = "sha256:153d4bd49abd8341888f96ccbdcc3855e742c215bb3955d08862d066c0a5069e"}, + {file = "Adafruit-Blinka-8.35.0.tar.gz", hash = "sha256:382d8a01a31fc427096bd0f3759e59560990bf8fc43f4e3b2ec5fb841427d718"}, + {file = "Adafruit_Blinka-8.35.0-py3-none-any.whl", hash = "sha256:67c606a50e28b4c9464cc06eb5cea3457f166ba7968ebaa06a286bf27df51a9a"}, ] [package.dependencies] @@ -126,13 +126,13 @@ typing-extensions = ">=4.0,<5.0" [[package]] name = "adafruit-circuitpython-requests" -version = "3.0.1" +version = "3.2.0" description = "A requests-like library for web interfacing" optional = false python-versions = "*" files = [ - {file = "adafruit-circuitpython-requests-3.0.1.tar.gz", hash = "sha256:0998cbc81429754f348a67ff7245655be4da2646e682215e4d80812feaa1f1d5"}, - {file = "adafruit_circuitpython_requests-3.0.1-py3-none-any.whl", hash = "sha256:9403b7877b473aa9fe59b1e5785fa8253cc5b95de98c1e3f913add3d65dda6bb"}, + {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"}, ] [package.dependencies] @@ -156,13 +156,13 @@ adafruit-circuitpython-busdevice = "*" [[package]] name = "adafruit-circuitpython-typing" -version = "1.10.2" +version = "1.10.3" description = "Types needed for type annotation that are not in `typing`" optional = false -python-versions = "*" +python-versions = ">=3.8" files = [ - {file = "adafruit-circuitpython-typing-1.10.2.tar.gz", hash = "sha256:ecfbaae7ac0f41b202aa3ed98cbd0f696b17e83b7e7f3fac6dc6a228cbc1c6c6"}, - {file = "adafruit_circuitpython_typing-1.10.2-py3-none-any.whl", hash = "sha256:e64bbf2f7d4a4bdf0f9be8e039cfe2ed199aa7d51aa44a090d50be4f0917d24f"}, + {file = "adafruit-circuitpython-typing-1.10.3.tar.gz", hash = "sha256:4c5c1eeb3ae8a3d2dfa75129654c73690fbdb576d9069542a6ea3355467e1e74"}, + {file = "adafruit_circuitpython_typing-1.10.3-py3-none-any.whl", hash = "sha256:4e23d6828deafa4dfd554fd7d3f06b8ff8e2cc554b9c7aecba9d778d45970c18"}, ] [package.dependencies] @@ -451,13 +451,77 @@ files = [ name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." -optional = true +optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "coverage" +version = "7.4.4" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "coverage-7.4.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0be5efd5127542ef31f165de269f77560d6cdef525fffa446de6f7e9186cfb2"}, + {file = "coverage-7.4.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ccd341521be3d1b3daeb41960ae94a5e87abe2f46f17224ba5d6f2b8398016cf"}, + {file = "coverage-7.4.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09fa497a8ab37784fbb20ab699c246053ac294d13fc7eb40ec007a5043ec91f8"}, + {file = "coverage-7.4.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b1a93009cb80730c9bca5d6d4665494b725b6e8e157c1cb7f2db5b4b122ea562"}, + {file = "coverage-7.4.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:690db6517f09336559dc0b5f55342df62370a48f5469fabf502db2c6d1cffcd2"}, + {file = "coverage-7.4.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:09c3255458533cb76ef55da8cc49ffab9e33f083739c8bd4f58e79fecfe288f7"}, + {file = "coverage-7.4.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:8ce1415194b4a6bd0cdcc3a1dfbf58b63f910dcb7330fe15bdff542c56949f87"}, + {file = "coverage-7.4.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b91cbc4b195444e7e258ba27ac33769c41b94967919f10037e6355e998af255c"}, + {file = "coverage-7.4.4-cp310-cp310-win32.whl", hash = "sha256:598825b51b81c808cb6f078dcb972f96af96b078faa47af7dfcdf282835baa8d"}, + {file = "coverage-7.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:09ef9199ed6653989ebbcaacc9b62b514bb63ea2f90256e71fea3ed74bd8ff6f"}, + {file = "coverage-7.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0f9f50e7ef2a71e2fae92774c99170eb8304e3fdf9c8c3c7ae9bab3e7229c5cf"}, + {file = "coverage-7.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:623512f8ba53c422fcfb2ce68362c97945095b864cda94a92edbaf5994201083"}, + {file = "coverage-7.4.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0513b9508b93da4e1716744ef6ebc507aff016ba115ffe8ecff744d1322a7b63"}, + {file = "coverage-7.4.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40209e141059b9370a2657c9b15607815359ab3ef9918f0196b6fccce8d3230f"}, + {file = "coverage-7.4.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a2b2b78c78293782fd3767d53e6474582f62443d0504b1554370bde86cc8227"}, + {file = "coverage-7.4.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:73bfb9c09951125d06ee473bed216e2c3742f530fc5acc1383883125de76d9cd"}, + {file = "coverage-7.4.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:1f384c3cc76aeedce208643697fb3e8437604b512255de6d18dae3f27655a384"}, + {file = "coverage-7.4.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:54eb8d1bf7cacfbf2a3186019bcf01d11c666bd495ed18717162f7eb1e9dd00b"}, + {file = "coverage-7.4.4-cp311-cp311-win32.whl", hash = "sha256:cac99918c7bba15302a2d81f0312c08054a3359eaa1929c7e4b26ebe41e9b286"}, + {file = "coverage-7.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:b14706df8b2de49869ae03a5ccbc211f4041750cd4a66f698df89d44f4bd30ec"}, + {file = "coverage-7.4.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:201bef2eea65e0e9c56343115ba3814e896afe6d36ffd37bab783261db430f76"}, + {file = "coverage-7.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41c9c5f3de16b903b610d09650e5e27adbfa7f500302718c9ffd1c12cf9d6818"}, + {file = "coverage-7.4.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d898fe162d26929b5960e4e138651f7427048e72c853607f2b200909794ed978"}, + {file = "coverage-7.4.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ea79bb50e805cd6ac058dfa3b5c8f6c040cb87fe83de10845857f5535d1db70"}, + {file = "coverage-7.4.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce4b94265ca988c3f8e479e741693d143026632672e3ff924f25fab50518dd51"}, + {file = "coverage-7.4.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:00838a35b882694afda09f85e469c96367daa3f3f2b097d846a7216993d37f4c"}, + {file = "coverage-7.4.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:fdfafb32984684eb03c2d83e1e51f64f0906b11e64482df3c5db936ce3839d48"}, + {file = "coverage-7.4.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:69eb372f7e2ece89f14751fbcbe470295d73ed41ecd37ca36ed2eb47512a6ab9"}, + {file = "coverage-7.4.4-cp312-cp312-win32.whl", hash = "sha256:137eb07173141545e07403cca94ab625cc1cc6bc4c1e97b6e3846270e7e1fea0"}, + {file = "coverage-7.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:d71eec7d83298f1af3326ce0ff1d0ea83c7cb98f72b577097f9083b20bdaf05e"}, + {file = "coverage-7.4.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d5ae728ff3b5401cc320d792866987e7e7e880e6ebd24433b70a33b643bb0384"}, + {file = "coverage-7.4.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cc4f1358cb0c78edef3ed237ef2c86056206bb8d9140e73b6b89fbcfcbdd40e1"}, + {file = "coverage-7.4.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8130a2aa2acb8788e0b56938786c33c7c98562697bf9f4c7d6e8e5e3a0501e4a"}, + {file = "coverage-7.4.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf271892d13e43bc2b51e6908ec9a6a5094a4df1d8af0bfc360088ee6c684409"}, + {file = "coverage-7.4.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4cdc86d54b5da0df6d3d3a2f0b710949286094c3a6700c21e9015932b81447e"}, + {file = "coverage-7.4.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ae71e7ddb7a413dd60052e90528f2f65270aad4b509563af6d03d53e979feafd"}, + {file = "coverage-7.4.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:38dd60d7bf242c4ed5b38e094baf6401faa114fc09e9e6632374388a404f98e7"}, + {file = "coverage-7.4.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:aa5b1c1bfc28384f1f53b69a023d789f72b2e0ab1b3787aae16992a7ca21056c"}, + {file = "coverage-7.4.4-cp38-cp38-win32.whl", hash = "sha256:dfa8fe35a0bb90382837b238fff375de15f0dcdb9ae68ff85f7a63649c98527e"}, + {file = "coverage-7.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:b2991665420a803495e0b90a79233c1433d6ed77ef282e8e152a324bbbc5e0c8"}, + {file = "coverage-7.4.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3b799445b9f7ee8bf299cfaed6f5b226c0037b74886a4e11515e569b36fe310d"}, + {file = "coverage-7.4.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b4d33f418f46362995f1e9d4f3a35a1b6322cb959c31d88ae56b0298e1c22357"}, + {file = "coverage-7.4.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aadacf9a2f407a4688d700e4ebab33a7e2e408f2ca04dbf4aef17585389eff3e"}, + {file = "coverage-7.4.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7c95949560050d04d46b919301826525597f07b33beba6187d04fa64d47ac82e"}, + {file = "coverage-7.4.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff7687ca3d7028d8a5f0ebae95a6e4827c5616b31a4ee1192bdfde697db110d4"}, + {file = "coverage-7.4.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5fc1de20b2d4a061b3df27ab9b7c7111e9a710f10dc2b84d33a4ab25065994ec"}, + {file = "coverage-7.4.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c74880fc64d4958159fbd537a091d2a585448a8f8508bf248d72112723974cbd"}, + {file = "coverage-7.4.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:742a76a12aa45b44d236815d282b03cfb1de3b4323f3e4ec933acfae08e54ade"}, + {file = "coverage-7.4.4-cp39-cp39-win32.whl", hash = "sha256:d89d7b2974cae412400e88f35d86af72208e1ede1a541954af5d944a8ba46c57"}, + {file = "coverage-7.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:9ca28a302acb19b6af89e90f33ee3e1906961f94b54ea37de6737b7ca9d8827c"}, + {file = "coverage-7.4.4-pp38.pp39.pp310-none-any.whl", hash = "sha256:b2c5edc4ac10a7ef6605a966c58929ec6c1bd0917fb8c15cb3363f65aa40e677"}, + {file = "coverage-7.4.4.tar.gz", hash = "sha256:c901df83d097649e257e803be22592aedfd5182f07b3cc87d640bbb9afd50f49"}, +] + +[package.extras] +toml = ["tomli"] + [[package]] name = "cython" version = "3.0.9" @@ -557,6 +621,20 @@ files = [ {file = "docutils-0.20.1.tar.gz", hash = "sha256:f08a4e276c3a1583a86dce3e34aba3fe04d02bba2dd51ed16106244e8a923e3b"}, ] +[[package]] +name = "execnet" +version = "2.0.2" +description = "execnet: rapid multi-Python deployment" +optional = false +python-versions = ">=3.7" +files = [ + {file = "execnet-2.0.2-py3-none-any.whl", hash = "sha256:88256416ae766bc9e8895c76a87928c0012183da3cc4fc18016e6f050e025f41"}, + {file = "execnet-2.0.2.tar.gz", hash = "sha256:cc59bc4423742fd71ad227122eb0dd44db51efb3dc4095b45ac9a08c770096af"}, +] + +[package.extras] +testing = ["hatch", "pre-commit", "pytest", "tox"] + [[package]] name = "frozenlist" version = "1.4.1" @@ -645,13 +723,13 @@ files = [ [[package]] name = "headless-kivy-pi" -version = "0.6.1" +version = "0.7.1" description = "Headless renderer for Kivy framework on Raspberry Pi" optional = true python-versions = ">=3.11,<4.0" files = [ - {file = "headless_kivy_pi-0.6.1-py3-none-any.whl", hash = "sha256:8aa23814c1829ebe83153227d4587fbc203122f0b863f34a750d47579de09502"}, - {file = "headless_kivy_pi-0.6.1.tar.gz", hash = "sha256:839ebf9545e2bb642057ac3e73fa5534b8e5a9ee6a69f00fba8d56a64fd7550a"}, + {file = "headless_kivy_pi-0.7.1-py3-none-any.whl", hash = "sha256:89e3d90f20ce01139c79b673b666da8f48a6c36f47b414a7416300082a5feedc"}, + {file = "headless_kivy_pi-0.7.1.tar.gz", hash = "sha256:fa564536100225498b921847b180ddc92aefdccfc38d211cfd72ee513efccdb4"}, ] [package.dependencies] @@ -677,6 +755,17 @@ files = [ {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"}, ] +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + [[package]] name = "kivy" version = "2.3.0" @@ -984,31 +1073,46 @@ files = [ [[package]] name = "packaging" -version = "23.2" +version = "24.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.7" files = [ - {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, - {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, + {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"}, + {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"}, ] [[package]] name = "pastel" version = "0.2.1" description = "Bring colors to your terminal." -optional = true +optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ {file = "pastel-0.2.1-py2.py3-none-any.whl", hash = "sha256:4349225fcdf6c2bb34d483e523475de5bb04a5c10ef711263452cb37d7dd4364"}, {file = "pastel-0.2.1.tar.gz", hash = "sha256:e6581ac04e973cac858828c6202c1e1e81fee1dc7de7683f3e1ffe0bfd8a573d"}, ] +[[package]] +name = "pluggy" +version = "1.4.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pluggy-1.4.0-py3-none-any.whl", hash = "sha256:7db9f7b503d67d1c5b95f59773ebb58a8c1c288129a88665838012cfb07b8981"}, + {file = "pluggy-1.4.0.tar.gz", hash = "sha256:8c85c2876142a764e5b7548e7d9a0e0ddb46f5185161049a79b7e974454223be"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + [[package]] name = "poethepoet" version = "0.24.4" description = "A task runner that works well with poetry." -optional = true +optional = false python-versions = ">=3.8" files = [ {file = "poethepoet-0.24.4-py3-none-any.whl", hash = "sha256:fb4ea35d7f40fe2081ea917d2e4102e2310fda2cde78974050ca83896e229075"}, @@ -1153,13 +1257,13 @@ files = [ [[package]] name = "pyright" -version = "1.1.353" +version = "1.1.354" description = "Command line wrapper for pyright" optional = false python-versions = ">=3.7" files = [ - {file = "pyright-1.1.353-py3-none-any.whl", hash = "sha256:8d7e6719d0be4fd9f4a37f010237c6a74d91ec1e7c81de634c2f3f9965f8ab43"}, - {file = "pyright-1.1.353.tar.gz", hash = "sha256:24343bbc2a4f997563f966b6244a2e863473f1d85af6d24abcb366fcbb4abca9"}, + {file = "pyright-1.1.354-py3-none-any.whl", hash = "sha256:f28d61ae8ae035fc52ded1070e8d9e786051a26a4127bbd7a4ba0399b81b37b5"}, + {file = "pyright-1.1.354.tar.gz", hash = "sha256:b1070dc774ff2e79eb0523fe87f4ba9a90550de7e4b030a2bc9e031864029a1f"}, ] [package.dependencies] @@ -1183,6 +1287,82 @@ files = [ [package.extras] cp2110 = ["hidapi"] +[[package]] +name = "pytest" +version = "8.1.1" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-8.1.1-py3-none-any.whl", hash = "sha256:2a8386cfc11fa9d2c50ee7b2a57e7d898ef90470a7a34c4b949ff59662bb78b7"}, + {file = "pytest-8.1.1.tar.gz", hash = "sha256:ac978141a75948948817d360297b7aae0fcb9d6ff6bc9ec6d514b85d5a65c044"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=1.4,<2.0" + +[package.extras] +testing = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-asyncio" +version = "0.23.5.post1" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-asyncio-0.23.5.post1.tar.gz", hash = "sha256:b9a8806bea78c21276bc34321bbf234ba1b2ea5b30d9f0ce0f2dea45e4685813"}, + {file = "pytest_asyncio-0.23.5.post1-py3-none-any.whl", hash = "sha256:30f54d27774e79ac409778889880242b0403d09cabd65b727ce90fe92dd5d80e"}, +] + +[package.dependencies] +pytest = ">=7.0.0,<9" + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] + +[[package]] +name = "pytest-cov" +version = "4.1.0" +description = "Pytest plugin for measuring coverage." +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"}, + {file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"}, +] + +[package.dependencies] +coverage = {version = ">=5.2.1", extras = ["toml"]} +pytest = ">=4.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] + +[[package]] +name = "pytest-xdist" +version = "3.5.0" +description = "pytest xdist plugin for distributed testing, most importantly across multiple CPUs" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-xdist-3.5.0.tar.gz", hash = "sha256:cbb36f3d67e0c478baa57fa4edc8843887e0f6cfc42d677530a36d7472b32d8a"}, + {file = "pytest_xdist-3.5.0-py3-none-any.whl", hash = "sha256:d075629c7e00b611df89f490a5063944bee7a4362a5ff11c7cc7824a03dfce24"}, +] + +[package.dependencies] +execnet = ">=1.1" +pytest = ">=6.2.0" + +[package.extras] +psutil = ["psutil (>=3.0)"] +setproctitle = ["setproctitle"] +testing = ["filelock"] + [[package]] name = "python-debouncer" version = "0.1.4" @@ -1228,13 +1408,13 @@ typing-extensions = ">=4.10.0,<5.0.0" [[package]] name = "python-redux" -version = "0.10.7" +version = "0.11.0" description = "Redux implementation for Python" optional = false python-versions = ">=3.11,<4.0" files = [ - {file = "python_redux-0.10.7-py3-none-any.whl", hash = "sha256:0654c94ea9e15f943a27431aa8bcb0e5a59332c7cf7dcacd77880eaab4267de9"}, - {file = "python_redux-0.10.7.tar.gz", hash = "sha256:e9cb92aea420c66998d736fa1941291f0c3e7905eec354c33aeb09d6967ab52b"}, + {file = "python_redux-0.11.0-py3-none-any.whl", hash = "sha256:8cfb2eb8b74f0b538589b92bf270c9b3611e66174388df61050413725ef73081"}, + {file = "python_redux-0.11.0.tar.gz", hash = "sha256:030669b6211984e3fceb8150e769f9f53d15a483a8e4865d889dc94648607bad"}, ] [package.dependencies] @@ -1424,20 +1604,34 @@ files = [ [[package]] name = "setuptools" -version = "69.1.1" +version = "69.2.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "setuptools-69.1.1-py3-none-any.whl", hash = "sha256:02fa291a0471b3a18b2b2481ed902af520c69e8ae0919c13da936542754b4c56"}, - {file = "setuptools-69.1.1.tar.gz", hash = "sha256:5c0806c7d9af348e6dd3777b4f4dbb42c7ad85b190104837488eab9a7c945cf8"}, + {file = "setuptools-69.2.0-py3-none-any.whl", hash = "sha256:c21c49fb1042386df081cb5d86759792ab89efca84cf114889191cd09aacc80c"}, + {file = "setuptools-69.2.0.tar.gz", hash = "sha256:0ff4183f8f42cd8fa3acea16c45205521a4ef28f73c6391d8a25e92893134f2e"}, ] [package.extras] docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] -testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pip (>=19.1)", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +testing = ["build[virtualenv]", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mypy (==1.9)", "packaging (>=23.2)", "pip (>=19.1)", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] +[[package]] +name = "tenacity" +version = "8.2.3" +description = "Retry code until it succeeds" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tenacity-8.2.3-py3-none-any.whl", hash = "sha256:ce510e327a630c9e1beaf17d42e6ffacc88185044ad85cf74c0a8887c6a0f88c"}, + {file = "tenacity-8.2.3.tar.gz", hash = "sha256:5398ef0d78e63f40007c1fb4c0bff96e1911394d2fa8d194f77619c05ff6cc8a"}, +] + +[package.extras] +doc = ["reno", "sphinx", "tornado (>=4.5)"] + [[package]] name = "toml" version = "0.10.2" @@ -1453,7 +1647,7 @@ files = [ name = "tomli" version = "2.0.1" description = "A lil' TOML parser" -optional = true +optional = false python-versions = ">=3.7" files = [ {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, @@ -1473,26 +1667,26 @@ files = [ [[package]] name = "ubo-gui" -version = "0.9.7" +version = "0.9.9" description = "GUI sdk for Ubo Pod" optional = true python-versions = ">=3.11,<4.0" files = [ - {file = "ubo_gui-0.9.7-py3-none-any.whl", hash = "sha256:133dd529dd2cb7e59b5842244eda52f0c550a85163ecd324e428df2686296c88"}, - {file = "ubo_gui-0.9.7.tar.gz", hash = "sha256:61031aacbdaec82dcabe26ccf8749ab1f11bfd2cbd6f2b199be1c48bc0d15e4e"}, + {file = "ubo_gui-0.9.9-py3-none-any.whl", hash = "sha256:904ea29dee17014687a3915242c6cbe8a342d57809654622bb679cfd258d4f85"}, + {file = "ubo_gui-0.9.9.tar.gz", hash = "sha256:cdf4e855fc61fc91c4151940bd663079beee5f612cba879e2145fb5b576a0f0e"}, ] [package.dependencies] headless-kivy-pi = [ - {version = ">=0.6.1,<0.7.0", extras = ["dev"], optional = true, markers = "extra == \"dev\""}, - {version = ">=0.6.1,<0.7.0", extras = ["default"], optional = true, markers = "extra == \"default\""}, + {version = ">=0.7.1,<0.8.0", extras = ["dev"], optional = true, markers = "extra == \"dev\""}, + {version = ">=0.7.1,<0.8.0", extras = ["default"], optional = true, markers = "extra == \"default\""}, ] python-immutable = ">=1.0.2,<2.0.0" qrcode = ">=7.4.2,<8.0.0" [package.extras] -default = ["headless-kivy-pi[default] (>=0.6.1,<0.7.0)"] -dev = ["headless-kivy-pi[dev] (>=0.6.1,<0.7.0)"] +default = ["headless-kivy-pi[default] (>=0.7.1,<0.8.0)"] +dev = ["headless-kivy-pi[dev] (>=0.7.1,<0.8.0)"] [[package]] name = "urllib3" @@ -1621,4 +1815,4 @@ dev = ["ubo-gui", "ubo-gui"] [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "7acc4f168c177a9a696827dfeea0ef94796279e40dff08b8ba75fd5ee8ac2dbd" +content-hash = "002ab898ba34f1e2566383fc92da243c57eeba5f68b0ff829456bbdf2a654469" diff --git a/pyproject.toml b/pyproject.toml index 1a731642..b7145234 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "ubo-app" -version = "0.11.0" +version = "0.11.1" description = "Ubo main app, running on device initialization. A platform for running other apps." authors = ["Sassan Haradji "] license = "Apache-2.0" @@ -15,21 +15,22 @@ priority = "primary" [tool.poetry.dependencies] + python = "^3.11" psutil = "^5.9.8" ubo-gui = [ - { version = "^0.9.7", markers = "extra=='default'", extras = [ + { version = "^0.9.9", markers = "extra=='default'", extras = [ 'default', ] }, - { version = "^0.9.7", markers = "extra=='dev'", extras = [ + { version = "^0.9.9", markers = "extra=='dev'", extras = [ 'dev', ] }, ] -python-redux = "^0.10.7" +python-redux = "^0.11.0" pyzbar = "^0.1.9" sdbus-networkmanager = { version = "^2.0.0", markers = "platform_machine=='aarch64'" } rpi_ws281x = { version = "^5.0.0", markers = "platform_machine=='aarch64'" } -python-debouncer = "^0.1.3" +python-debouncer = "^0.1.4" adafruit-circuitpython-neopixel = "^6.3.11" pulsectl = "^23.5.2" aiohttp = "^3.9.1" @@ -43,9 +44,15 @@ python-dotenv = "^1.0.1" optional = true [tool.poetry.group.dev.dependencies] +poethepoet = "^0.24.4" pyright = "^1.1.349" ruff = "^0.3.2" toml = "^0.10.2" +pytest = "^8.0.0" +pytest-cov = "^4.1.0" +pytest-xdist = "^3.5.0" +tenacity = "^8.2.3" +pytest-asyncio = "^0.23.5.post1" [tool.poetry.extras] default = ['ubo-gui'] @@ -61,7 +68,8 @@ build-backend = "poetry.core.masonry.api" [tool.poe.tasks] lint = "ruff check . --unsafe-fixes" typecheck = "pyright -p pyproject.toml ." -sanity = ["typecheck", "lint"] +test = "pytest --cov=ubo_app --cov-report=term-missing" +sanity = ["typecheck", "lint", "test"] [tool.poe.tasks.deploy_to_device] args = [ @@ -85,6 +93,9 @@ docstring-quotes = "double" inline-quotes = "single" multiline-quotes = "double" +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["S101"] + [tool.ruff.format] quote-style = 'single' @@ -93,3 +104,9 @@ profile = "black" [tool.pyright] exclude = ['typings'] + +[tool.pytest.ini_options] +asyncio_mode = 'auto' +filterwarnings = "ignore:'imghdr' is deprecated:DeprecationWarning" +log_cli = 1 +log_cli_level = 'ERROR' diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..fa59672d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,173 @@ +"""Pytest configuration file for the tests.""" +from __future__ import annotations + +import asyncio +import atexit +import datetime +import gc +import sys +import threading +import weakref +from typing import TYPE_CHECKING, AsyncGenerator, Callable, Generator + +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 + +if TYPE_CHECKING: + from logging import Logger + + from ubo_app.menu import MenuApp + +__all__ = ('app_context', 'snapshot') + + +@pytest.fixture(autouse=True, name='monkeypatch_atexit') +def _(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(atexit, 'register', lambda _: None) + + +modules_snapshot = set(sys.modules) + + +@pytest.fixture(autouse=True) +def _(monkeypatch: pytest.MonkeyPatch) -> None: + """Mock external resources.""" + monkeypatch.setattr('psutil.cpu_percent', lambda **_: 50) + monkeypatch.setattr( + 'psutil.virtual_memory', + lambda *_: type('', (object,), {'percent': 50}), + ) + + class DateTime(datetime.datetime): + @classmethod + def now(cls: type[DateTime], tz: datetime.tzinfo | None = None) -> DateTime: + _ = tz + return DateTime(2023, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc) + + monkeypatch.setattr(datetime, 'datetime', DateTime) + + +@pytest.fixture() +def logger() -> Logger: + import logging + + import ubo_app.logging + from ubo_app.constants import LOG_LEVEL + + level = ( + getattr( + ubo_app.logging, + LOG_LEVEL, + getattr(logging, LOG_LEVEL, logging.DEBUG), + ) + if LOG_LEVEL + else logging.DEBUG + ) + + logger = ubo_app.logging.get_logger('test') + logger.setLevel(level) + + return logger + + +class AppContext: + """Context object for tests running a menu application.""" + + def set_app(self: AppContext, app: MenuApp) -> None: + """Set the application.""" + self.app = app + loop = asyncio.get_event_loop() + self.task = loop.create_task(self.app.async_run(async_lib='asyncio')) + + +@pytest.fixture() +async def app_context(logger: Logger) -> AsyncGenerator[AppContext, None]: + """Create the application.""" + import os + + os.environ['KIVY_NO_FILELOG'] = '1' + os.environ['KIVY_NO_CONSOLELOG'] = '1' + + import headless_kivy_pi.config + + headless_kivy_pi.config.setup_headless_kivy({'automatic_fps': True}) + + context = AppContext() + + yield context + + assert context.task is not None, 'App not set for test' + + await context.task + + app_ref = weakref.ref(context.app) + context.app.root.clear_widgets() + + del context.app + del context.task + + gc.collect() + app = app_ref() + + if app is not None: + logger.debug( + 'Memory leak: failed to release app for test.', + extra={ + 'referrers': gc.get_referrers(app), + 'referents': gc.get_referents(app), + 'refcount': sys.getrefcount(app), + 'ref': app, + }, + ) + gc.collect() + for cell in gc.get_referrers(app): + 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' + + from kivy.core.window import Window + + Window.close() + + for module in set(sys.modules) - modules_snapshot: + if module != 'objc' and 'numpy' not in module and 'cache' not in module: + del sys.modules[module] + gc.collect() + + +@pytest.fixture() +def needs_finish() -> Generator: + yield None + + from ubo_app.store import dispatch + + dispatch(FinishAction()) + + +class WaitFor(threading.Thread): + def __call__( + self: WaitFor, + satisfaction: Callable[[], None], + *, + timeout: float = 1, + ) -> None: + self.retry = retry( + stop=stop_after_delay(timeout), + wait=wait_exponential(multiplier=0.5), + )(satisfaction) + self.start() + + def run(self: WaitFor) -> None: + self.retry() + + +@pytest.fixture() +def wait_for() -> Generator[WaitFor, None, None]: + context = WaitFor() + yield context + context.join() diff --git a/tests/results/test_all_services_register-000.hash b/tests/results/test_all_services_register-000.hash new file mode 100644 index 00000000..1416cd17 --- /dev/null +++ b/tests/results/test_all_services_register-000.hash @@ -0,0 +1 @@ +a4173e6761d21df059d9865602afbe8482c959c68a8a9426e3c3270374d77471 \ No newline at end of file diff --git a/tests/snapshot.py b/tests/snapshot.py new file mode 100644 index 00000000..77d00140 --- /dev/null +++ b/tests/snapshot.py @@ -0,0 +1,91 @@ +"""Let the test check snapshots of the window during execution.""" +from __future__ import annotations + +import os +from typing import TYPE_CHECKING + +import pytest + +if TYPE_CHECKING: + from logging import Logger + from pathlib import Path + + from _pytest.fixtures import SubRequest + 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' + + +def write_image(image_path: Path, array: NDArray) -> None: + """Write the `NDAarray` as an image to the given path.""" + import png + + png.Writer( + width=array.shape[0], + height=array.shape[1], + greyscale=False, # pyright: ignore [reportArgumentType] + bitdepth=8, + ).write( + image_path.open('wb'), + array.reshape(-1, array.shape[0] * 3).tolist(), + ) + + +class SnapshotContext: + """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' + 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.""" + import hashlib + + from headless_kivy_pi.config import _display + + array = _display.raw_data + data = array.tobytes() + sha256 = hashlib.sha256() + 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}' + + from headless_kivy_pi.config import _display + + path = self.results_dir / filename + hash_path = path.with_suffix('.hash') + + 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 + + +@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) diff --git a/tests/test_general_health.py b/tests/test_general_health.py new file mode 100644 index 00000000..7ad01da1 --- /dev/null +++ b/tests/test_general_health.py @@ -0,0 +1,27 @@ +# 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 + + +async def test_app_runs_and_exits( + app_context: AppContext, + needs_finish: None, +) -> None: + """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) diff --git a/tests/test_services.py b/tests/test_services.py new file mode 100644 index 00000000..7ee118cb --- /dev/null +++ b/tests/test_services.py @@ -0,0 +1,60 @@ +# ruff: noqa: S101 +"""Test the general health of the application.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from tenacity import AsyncRetrying, stop_after_delay, wait_fixed + +if TYPE_CHECKING: + from tests.conftest import AppContext + from tests.snapshot import SnapshotContext + +ALL_SERVICES_LABELS = [ + 'RGB Ring', + 'Sound', + 'Ethernet', + 'IP', + 'WiFi', + 'Keyboard', + 'Keypad', + 'Notifications', + 'Camera', + 'Sensors', + 'Docker', +] + + +async def test_all_services_register( + app_context: AppContext, + snapshot: SnapshotContext, + needs_finish: None, +) -> None: + """Test all services load.""" + _ = needs_finish + from ubo_app.load_services import load_services + from ubo_app.menu import MenuApp + + app = MenuApp() + load_services() + app_context.set_app(app) + + latest_hash = snapshot.hash + + async for attempt in AsyncRetrying( + stop=stop_after_delay(15), + wait=wait_fixed(3), + ): + with attempt: + from ubo_app.load_services import REGISTERED_PATHS + + for service_name in ALL_SERVICES_LABELS: + assert any( + 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() diff --git a/tests/utils/import_tracker.py b/tests/utils/import_tracker.py new file mode 100644 index 00000000..d68c6ec4 --- /dev/null +++ b/tests/utils/import_tracker.py @@ -0,0 +1,62 @@ +"""A module to track imports of other modules.""" +from __future__ import annotations + +import importlib.abc +import sys +from dataclasses import dataclass +from typing import TYPE_CHECKING, Callable, Sequence + +if TYPE_CHECKING: + from types import ModuleType + + +class ImportTracker(importlib.abc.MetaPathFinder): + """A meta path finder that tracks imports.""" + + def __init__(self: ImportTracker, modules: Sequence[str]) -> None: + """Initialize the import tracker.""" + self.modules = modules + self.imported_modules = {} + super().__init__() + + def find_spec( + self: ImportTracker, + fullname: str, + path: Sequence[str] | None, + target: ModuleType | None = None, + ) -> None: + """Find a module spec for the given module.""" + _ = (path, target) + if fullname not in self.imported_modules: + self.imported_modules[fullname] = 0 + self.imported_modules[fullname] += 1 + + +@dataclass +class Tracker: + """Return type for the install_tracker function.""" + + uninstall: Callable[[], None] + check: Callable[[str], bool] + imported_modules: dict[str, int] + + +def install_tracker(modules: Sequence[str]) -> Tracker: + """Install the import tracker.""" + import_tracker = ImportTracker(modules) + + sys.meta_path.insert(0, import_tracker) + + def uninstall_tracker() -> None: + """Uninstall the import tracker.""" + sys.meta_path.remove(import_tracker) + + def was_module_imported(module_name: str) -> bool: + """Check if a module was imported.""" + return module_name in import_tracker.imported_modules + + return Tracker( + uninstall=uninstall_tracker, + check=was_module_imported, + imported_modules=import_tracker.imported_modules, + ) diff --git a/ubo_app/__init__.py b/ubo_app/__init__.py index cf22b1c3..afc3970b 100644 --- a/ubo_app/__init__.py +++ b/ubo_app/__init__.py @@ -7,11 +7,10 @@ from redux import FinishAction -from ubo_app.constants import GUI_LOG_LEVEL, LOG_LEVEL +def setup_logging() -> None: + from ubo_app.constants import GUI_LOG_LEVEL, LOG_LEVEL -def main() -> None: - """Instantiate the `MenuApp` and run it.""" if LOG_LEVEL: import logging @@ -41,6 +40,11 @@ def main() -> None: 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 @@ -76,11 +80,11 @@ def thread_exception_handler(args: threading.ExceptHookArgs) -> None: threading.excepthook = thread_exception_handler - import headless_kivy_pi.config - - os.environ['KIVY_METRICS_DENSITY'] = '1' 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 @@ -88,12 +92,7 @@ def thread_exception_handler(args: threading.ExceptHookArgs) -> None: from ubo_app.load_services import load_services from ubo_app.menu import MenuApp - # Needed since redux is scheduled using Clock scheduler and Clock doesn't run before - # app is running - Clock.tick() - load_services() - app = MenuApp() try: diff --git a/ubo_app/load_services.py b/ubo_app/load_services.py index 5a21d952..c7a897ea 100644 --- a/ubo_app/load_services.py +++ b/ubo_app/load_services.py @@ -126,11 +126,11 @@ class UboServiceThread(threading.Thread): def __init__( self: UboServiceThread, path: Path, - service_uid: str, ) -> None: + name = path.name super().__init__() - self.service_uid = service_uid - self.id = self.service_uid.split(':')[1] + self.service_uid = f'{uuid.uuid4().hex}:{name}' + self.name = name self.label = '' self.path = path self.loop = asyncio.new_event_loop() @@ -176,7 +176,7 @@ def stop(self: UboServiceThread) -> None: self.loop.call_soon_threadsafe(self.loop.stop) def __repr__(self: UboServiceThread) -> str: - return f'' + return f'' def register_service( @@ -226,16 +226,12 @@ def load_services() -> None: ]: if Path(services_directory_path).is_dir(): for service_path in Path(services_directory_path).iterdir(): - service_uid = f'{uuid.uuid4().hex}:{service_path.name}' if not service_path.is_dir(): continue current_path = Path().absolute() os.chdir(service_path.as_posix()) - service = UboServiceThread( - service_path, - service_uid, - ) + service = UboServiceThread(service_path) REGISTERED_PATHS[service_path] = service service.start() diff --git a/ubo_app/menu_central.py b/ubo_app/menu_central.py index 7fc1f701..374f2742 100644 --- a/ubo_app/menu_central.py +++ b/ubo_app/menu_central.py @@ -2,14 +2,14 @@ from __future__ import annotations import pathlib +import weakref from functools import cached_property -from threading import Thread from typing import TYPE_CHECKING, Sequence from debouncer import DebounceOptions, debounce from kivy.clock import Clock, mainthread from kivy.lang.builder import Builder -from redux import EventSubscriptionOptions +from redux import AutorunOptions, EventSubscriptionOptions from ubo_gui.app import UboApp from ubo_gui.gauge import GaugeWidget from ubo_gui.menu import MenuWidget @@ -24,7 +24,6 @@ NotificationsClearAction, NotificationsDisplayEvent, ) -from ubo_app.utils.async_ import create_task from .store import autorun, dispatch, subscribe_event @@ -46,30 +45,32 @@ def __init__( self.ids.central_column.add_widget(self.cpu_gauge) self.ids.central_column.add_widget(self.ram_gauge) - volume_widget = VolumeWidget() - self.ids.right_column.add_widget(volume_widget) + self.volume_widget = VolumeWidget() + self.ids.right_column.add_widget(self.volume_widget) - @autorun(lambda state: state.sound.playback_volume) - def _(selector_result: float) -> None: - volume_widget.value = selector_result * 100 + self.sync_output_volume = self._sync_output_volume + autorun( + lambda state: state.sound.playback_volume, + options=AutorunOptions(keep_ref=False), + )(self.sync_output_volume) + + def _sync_output_volume(self: HomePage, selector_result: float) -> None: + self.volume_widget.value = selector_result * 100 @cached_property def cpu_gauge(self: HomePage) -> GaugeWidget: import psutil - gauge = GaugeWidget(value=0, fill_color='#24D636', label='CPU') - - value = 0 + gauge = GaugeWidget( + value=psutil.cpu_percent(percpu=False), + fill_color='#24D636', + label='CPU', + ) - def calculate_value() -> None: - nonlocal value - value = psutil.cpu_percent(interval=1, percpu=False) - gauge.value = value + def set_value(_: int) -> None: + gauge.value = psutil.cpu_percent(percpu=False) - Clock.schedule_interval( - lambda _: Thread(target=calculate_value).start(), - 1, - ) + Clock.schedule_interval(set_value, 1) return gauge @@ -92,12 +93,13 @@ 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: if self.depth == 1: - return HomePage( - self.current_menu_items, - name=f'Page {self.depth} 0', - ) + return self.home_page return super().get_current_screen() @@ -110,38 +112,46 @@ def set_path(menu_widget: MenuWidget, _: list[tuple[Menu, int] | PageWidget]) -> class MenuAppCentral(UboApp): - @cached_property - def central(self: MenuAppCentral) -> Widget | None: - """Build the main menu and initiate it.""" - self.menu_widget = MenuWidgetWithHomePage() + def __init__(self: MenuAppCentral, **kwargs: object) -> None: + super().__init__(**kwargs) + + _self = weakref.ref(self) @autorun(lambda state: state.main.menu) @debounce(0.1, DebounceOptions(leading=True, trailing=True, time_window=0.1)) - async def sync_current_menu(menu: Menu | None) -> None: - if not menu: + async def _(menu: Menu | None) -> None: + self = _self() + if not self or not menu or not self: return mainthread(self.menu_widget.set_root_menu)(menu) - sync_current_menu.subscribe(create_task, immediate_run=True) + def handle_title_change(self: MenuAppCentral, _: MenuWidget, title: str) -> None: + self.root.title = title - def handle_title_change(_: MenuWidget, title: str) -> None: - self.root.title = title + @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=handle_title_change) + self.menu_widget.bind(title=self.handle_title_change) self.menu_widget.bind(current_screen=set_path) subscribe_event( KeypadKeyPressEvent, self.handle_key_press_event, - options=EventSubscriptionOptions(immediate_run=True), + options=EventSubscriptionOptions( + immediate_run=True, + keep_ref=False, + ), ) subscribe_event( NotificationsDisplayEvent, - lambda event: Clock.schedule_once( - lambda _: self.display_notification(event), - -1, + self.display_notification, + options=EventSubscriptionOptions( + immediate_run=True, + keep_ref=False, ), ) @@ -164,6 +174,7 @@ def handle_key_press_event( if key_press_event.key == Key.DOWN: self.menu_widget.go_down() + @mainthread def display_notification( self: MenuAppCentral, event: NotificationsDisplayEvent, diff --git a/ubo_app/menu_footer.py b/ubo_app/menu_footer.py index 7fe0a7b8..ac3c626c 100644 --- a/ubo_app/menu_footer.py +++ b/ubo_app/menu_footer.py @@ -11,6 +11,7 @@ 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 @@ -20,6 +21,12 @@ class MenuAppFooter(UboApp): + def _set_temperature_value(self: MenuAppFooter, value: float | None = None) -> None: + if value is None: + self.temperature.text = '-' + else: + self.temperature.text = f'{value:0.1f}°C' + @cached_property def temperature_widget(self: MenuAppFooter) -> BoxLayout: layout = BoxLayout( @@ -29,8 +36,8 @@ def temperature_widget(self: MenuAppFooter) -> BoxLayout: size_hint=(None, 1), ) - temperature = Label(font_size=dp(14), size_hint=(None, 1), valign='middle') - temperature.bind( + self.temperature = Label(font_size=dp(14), size_hint=(None, 1), valign='middle') + self.temperature.bind( texture_size=lambda temperature, texture_size: setattr( temperature, 'width', @@ -39,12 +46,12 @@ def temperature_widget(self: MenuAppFooter) -> BoxLayout: or setattr(layout, 'width', temperature.width + dp(12)), ) - @autorun(lambda state: state.sensors.temperature.value) - def set_value(value: float | None = None) -> None: - if value is None: - temperature.text = '-' - else: - temperature.text = f'{value:0.1f}°C' + autorun( + lambda state: state.sensors.temperature.value, + options=AutorunOptions(keep_ref=False), + )( + self._set_temperature_value, + ) icon = Label( text='device_thermostat', @@ -56,13 +63,20 @@ def set_value(value: float | None = None) -> None: width=dp(12), ) layout.add_widget(icon) - layout.add_widget(temperature) + layout.add_widget(self.temperature) return layout + def _set_light_value(self: MenuAppFooter, value: float | None = None) -> None: + if value is None: + self.light.color = (0.5, 0, 0, 1) + else: + v = min(value, 140) / 140 + self.light.color = (1, 1, 1, v) + @cached_property def light_widget(self: MenuAppFooter) -> Label: - light = Label( + self.light = Label( text='light_mode', color='#ffffff', font_name='material_symbols', @@ -71,7 +85,7 @@ def light_widget(self: MenuAppFooter) -> Label: size_hint=(None, 1), width=dp(16), ) - light.bind( + self.light.bind( texture_size=lambda light, texture_size: setattr( light, 'width', @@ -79,15 +93,12 @@ def light_widget(self: MenuAppFooter) -> Label: ), ) - @autorun(lambda state: state.sensors.light.value) - def set_value(value: float | None = None) -> None: - if value is None: - light.color = (0.5, 0, 0, 1) - else: - v = min(value, 140) / 140 - light.color = (1, 1, 1, v) + autorun( + lambda state: state.sensors.light.value, + options=AutorunOptions(keep_ref=False), + )(self._set_light_value) - return light + return self.light @cached_property def clock_widget(self: MenuAppFooter) -> Label: @@ -119,12 +130,51 @@ def update(_: int | None = None) -> None: return clock + def _render_icons( + self: MenuAppFooter, + selector_result: Sequence[IconState], + ) -> None: + icons = selector_result + self.icons_layout.clear_widgets() + for icon in list(reversed(icons))[:4]: + label = Label( + text=icon.symbol, + color=icon.color, + font_name='material_symbols', + font_size=dp(20), + font_features='fill=0', + size_hint=(None, 1), + width=dp(22), + ) + self.icons_layout.add_widget(label) + self.icons_layout.add_widget(Widget(size_hint=(None, 1), width=dp(2))) + self.icons_layout.bind(minimum_width=self.icons_layout.setter('width')) + + def _handle_depth_change(self: MenuAppFooter, _: Sequence[str]) -> None: + is_fullscreen = False + if not is_fullscreen: + if self.normal_footer_layout in self.footer_layout.children: + self.footer_layout.remove_widget(self.normal_footer_layout) + self.footer_layout.add_widget(self.home_footer_layout) + elif self.home_footer_layout in self.footer_layout.children: + self.footer_layout.remove_widget(self.home_footer_layout) + self.footer_layout.add_widget(self.normal_footer_layout) + + def set_icons_layout_x(self: MenuAppFooter, *_: list[Any]) -> None: + self.icons_layout.x = ( + self.icons_widget.x + self.icons_widget.width - self.icons_layout.width + ) + @cached_property def footer(self: MenuAppFooter) -> Widget | None: - layout = BoxLayout() + self.footer_layout = BoxLayout() - normal_footer_layout = BoxLayout(orientation='horizontal', spacing=0, padding=0) - normal_footer_layout.add_widget( + self.normal_footer_layout = BoxLayout( + orientation='horizontal', + spacing=0, + padding=0, + ) + self.normal_footer_layout.add_widget( Label( text='reply', font_name='material_symbols', @@ -133,71 +183,55 @@ def footer(self: MenuAppFooter) -> Widget | None: size_hint=(None, 1), ), ) - normal_footer_layout.add_widget(Widget(size_hint=(1, 1))) + self.normal_footer_layout.add_widget(Widget(size_hint=(1, 1))) - home_footer_layout = BoxLayout(orientation='horizontal', spacing=0, padding=0) + self.home_footer_layout = BoxLayout( + orientation='horizontal', + spacing=0, + padding=0, + ) - home_footer_layout.add_widget(Widget(size_hint=(None, 1), width=dp(2))) - home_footer_layout.add_widget(self.clock_widget) - home_footer_layout.add_widget(Widget(size_hint=(None, 1), width=dp(2))) - home_footer_layout.add_widget(self.temperature_widget) - home_footer_layout.add_widget(Widget(size_hint=(None, 1), width=dp(2))) - home_footer_layout.add_widget(self.light_widget) - home_footer_layout.add_widget(Widget(size_hint=(None, 1), width=dp(2))) + self.home_footer_layout.add_widget(Widget(size_hint=(None, 1), width=dp(2))) + self.home_footer_layout.add_widget(self.clock_widget) + self.home_footer_layout.add_widget(Widget(size_hint=(None, 1), width=dp(2))) + self.home_footer_layout.add_widget(self.temperature_widget) + self.home_footer_layout.add_widget(Widget(size_hint=(None, 1), width=dp(2))) + self.home_footer_layout.add_widget(self.light_widget) + self.home_footer_layout.add_widget(Widget(size_hint=(None, 1), width=dp(2))) - icons_widget = StencilView(size_hint=(1, 1)) - home_footer_layout.add_widget(icons_widget) + self.icons_widget = StencilView(size_hint=(1, 1)) + self.home_footer_layout.add_widget(self.icons_widget) - icons_layout = BoxLayout( + self.icons_layout = BoxLayout( orientation='horizontal', spacing=dp(4), padding=0, ) - icons_widget.add_widget(icons_layout) + self.icons_widget.add_widget(self.icons_layout) - def set_icons_layout_x(*_: list[Any]) -> None: - icons_layout.x = icons_widget.x + icons_widget.width - icons_layout.width - - icons_widget.bind( - width=set_icons_layout_x, - x=set_icons_layout_x, - height=icons_layout.setter('height'), - y=icons_layout.setter('y'), + self.icons_widget.bind( + width=self.set_icons_layout_x, + x=self.set_icons_layout_x, + height=self.icons_layout.setter('height'), + y=self.icons_layout.setter('y'), ) - icons_layout.bind( - width=set_icons_layout_x, - x=set_icons_layout_x, + self.icons_layout.bind( + width=self.set_icons_layout_x, + x=self.set_icons_layout_x, ) - @autorun(lambda state: state.status_icons.icons) - def render_icons(selector_result: Sequence[IconState]) -> None: - icons = selector_result - icons_layout.clear_widgets() - for icon in list(reversed(icons))[:4]: - label = Label( - text=icon.symbol, - color=icon.color, - font_name='material_symbols', - font_size=dp(20), - font_features='fill=0', - size_hint=(None, 1), - width=dp(22), - ) - icons_layout.add_widget(label) - icons_layout.add_widget(Widget(size_hint=(None, 1), width=dp(2))) - icons_layout.bind(minimum_width=icons_layout.setter('width')) - - @autorun(lambda state: state.main.path) - def handle_depth_change(_: Sequence[str]) -> None: - is_fullscreen = False - if not is_fullscreen: - if normal_footer_layout in layout.children: - layout.remove_widget(normal_footer_layout) - layout.add_widget(home_footer_layout) - elif home_footer_layout in layout.children: - layout.remove_widget(home_footer_layout) - layout.add_widget(normal_footer_layout) - - layout.add_widget(home_footer_layout) + autorun( + lambda state: state.status_icons.icons, + options=AutorunOptions(keep_ref=False), + )(self._render_icons) - return layout + autorun( + lambda state: state.main.path, + options=AutorunOptions(keep_ref=False), + )( + self._handle_depth_change, + ) + + self.footer_layout.add_widget(self.home_footer_layout) + + return self.footer_layout diff --git a/ubo_app/services/000-sound/setup.py b/ubo_app/services/000-sound/setup.py index c693a98b..a6f10662 100644 --- a/ubo_app/services/000-sound/setup.py +++ b/ubo_app/services/000-sound/setup.py @@ -23,15 +23,15 @@ def init_service() -> None: audio_manager = AudioManager() @autorun(lambda state: state.sound.playback_volume) - def sync_playback_volume(volume: float) -> None: + def _(volume: float) -> None: audio_manager.set_playback_volume(volume) @autorun(lambda state: state.sound.capture_volume) - def sync_capture_volume(volume: float) -> None: + def _(volume: float) -> None: audio_manager.set_capture_volume(volume) @autorun(lambda state: state.sound.is_playback_mute) - def sync_playback_mute(is_mute: bool) -> None: # noqa: FBT001 + def _(is_mute: bool) -> None: # noqa: FBT001 audio_manager.set_playback_mute(mute=is_mute) subscribe_event( diff --git a/ubo_app/services/010-ethernet/ubo_handle.py b/ubo_app/services/010-ethernet/ubo_handle.py index 52cad2ea..74dfeb7c 100644 --- a/ubo_app/services/010-ethernet/ubo_handle.py +++ b/ubo_app/services/010-ethernet/ubo_handle.py @@ -4,8 +4,8 @@ from ubo_app.load_services import register_service register_service( - service_id='wifi', - label='WiFi', + service_id='ethernet', + label='Ethernet', ) init_service() diff --git a/ubo_app/services/040-camera/setup.py b/ubo_app/services/040-camera/setup.py index c543c45b..9f34d0a6 100644 --- a/ubo_app/services/040-camera/setup.py +++ b/ubo_app/services/040-camera/setup.py @@ -100,6 +100,10 @@ def start_camera_viewfinder(start_event: CameraStartViewfinderEvent) -> None: regex = re.compile(regex_pattern) if regex_pattern is not None else None last_match = 0 + display = headless_kivy_pi.config._display # noqa: SLF001 + if not display: + return + def feed_viewfinder(_: object) -> None: display = headless_kivy_pi.config._display # noqa: SLF001 if not display: @@ -141,7 +145,7 @@ def feed_viewfinder(_: object) -> None: headless_kivy_pi.config.pause() - def handle_stop_viewfinder(_: CameraStopViewfinderEvent) -> None: + def handle_stop_viewfinder() -> None: feed_viewfinder_scheduler.cancel() headless_kivy_pi.config.resume() cancel_subscription() diff --git a/ubo_app/side_effects.py b/ubo_app/side_effects.py index 0e49a05d..44c91cc5 100644 --- a/ubo_app/side_effects.py +++ b/ubo_app/side_effects.py @@ -3,14 +3,17 @@ import atexit import subprocess +import weakref from typing import TYPE_CHECKING, Sequence from debouncer import DebounceOptions, debounce -from kivy.clock import Clock -from redux import FinishAction, FinishEvent +from kivy.clock import mainthread +from redux import AutorunOptions, EventSubscriptionOptions, FinishAction, FinishEvent from ubo_app.store import autorun, dispatch, subscribe_event from ubo_app.store.main import PowerOffEvent +from ubo_app.store.services.notifications import Chime +from ubo_app.store.services.sound import SoundPlayChimeAction from ubo_app.store.update_manager import ( UpdateManagerCheckEvent, UpdateManagerSetStatusAction, @@ -20,16 +23,22 @@ from ubo_app.store.update_manager.reducer import ABOUT_MENU_PATH from ubo_app.store.update_manager.utils import check_version, update from ubo_app.utils.async_ import create_task -from ubo_app.utils.hardware import initialize_board, turn_off_screen, turn_on_screen +from ubo_app.utils.hardware import ( + IS_RPI, + initialize_board, + turn_off_screen, + turn_on_screen, +) if TYPE_CHECKING: from ubo_app.menu import MenuApp -def power_off(_: PowerOffEvent) -> None: +def power_off() -> None: """Power off the device.""" - dispatch(FinishAction()) - subprocess.run(['/usr/bin/env', 'systemctl', 'poweroff', '-i'], check=True) # noqa: S603 + dispatch(SoundPlayChimeAction(name=Chime.FAILURE), FinishAction()) + if IS_RPI: + subprocess.run(['/usr/bin/env', 'systemctl', 'poweroff', '-i'], check=True) # noqa: S603 def setup(app: MenuApp) -> None: @@ -37,10 +46,35 @@ def setup(app: MenuApp) -> None: turn_on_screen() initialize_board() - subscribe_event(PowerOffEvent, power_off) - subscribe_event(FinishEvent, lambda *_: Clock.schedule_once(app.stop)) - subscribe_event(UpdateManagerUpdateEvent, lambda: create_task(update())) - subscribe_event(UpdateManagerCheckEvent, lambda: create_task(check_version())) + subscribe_event( + PowerOffEvent, + power_off, + options=EventSubscriptionOptions(keep_ref=False), + ) + + app_ref = weakref.ref(app) + + @mainthread + def stop_app() -> None: + app = app_ref() + 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), + ) @debounce( wait=10, @@ -49,8 +83,8 @@ def setup(app: MenuApp) -> None: async def request_check_version() -> None: dispatch(UpdateManagerSetStatusAction(status=UpdateStatus.CHECKING)) - @autorun(lambda state: state.main.path) - def check_version_caller( + @autorun(lambda state: state.main.path, options=AutorunOptions(keep_ref=False)) + def _( path: Sequence[str], previous_path: Sequence[str], ) -> None: diff --git a/ubo_app/store/__init__.py b/ubo_app/store/__init__.py index fb7db1f7..97918fb6 100644 --- a/ubo_app/store/__init__.py +++ b/ubo_app/store/__init__.py @@ -32,6 +32,10 @@ from ubo_app.utils.async_ import create_task +def scheduler(callback: Callable[[], None], *, interval: bool) -> None: + Clock.create_trigger(lambda _: callback(), 0, interval=interval)() + + class RootState(BaseCombineReducerState): main: MainState status_icons: StatusIconsState @@ -69,10 +73,6 @@ class RootState(BaseCombineReducerState): ) -def scheduler(callback: Callable[[], None], *, interval: bool) -> None: - Clock.create_trigger(lambda _: callback(), 0, interval=interval)() - - store = Store( root_reducer, CreateStoreOptions( diff --git a/ubo_app/utils/garbage_collection.py b/ubo_app/utils/garbage_collection.py new file mode 100644 index 00000000..cdc6d6d3 --- /dev/null +++ b/ubo_app/utils/garbage_collection.py @@ -0,0 +1,93 @@ +# ruff: noqa: BLE001, S112, T100, T201 +"""Garbage collection investigation tools.""" +from __future__ import annotations + +import contextlib +import gc +from typing import TYPE_CHECKING, Callable + +from redux.main import inspect + +if TYPE_CHECKING: + import weakref + +SHORT_PRINT_LENGTH = 60 + + +def short_print(obj: object) -> None: + """Print the object.""" + print(type(obj), end=' ') + try: + print( + str(obj)[:SHORT_PRINT_LENGTH] + '...' + if len(str(obj)) > SHORT_PRINT_LENGTH + else str(obj), + ) + except Exception as exception: + if isinstance(exception, KeyboardInterrupt): + raise + print('Failed to print object') + + +def examine( + obj: object, + *, + depth_limit: int, + filter_: Callable[[object], bool] = lambda _: True, + looking_for: str | None = None, +) -> None: + """Examine the object.""" + island = [obj] + to_check: list[tuple[object, int]] = [(obj, 1)] + path = [] + + while to_check: + obj, depth = to_check.pop() + if looking_for: + while len(path) >= depth: + path.pop() + path.append(obj) + with contextlib.suppress(Exception): + if looking_for and looking_for in str(type(obj)): + print('Found') + for i in path: + short_print(i) + break + try: + referrers = [ + i + for i in gc.get_referrers(obj) + if i not in island + and i not in to_check + and i is not path + and i is not island + and i is not to_check + ] + except Exception: + continue + + for i in referrers: + island.append(i) + if filter_(i): + short_print(i) + if depth < depth_limit: + to_check.append((i, depth + 1)) + del referrers + + +def search_stack_for_instance(ref: weakref.ReferenceType) -> None: + """Search the stack for an instance.""" + frames = inspect.stack() + with contextlib.suppress(Exception): + for frame in frames: + local_vars = frame.frame.f_locals + for var_name, var_value in local_vars.items(): + if var_value is ref(): + print( + 'Found instance', + { + 'var_name': var_name, + 'var_value': var_value, + 'frame': frame, + }, + )