From 20e61149aae7c7c619a654a4dda1c7366fe447c8 Mon Sep 17 00:00:00 2001 From: Sassan Haradji Date: Wed, 24 Apr 2024 02:28:34 +0400 Subject: [PATCH] feat(core): organize settings in four different categories of connectivity, interface, system and apps feat(core): parse pronunciation hints in notification's extra info and render them as normal text while passing them to picovoice (used for pronunciation of ssh for example) feat (core): add shortcut `s` to write a json dump of the store into `snapshot.json` feat(core): add dill package and use it for pickling complex datatypes feat(core): add secrets module to abstract storing, recalling and removing secrets feat(core): add persistent_store module to abstract storing and recalling store elements feat(voice): use the new secrets module to save and load picovoice access key feat(docker): use the new secrets module to save and load passwords of different registries feat(docker): use the new persistent_store module to save and load docker registry to username mapping feat(sound): use the new persistent_store module to save and load playback volume, capture volume, and their mute state --- .gitignore | 4 +- CHANGELOG.md | 20 + poetry.lock | 420 ++++++++++++++---- pyproject.toml | 13 +- scripts/Dockerfile.dev | 2 +- tests/conftest.py | 4 +- .../wireless_flow/store-000.jsonc | 116 ++++- .../wireless_flow/store-001.jsonc | 116 ++++- .../wireless_flow/store-002.jsonc | 116 ++++- .../wireless_flow/store-003.jsonc | 118 ++++- .../wireless_flow/store-004.jsonc | 120 ++++- .../wireless_flow/store-005.jsonc | 120 ++++- .../wireless_flow/store-006.jsonc | 120 ++++- .../wireless_flow/store-007.jsonc | 117 ++++- .../wireless_flow/store-008.jsonc | 262 +++++++++++ .../wireless_flow/window-002.hash | 2 +- .../wireless_flow/window-003.hash | 2 +- .../wireless_flow/window-004.hash | 2 +- .../wireless_flow/window-005.hash | 2 +- .../wireless_flow/window-006.hash | 2 +- .../wireless_flow/window-008.hash | 2 + tests/end_to_end/test_wireless_flow.py | 6 + tests/fixtures/app.py | 22 + tests/fixtures/snapshot.py | 6 +- .../app_runs_and_exits/store-000.jsonc | 76 +++- .../all_services_register/store-000.jsonc | 258 +++++++---- tests/monkeypatch.py | 20 +- ubo_app/constants.py | 8 +- ubo_app/load_services.py | 4 +- ubo_app/menu_app/menu_central.py | 9 +- ubo_app/services/000-sound/reducer.py | 7 +- ubo_app/services/000-sound/setup.py | 6 + ubo_app/services/020-keyboard/setup.py | 4 +- ubo_app/services/030-ip/setup.py | 10 +- ubo_app/services/030-wifi/setup.py | 10 +- ubo_app/services/040-camera/setup.py | 5 + ubo_app/services/050-lightdm/setup.py | 4 +- ubo_app/services/050-ssh/setup.py | 11 +- ubo_app/services/080-docker/image.py | 71 +-- ubo_app/services/080-docker/reducer.py | 36 +- ubo_app/services/080-docker/setup.py | 130 +++++- ubo_app/services/090-voice/setup.py | 129 ++++-- ubo_app/side_effects.py | 16 +- ubo_app/store/__init__.py | 115 ++++- ubo_app/store/main/__init__.py | 14 +- ubo_app/store/main/_menus.py | 15 +- ubo_app/store/main/reducer.py | 117 ++++- ubo_app/store/services/docker.py | 28 +- ubo_app/store/services/sound.py | 40 +- ubo_app/store/update_manager/reducer.py | 3 +- ubo_app/utils/persistent_store.py | 89 ++++ ubo_app/utils/secrets.py | 50 +++ ubo_app/utils/serializer.py | 16 + 53 files changed, 2497 insertions(+), 518 deletions(-) create mode 100644 tests/end_to_end/results/test_wireless_flow/wireless_flow/store-008.jsonc create mode 100644 tests/end_to_end/results/test_wireless_flow/wireless_flow/window-008.hash create mode 100644 ubo_app/utils/persistent_store.py create mode 100644 ubo_app/utils/secrets.py create mode 100644 ubo_app/utils/serializer.py diff --git a/.gitignore b/.gitignore index 0a60fb7c..bdad5813 100644 --- a/.gitignore +++ b/.gitignore @@ -85,4 +85,6 @@ headless_kivy_pi_buffer.raw */packer/packer_cache/ */packer/output-* -screenshots +/screenshots +/snapshot.json +/snapshot.bin diff --git a/CHANGELOG.md b/CHANGELOG.md index 5efe89f7..05e4c6ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## Version 0.13.0 + +- feat(core): organize settings in four different categories of connectivity, + interface, system and apps +- feat(core): parse pronunciation hints in notification's extra info and render them + as normal text while passing them to picovoice (used for pronunciation of ssh + for example) +- feat (core): add shortcut `s` to write a json dump of the store into `snapshot.json` +- feat(core): add dill package and use it for pickling complex datatypes +- feat(core): add secrets module to abstract storing, recalling and removing secrets +- feat(core): add persistent_store module to abstract storing and recalling store + elements +- feat(voice): use the new secrets module to save and load picovoice access key +- feat(docker): use the new secrets module to save and load passwords of different + registries +- feat(docker): use the new persistent_store module to save and load docker registry + to username mapping +- feat(sound): use the new persistent_store module to save and load playback volume, + capture volume, and their mute state + ## Version 0.12.7 - feat(notification): make the extra information screen scrollable diff --git a/poetry.lock b/poetry.lock index 84b78d6b..4c49c6e2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -35,13 +35,13 @@ adafruit-circuitpython-register = "*" [[package]] name = "adafruit-circuitpython-busdevice" -version = "5.2.6" +version = "5.2.9" description = "CircuitPython bus device classes to manage bus sharing." optional = false python-versions = "*" files = [ - {file = "adafruit-circuitpython-busdevice-5.2.6.tar.gz", hash = "sha256:ed06f5552e5567b0c89589c5bc6ef3adcac67d59eb505ce9127af99f33c2bc90"}, - {file = "adafruit_circuitpython_busdevice-5.2.6-py3-none-any.whl", hash = "sha256:9f25577843f0a338a0936a1b57436f4451f7783b38e3cf46160b6be78faeaa44"}, + {file = "adafruit_circuitpython_busdevice-5.2.9-py3-none-any.whl", hash = "sha256:78245c551eb3795a4f3739f1adac0b08a65c9fb0f722e866de620482cbbac51f"}, + {file = "adafruit_circuitpython_busdevice-5.2.9.tar.gz", hash = "sha256:9f9c3df385091410dac5961918e475ec88faed7ff543ad6d74208f08c2566513"}, ] [package.dependencies] @@ -318,6 +318,24 @@ files = [ [package.dependencies] frozenlist = ">=1.1.0" +[[package]] +name = "asttokens" +version = "2.4.1" +description = "Annotate AST trees with source code positions" +optional = false +python-versions = "*" +files = [ + {file = "asttokens-2.4.1-py2.py3-none-any.whl", hash = "sha256:051ed49c3dcae8913ea7cd08e46a606dba30b79993209636c4875bc1d637bc24"}, + {file = "asttokens-2.4.1.tar.gz", hash = "sha256:b03869718ba9a6eb027e134bfdf69f38a236d681c83c160d510768af11254ba0"}, +] + +[package.dependencies] +six = ">=1.12.0" + +[package.extras] +astroid = ["astroid (>=1,<2)", "astroid (>=2,<4)"] +test = ["astroid (>=1,<2)", "astroid (>=2,<4)", "pytest"] + [[package]] name = "attrs" version = "23.2.0" @@ -460,63 +478,63 @@ files = [ [[package]] name = "coverage" -version = "7.4.4" +version = "7.5.0" 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"}, + {file = "coverage-7.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:432949a32c3e3f820af808db1833d6d1631664d53dd3ce487aa25d574e18ad1c"}, + {file = "coverage-7.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2bd7065249703cbeb6d4ce679c734bef0ee69baa7bff9724361ada04a15b7e3b"}, + {file = "coverage-7.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bbfe6389c5522b99768a93d89aca52ef92310a96b99782973b9d11e80511f932"}, + {file = "coverage-7.5.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:39793731182c4be939b4be0cdecde074b833f6171313cf53481f869937129ed3"}, + {file = "coverage-7.5.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85a5dbe1ba1bf38d6c63b6d2c42132d45cbee6d9f0c51b52c59aa4afba057517"}, + {file = "coverage-7.5.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:357754dcdfd811462a725e7501a9b4556388e8ecf66e79df6f4b988fa3d0b39a"}, + {file = "coverage-7.5.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a81eb64feded34f40c8986869a2f764f0fe2db58c0530d3a4afbcde50f314880"}, + {file = "coverage-7.5.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:51431d0abbed3a868e967f8257c5faf283d41ec882f58413cf295a389bb22e58"}, + {file = "coverage-7.5.0-cp310-cp310-win32.whl", hash = "sha256:f609ebcb0242d84b7adeee2b06c11a2ddaec5464d21888b2c8255f5fd6a98ae4"}, + {file = "coverage-7.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:6782cd6216fab5a83216cc39f13ebe30adfac2fa72688c5a4d8d180cd52e8f6a"}, + {file = "coverage-7.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e768d870801f68c74c2b669fc909839660180c366501d4cc4b87efd6b0eee375"}, + {file = "coverage-7.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:84921b10aeb2dd453247fd10de22907984eaf80901b578a5cf0bb1e279a587cb"}, + {file = "coverage-7.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:710c62b6e35a9a766b99b15cdc56d5aeda0914edae8bb467e9c355f75d14ee95"}, + {file = "coverage-7.5.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c379cdd3efc0658e652a14112d51a7668f6bfca7445c5a10dee7eabecabba19d"}, + {file = "coverage-7.5.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fea9d3ca80bcf17edb2c08a4704259dadac196fe5e9274067e7a20511fad1743"}, + {file = "coverage-7.5.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:41327143c5b1d715f5f98a397608f90ab9ebba606ae4e6f3389c2145410c52b1"}, + {file = "coverage-7.5.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:565b2e82d0968c977e0b0f7cbf25fd06d78d4856289abc79694c8edcce6eb2de"}, + {file = "coverage-7.5.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cf3539007202ebfe03923128fedfdd245db5860a36810136ad95a564a2fdffff"}, + {file = "coverage-7.5.0-cp311-cp311-win32.whl", hash = "sha256:bf0b4b8d9caa8d64df838e0f8dcf68fb570c5733b726d1494b87f3da85db3a2d"}, + {file = "coverage-7.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:9c6384cc90e37cfb60435bbbe0488444e54b98700f727f16f64d8bfda0b84656"}, + {file = "coverage-7.5.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:fed7a72d54bd52f4aeb6c6e951f363903bd7d70bc1cad64dd1f087980d309ab9"}, + {file = "coverage-7.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cbe6581fcff7c8e262eb574244f81f5faaea539e712a058e6707a9d272fe5b64"}, + {file = "coverage-7.5.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad97ec0da94b378e593ef532b980c15e377df9b9608c7c6da3506953182398af"}, + {file = "coverage-7.5.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd4bacd62aa2f1a1627352fe68885d6ee694bdaebb16038b6e680f2924a9b2cc"}, + {file = "coverage-7.5.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:adf032b6c105881f9d77fa17d9eebe0ad1f9bfb2ad25777811f97c5362aa07f2"}, + {file = "coverage-7.5.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4ba01d9ba112b55bfa4b24808ec431197bb34f09f66f7cb4fd0258ff9d3711b1"}, + {file = "coverage-7.5.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:f0bfe42523893c188e9616d853c47685e1c575fe25f737adf473d0405dcfa7eb"}, + {file = "coverage-7.5.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a9a7ef30a1b02547c1b23fa9a5564f03c9982fc71eb2ecb7f98c96d7a0db5cf2"}, + {file = "coverage-7.5.0-cp312-cp312-win32.whl", hash = "sha256:3c2b77f295edb9fcdb6a250f83e6481c679335ca7e6e4a955e4290350f2d22a4"}, + {file = "coverage-7.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:427e1e627b0963ac02d7c8730ca6d935df10280d230508c0ba059505e9233475"}, + {file = "coverage-7.5.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9dd88fce54abbdbf4c42fb1fea0e498973d07816f24c0e27a1ecaf91883ce69e"}, + {file = "coverage-7.5.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a898c11dca8f8c97b467138004a30133974aacd572818c383596f8d5b2eb04a9"}, + {file = "coverage-7.5.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:07dfdd492d645eea1bd70fb1d6febdcf47db178b0d99161d8e4eed18e7f62fe7"}, + {file = "coverage-7.5.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d3d117890b6eee85887b1eed41eefe2e598ad6e40523d9f94c4c4b213258e4a4"}, + {file = "coverage-7.5.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6afd2e84e7da40fe23ca588379f815fb6dbbb1b757c883935ed11647205111cb"}, + {file = "coverage-7.5.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:a9960dd1891b2ddf13a7fe45339cd59ecee3abb6b8326d8b932d0c5da208104f"}, + {file = "coverage-7.5.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ced268e82af993d7801a9db2dbc1d2322e786c5dc76295d8e89473d46c6b84d4"}, + {file = "coverage-7.5.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e7c211f25777746d468d76f11719e64acb40eed410d81c26cefac641975beb88"}, + {file = "coverage-7.5.0-cp38-cp38-win32.whl", hash = "sha256:262fffc1f6c1a26125d5d573e1ec379285a3723363f3bd9c83923c9593a2ac25"}, + {file = "coverage-7.5.0-cp38-cp38-win_amd64.whl", hash = "sha256:eed462b4541c540d63ab57b3fc69e7d8c84d5957668854ee4e408b50e92ce26a"}, + {file = "coverage-7.5.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d0194d654e360b3e6cc9b774e83235bae6b9b2cac3be09040880bb0e8a88f4a1"}, + {file = "coverage-7.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:33c020d3322662e74bc507fb11488773a96894aa82a622c35a5a28673c0c26f5"}, + {file = "coverage-7.5.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbdf2cae14a06827bec50bd58e49249452d211d9caddd8bd80e35b53cb04631"}, + {file = "coverage-7.5.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3235d7c781232e525b0761730e052388a01548bd7f67d0067a253887c6e8df46"}, + {file = "coverage-7.5.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2de4e546f0ec4b2787d625e0b16b78e99c3e21bc1722b4977c0dddf11ca84e"}, + {file = "coverage-7.5.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4d0e206259b73af35c4ec1319fd04003776e11e859936658cb6ceffdeba0f5be"}, + {file = "coverage-7.5.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:2055c4fb9a6ff624253d432aa471a37202cd8f458c033d6d989be4499aed037b"}, + {file = "coverage-7.5.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:075299460948cd12722a970c7eae43d25d37989da682997687b34ae6b87c0ef0"}, + {file = "coverage-7.5.0-cp39-cp39-win32.whl", hash = "sha256:280132aada3bc2f0fac939a5771db4fbb84f245cb35b94fae4994d4c1f80dae7"}, + {file = "coverage-7.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:c58536f6892559e030e6924896a44098bc1290663ea12532c78cef71d0df8493"}, + {file = "coverage-7.5.0-pp38.pp39.pp310-none-any.whl", hash = "sha256:2b57780b51084d5223eee7b59f0d4911c31c16ee5aa12737c7a02455829ff067"}, + {file = "coverage-7.5.0.tar.gz", hash = "sha256:cf62d17310f34084c59c01e027259076479128d11e4661bb6c9acb38c5e19bb8"}, ] [package.extras] @@ -589,6 +607,32 @@ files = [ {file = "Cython-3.0.10.tar.gz", hash = "sha256:dcc96739331fb854dcf503f94607576cfe8488066c61ca50dfd55836f132de99"}, ] +[[package]] +name = "decorator" +version = "5.1.1" +description = "Decorators for Humans" +optional = false +python-versions = ">=3.5" +files = [ + {file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"}, + {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"}, +] + +[[package]] +name = "dill" +version = "0.3.8" +description = "serialize all of Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "dill-0.3.8-py3-none-any.whl", hash = "sha256:c36ca9ffb54365bdd2f8eb3eff7d2a21237f8452b57ace88b1ac615b7e815bd7"}, + {file = "dill-0.3.8.tar.gz", hash = "sha256:3ebe3c479ad625c4553aca177444d89b486b1d84982eeacded644afc0cf797ca"}, +] + +[package.extras] +graph = ["objgraph (>=1.7.2)"] +profile = ["gprof2dot (>=2022.7.29)"] + [[package]] name = "docker" version = "7.0.0" @@ -612,13 +656,13 @@ websockets = ["websocket-client (>=1.3.0)"] [[package]] name = "docutils" -version = "0.21.1" +version = "0.21.2" description = "Docutils -- Python Documentation Utilities" optional = true python-versions = ">=3.9" files = [ - {file = "docutils-0.21.1-py3-none-any.whl", hash = "sha256:14c8d34a55b46c88f9f714adb29cefbdd69fb82f3fef825e59c5faab935390d8"}, - {file = "docutils-0.21.1.tar.gz", hash = "sha256:65249d8a5345bc95e0f40f280ba63c98eb24de35c6c8f5b662e3e8948adea83f"}, + {file = "docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2"}, + {file = "docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f"}, ] [[package]] @@ -635,6 +679,20 @@ files = [ [package.extras] testing = ["hatch", "pre-commit", "pytest", "tox"] +[[package]] +name = "executing" +version = "2.0.1" +description = "Get the currently executing AST node of a frame, and other information" +optional = false +python-versions = ">=3.5" +files = [ + {file = "executing-2.0.1-py2.py3-none-any.whl", hash = "sha256:eac49ca94516ccc753f9fb5ce82603156e590b27525a8bc32cce8ae302eb61bc"}, + {file = "executing-2.0.1.tar.gz", hash = "sha256:35afe2ce3affba8ee97f2d69927fa823b08b472b7b994e36a52a964b93d16147"}, +] + +[package.extras] +tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipython", "littleutils", "pytest", "rich"] + [[package]] name = "frozenlist" version = "1.4.1" @@ -766,6 +824,62 @@ files = [ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] +[[package]] +name = "ipython" +version = "8.23.0" +description = "IPython: Productive Interactive Computing" +optional = false +python-versions = ">=3.10" +files = [ + {file = "ipython-8.23.0-py3-none-any.whl", hash = "sha256:07232af52a5ba146dc3372c7bf52a0f890a23edf38d77caef8d53f9cdc2584c1"}, + {file = "ipython-8.23.0.tar.gz", hash = "sha256:7468edaf4f6de3e1b912e57f66c241e6fd3c7099f2ec2136e239e142e800274d"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +decorator = "*" +jedi = ">=0.16" +matplotlib-inline = "*" +pexpect = {version = ">4.3", markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\""} +prompt-toolkit = ">=3.0.41,<3.1.0" +pygments = ">=2.4.0" +stack-data = "*" +traitlets = ">=5.13.0" +typing-extensions = {version = "*", markers = "python_version < \"3.12\""} + +[package.extras] +all = ["ipython[black,doc,kernel,matplotlib,nbconvert,nbformat,notebook,parallel,qtconsole]", "ipython[test,test-extra]"] +black = ["black"] +doc = ["docrepr", "exceptiongroup", "ipykernel", "ipython[test]", "matplotlib", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "sphinxcontrib-jquery", "stack-data", "typing-extensions"] +kernel = ["ipykernel"] +matplotlib = ["matplotlib"] +nbconvert = ["nbconvert"] +nbformat = ["nbformat"] +notebook = ["ipywidgets", "notebook"] +parallel = ["ipyparallel"] +qtconsole = ["qtconsole"] +test = ["pickleshare", "pytest (<8)", "pytest-asyncio (<0.22)", "testpath"] +test-extra = ["curio", "ipython[test]", "matplotlib (!=3.2.0)", "nbformat", "numpy (>=1.23)", "pandas", "trio"] + +[[package]] +name = "jedi" +version = "0.19.1" +description = "An autocompletion tool for Python that can be used for text editors." +optional = false +python-versions = ">=3.6" +files = [ + {file = "jedi-0.19.1-py2.py3-none-any.whl", hash = "sha256:e983c654fe5c02867aef4cdfce5a2fbb4a50adc0af145f70504238f18ef5e7e0"}, + {file = "jedi-0.19.1.tar.gz", hash = "sha256:cf0496f3651bc65d7174ac1b7d043eff454892c708a87d1b683e57b569927ffd"}, +] + +[package.dependencies] +parso = ">=0.8.3,<0.9.0" + +[package.extras] +docs = ["Jinja2 (==2.11.3)", "MarkupSafe (==1.1.1)", "Pygments (==2.8.1)", "alabaster (==0.7.12)", "babel (==2.9.1)", "chardet (==4.0.0)", "commonmark (==0.8.1)", "docutils (==0.17.1)", "future (==0.18.2)", "idna (==2.10)", "imagesize (==1.2.0)", "mock (==1.0.1)", "packaging (==20.9)", "pyparsing (==2.4.7)", "pytz (==2021.1)", "readthedocs-sphinx-ext (==2.1.4)", "recommonmark (==0.5.0)", "requests (==2.25.1)", "six (==1.15.0)", "snowballstemmer (==2.1.0)", "sphinx (==1.8.5)", "sphinx-rtd-theme (==0.4.3)", "sphinxcontrib-serializinghtml (==1.1.4)", "sphinxcontrib-websupport (==1.2.4)", "urllib3 (==1.26.4)"] +qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"] +testing = ["Django", "attrs", "colorama", "docopt", "pytest (<7.0.0)"] + [[package]] name = "kivy" version = "2.3.0" @@ -913,6 +1027,20 @@ files = [ {file = "lock-2018.3.25.2110.tar.gz", hash = "sha256:cc5ac770930493eed7a8cfd0cf2568a125faf112eb8aa6b6149b3e581523d0c7"}, ] +[[package]] +name = "matplotlib-inline" +version = "0.1.7" +description = "Inline Matplotlib backend for Jupyter" +optional = false +python-versions = ">=3.8" +files = [ + {file = "matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca"}, + {file = "matplotlib_inline-0.1.7.tar.gz", hash = "sha256:8423b23ec666be3d16e16b60bdd8ac4e86e840ebd1dd11a30b9f117f2fa0ab90"}, +] + +[package.dependencies] +traitlets = "*" + [[package]] name = "multidict" version = "6.0.5" @@ -1082,6 +1210,21 @@ files = [ {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"}, ] +[[package]] +name = "parso" +version = "0.8.4" +description = "A Python Parser" +optional = false +python-versions = ">=3.6" +files = [ + {file = "parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18"}, + {file = "parso-0.8.4.tar.gz", hash = "sha256:eb3a7b58240fb99099a345571deecc0f9540ea5f4dd2fe14c2a99d6b281ab92d"}, +] + +[package.extras] +qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"] +testing = ["docopt", "pytest"] + [[package]] name = "pastel" version = "0.2.1" @@ -1093,15 +1236,45 @@ files = [ {file = "pastel-0.2.1.tar.gz", hash = "sha256:e6581ac04e973cac858828c6202c1e1e81fee1dc7de7683f3e1ffe0bfd8a573d"}, ] +[[package]] +name = "pexpect" +version = "4.9.0" +description = "Pexpect allows easy control of interactive console applications." +optional = false +python-versions = "*" +files = [ + {file = "pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523"}, + {file = "pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f"}, +] + +[package.dependencies] +ptyprocess = ">=0.5" + +[[package]] +name = "platformdirs" +version = "4.2.1" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.8" +files = [ + {file = "platformdirs-4.2.1-py3-none-any.whl", hash = "sha256:17d5a1161b3fd67b390023cb2d3b026bbd40abde6fdb052dfbd3a29c3ba22ee1"}, + {file = "platformdirs-4.2.1.tar.gz", hash = "sha256:031cd18d4ec63ec53e82dceaac0417d218a6863f7745dfcc9efe7793b7039bdf"}, +] + +[package.extras] +docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] +type = ["mypy (>=1.8)"] + [[package]] name = "pluggy" -version = "1.4.0" +version = "1.5.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"}, + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, ] [package.extras] @@ -1126,6 +1299,20 @@ tomli = ">=1.2.2" [package.extras] poetry-plugin = ["poetry (>=1.0,<2.0)"] +[[package]] +name = "prompt-toolkit" +version = "3.0.43" +description = "Library for building powerful interactive command lines in Python" +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "prompt_toolkit-3.0.43-py3-none-any.whl", hash = "sha256:a11a29cb3bf0a28a387fe5122cdb649816a957cd9261dcedf8c9f1fef33eacf6"}, + {file = "prompt_toolkit-3.0.43.tar.gz", hash = "sha256:3527b7af26106cbc65a040bcc84839a3566ec1b051bb0bfe953631e704b0ff7d"}, +] + +[package.dependencies] +wcwidth = "*" + [[package]] name = "psutil" version = "5.9.8" @@ -1154,6 +1341,17 @@ files = [ [package.extras] test = ["enum34", "ipaddress", "mock", "pywin32", "wmi"] +[[package]] +name = "ptyprocess" +version = "0.7.0" +description = "Run a subprocess in a pseudo terminal" +optional = false +python-versions = "*" +files = [ + {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, + {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"}, +] + [[package]] name = "pulsectl" version = "23.5.2" @@ -1165,6 +1363,20 @@ files = [ {file = "pulsectl-23.5.2.tar.gz", hash = "sha256:e911d398eaf0539cf3c63b4217357b51a3d1b7e4a50607d1591cf2b49f5d2c6a"}, ] +[[package]] +name = "pure-eval" +version = "0.2.2" +description = "Safely evaluate AST nodes without side effects" +optional = false +python-versions = "*" +files = [ + {file = "pure_eval-0.2.2-py3-none-any.whl", hash = "sha256:01eaab343580944bc56080ebe0a674b39ec44a945e6d09ba7db3cb8cec289350"}, + {file = "pure_eval-0.2.2.tar.gz", hash = "sha256:2b45320af6dfaa1750f543d714b6d1c520a1688dec6fd24d339063ce0aaa9ac3"}, +] + +[package.extras] +tests = ["pytest"] + [[package]] name = "pvorca" version = "0.1.4" @@ -1218,7 +1430,7 @@ pyusb = ">=1.0.0,<1.2.0 || >1.2.0" name = "pygments" version = "2.17.2" description = "Pygments is a syntax highlighting package written in Python." -optional = true +optional = false python-versions = ">=3.7" files = [ {file = "pygments-2.17.2-py3-none-any.whl", hash = "sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c"}, @@ -1291,13 +1503,13 @@ files = [ [[package]] name = "pyright" -version = "1.1.359" +version = "1.1.360" description = "Command line wrapper for pyright" optional = false python-versions = ">=3.7" files = [ - {file = "pyright-1.1.359-py3-none-any.whl", hash = "sha256:5582777be7eab73512277ac7da7b41e15bc0737f488629cb9babd96e0769be61"}, - {file = "pyright-1.1.359.tar.gz", hash = "sha256:f0eab50f3dafce8a7302caeafd6a733f39901a2bf5170bb23d77fd607c8a8dbc"}, + {file = "pyright-1.1.360-py3-none-any.whl", hash = "sha256:7637f75451ac968b7cf1f8c51cfefb6d60ac7d086eb845364bc8ac03a026efd7"}, + {file = "pyright-1.1.360.tar.gz", hash = "sha256:784ddcda9745e9f5610483d7b963e9aa8d4f50d7755a9dffb28ccbeb27adce32"}, ] [package.dependencies] @@ -1410,18 +1622,18 @@ pytest = ">=7.0.0" [[package]] name = "pytest-xdist" -version = "3.6.0" +version = "3.5.0" description = "pytest xdist plugin for distributed testing, most importantly across multiple CPUs" optional = false -python-versions = ">=3.8" +python-versions = ">=3.7" files = [ - {file = "pytest_xdist-3.6.0-py3-none-any.whl", hash = "sha256:958e08f38472e1b3a83450d8d3e682e90fdbffee39a97dd0f27185a3bd9074d1"}, - {file = "pytest_xdist-3.6.0.tar.gz", hash = "sha256:2bf346fb1f1481c8d255750f80bc1dfb9fb18b9ad5286ead0b741b6fd56d15b7"}, + {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 = ">=2.1" -pytest = ">=7.0.0" +execnet = ">=1.1" +pytest = ">=6.2.0" [package.extras] psutil = ["psutil (>=3.0)"] @@ -1473,13 +1685,13 @@ typing-extensions = ">=4.10.0,<5.0.0" [[package]] name = "python-redux" -version = "0.14.1" +version = "0.14.3" description = "Redux implementation for Python" optional = false python-versions = "<4.0,>=3.11" files = [ - {file = "python_redux-0.14.1-py3-none-any.whl", hash = "sha256:0f3627ca0ac9378b90c1a863df4c045a7253bec7d0142b65a7e8b6efea069ad6"}, - {file = "python_redux-0.14.1.tar.gz", hash = "sha256:e233e2f8dc58c5338beacdbeed4b3655dfa917e3b8dd01c9aac69339657b6def"}, + {file = "python_redux-0.14.3-py3-none-any.whl", hash = "sha256:94ab341d5ac6d3c90c18d48eaaf173e81f8bb57f0df7a6f2c75a05431b0aecd6"}, + {file = "python_redux-0.14.3.tar.gz", hash = "sha256:f010ecda2af7f45b306b116a0d54edfc2bf60367a48e72fa9dcdfa144ea8a4a1"}, ] [package.dependencies] @@ -1728,6 +1940,36 @@ docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments 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,!=8.1.1)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "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 = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + +[[package]] +name = "stack-data" +version = "0.6.3" +description = "Extract data from python stack frames and tracebacks for informative displays" +optional = false +python-versions = "*" +files = [ + {file = "stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695"}, + {file = "stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9"}, +] + +[package.dependencies] +asttokens = ">=2.1.0" +executing = ">=1.2.0" +pure-eval = "*" + +[package.extras] +tests = ["cython", "littleutils", "pygments", "pytest", "typeguard"] + [[package]] name = "tenacity" version = "8.2.3" @@ -1764,6 +2006,21 @@ files = [ {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, ] +[[package]] +name = "traitlets" +version = "5.14.3" +description = "Traitlets Python configuration system" +optional = false +python-versions = ">=3.8" +files = [ + {file = "traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f"}, + {file = "traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7"}, +] + +[package.extras] +docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"] +test = ["argcomplete (>=3.0.3)", "mypy (>=1.7.0)", "pre-commit", "pytest (>=7.0,<8.2)", "pytest-mock", "pytest-mypy-testing"] + [[package]] name = "typing-extensions" version = "4.11.0" @@ -1815,6 +2072,17 @@ h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] +[[package]] +name = "wcwidth" +version = "0.2.13" +description = "Measures the displayed width of unicode strings in a terminal" +optional = false +python-versions = "*" +files = [ + {file = "wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859"}, + {file = "wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"}, +] + [[package]] name = "yarl" version = "1.9.4" @@ -1925,4 +2193,4 @@ dev = ["ubo-gui", "ubo-gui"] [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "5d2fff2ac70e67fe711cd33c266a0590f2e47c9f1216fc9dcb03a41087a30b40" +content-hash = "be9dae7f67dfe96d77fc08793f06b09da46224693b57e83db72e2a645d4e5e6c" diff --git a/pyproject.toml b/pyproject.toml index 707aeac8..423ff55c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "ubo-app" -version = "0.12.7" +version = "0.13.0" description = "Ubo main app, running on device initialization. A platform for running other apps." authors = ["Sassan Haradji "] license = "Apache-2.0" @@ -25,7 +25,7 @@ ubo-gui = [ "dev", ] }, ] -python-redux = "^0.14.1" +python-redux = "^0.14.3" pyzbar = "^0.1.9" sdbus-networkmanager = { version = "^2.0.0", markers = "platform_machine=='aarch64'" } rpi_ws281x = { version = "^5.0.0", markers = "platform_machine=='aarch64'" } @@ -40,13 +40,15 @@ docker = "^7.0.0" python-dotenv = "^1.0.1" sentry-sdk = "^1.43.0" pvorca = "^0.1.4" +platformdirs = "^4.2.0" +dill = "^0.3.8" [tool.poetry.group.dev] optional = true [tool.poetry.group.dev.dependencies] poethepoet = "^0.24.4" -pyright = "^1.1.359" +pyright = "^1.1.360" pytest = "^8.0.0" pytest-asyncio = "^0.23.5.post1" pytest-cov = "^4.1.0" @@ -57,6 +59,7 @@ tenacity = "^8.2.3" toml = "^0.10.2" pytest-mock = "^3.14.0" pyaudio = { version = "^0.2.14", markers = "platform_machine!='aarch64'" } +ipython = "^8.23.0" [tool.poetry.extras] default = ["ubo-gui"] @@ -105,8 +108,8 @@ inline-quotes = "single" multiline-quotes = "double" [tool.ruff.lint.per-file-ignores] -"tests/*" = ["S101", "PLR0913"] -"**/reducer.py" = ["C901", "PLR0912"] +"tests/*" = ["S101", "PLR0913", "PLR0915"] +"**/reducer.py" = ["C901", "PLR0912", "PLR0915"] "ubo_app/services/*/ubo_handle.py" = ["TCH004"] [tool.ruff.format] diff --git a/scripts/Dockerfile.dev b/scripts/Dockerfile.dev index d5c8ccc6..5468e877 100644 --- a/scripts/Dockerfile.dev +++ b/scripts/Dockerfile.dev @@ -2,7 +2,7 @@ FROM ubuntu:mantic ARG DEBIAN_FRONTEND=noninteractive RUN apt -y update -RUN apt -y install gcc curl git libcap-dev libegl1 libgl1 libmtdev1 libzbar0 python3 python3-dev +RUN apt -y install gcc curl git libcap-dev libegl1 libgl1 libmtdev1 libzbar0 python3 python3-dev file RUN curl -sSL https://install.python-poetry.org | python3 - ENV PATH="${PATH}:/root/.local/bin" WORKDIR /ubo-app diff --git a/tests/conftest.py b/tests/conftest.py index 4967b510..c5d91e71 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,8 +14,7 @@ pytest.register_assert_rewrite('tests.fixtures') -# isort: off -from tests.fixtures import ( # noqa: E402 +from tests.fixtures import ( # noqa: E402, I001 AppContext, LoadServices, Stability, @@ -36,7 +35,6 @@ store_snapshot, wait_for, ) -# isort: on fixtures = ( diff --git a/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-000.jsonc b/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-000.jsonc index 5e145813..49fa56ad 100644 --- a/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-000.jsonc +++ b/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-000.jsonc @@ -55,13 +55,12 @@ 1, 1 ], - "icon": "󰖩", + "icon": null, "is_short": false, - "label": "WiFi", + "label": "Connectivity", "sub_menu": { "items": [ { - "application": "ubo_app/services/030-wifi/pages/create_wireless_connection.py:CreateWirelessConnectionPage", "background_color": "#68B7FF", "color": [ 1, @@ -69,30 +68,100 @@ 1, 1 ], - "icon": "󱛃", + "icon": "󰖩", "is_short": false, - "label": "Add" - }, - { - "action": "", - "background_color": "#68B7FF", - "color": [ - 1, - 1, - 1, - 1 - ], - "icon": "󱖫", - "is_short": false, - "label": "Select" + "label": "WiFi", + "sub_menu": { + "items": [ + { + "application": "ubo_app/services/030-wifi/pages/create_wireless_connection.py:CreateWirelessConnectionPage", + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": "󱛃", + "is_short": false, + "label": "Add" + }, + { + "action": "", + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": "󱖫", + "is_short": false, + "label": "Select" + } + ], + "placeholder": null, + "title": "WiFi Settings" + } } ], - "placeholder": null, - "title": "WiFi Settings" + "placeholder": "No settings in this category", + "title": "Connectivity" + } + }, + { + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": null, + "is_short": false, + "label": "Interface", + "sub_menu": { + "items": [], + "placeholder": "No settings in this category", + "title": "Interface" + } + }, + { + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": null, + "is_short": false, + "label": "System", + "sub_menu": { + "items": [], + "placeholder": "No settings in this category", + "title": "System" + } + }, + { + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": null, + "is_short": false, + "label": "Apps", + "sub_menu": { + "items": [], + "placeholder": "No settings in this category", + "title": "Apps" } } ], - "placeholder": "No settings", + "placeholder": null, "title": "Settings" } }, @@ -159,7 +228,10 @@ }, "path": [ "Dashboard" - ] + ], + "settings_items_priorities": { + "WiFi": 2 + } }, "status_icons": { "icons": [ diff --git a/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-001.jsonc b/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-001.jsonc index cf80cd93..b9c16b95 100644 --- a/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-001.jsonc +++ b/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-001.jsonc @@ -55,13 +55,12 @@ 1, 1 ], - "icon": "󰖩", + "icon": null, "is_short": false, - "label": "WiFi", + "label": "Connectivity", "sub_menu": { "items": [ { - "application": "ubo_app/services/030-wifi/pages/create_wireless_connection.py:CreateWirelessConnectionPage", "background_color": "#68B7FF", "color": [ 1, @@ -69,30 +68,100 @@ 1, 1 ], - "icon": "󱛃", + "icon": "󰖩", "is_short": false, - "label": "Add" - }, - { - "action": "", - "background_color": "#68B7FF", - "color": [ - 1, - 1, - 1, - 1 - ], - "icon": "󱖫", - "is_short": false, - "label": "Select" + "label": "WiFi", + "sub_menu": { + "items": [ + { + "application": "ubo_app/services/030-wifi/pages/create_wireless_connection.py:CreateWirelessConnectionPage", + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": "󱛃", + "is_short": false, + "label": "Add" + }, + { + "action": "", + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": "󱖫", + "is_short": false, + "label": "Select" + } + ], + "placeholder": null, + "title": "WiFi Settings" + } } ], - "placeholder": null, - "title": "WiFi Settings" + "placeholder": "No settings in this category", + "title": "Connectivity" + } + }, + { + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": null, + "is_short": false, + "label": "Interface", + "sub_menu": { + "items": [], + "placeholder": "No settings in this category", + "title": "Interface" + } + }, + { + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": null, + "is_short": false, + "label": "System", + "sub_menu": { + "items": [], + "placeholder": "No settings in this category", + "title": "System" + } + }, + { + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": null, + "is_short": false, + "label": "Apps", + "sub_menu": { + "items": [], + "placeholder": "No settings in this category", + "title": "Apps" } } ], - "placeholder": "No settings", + "placeholder": null, "title": "Settings" } }, @@ -160,7 +229,10 @@ "path": [ "Dashboard", "Main" - ] + ], + "settings_items_priorities": { + "WiFi": 2 + } }, "status_icons": { "icons": [ diff --git a/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-002.jsonc b/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-002.jsonc index a16e50dd..1abe5ff6 100644 --- a/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-002.jsonc +++ b/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-002.jsonc @@ -55,13 +55,12 @@ 1, 1 ], - "icon": "󰖩", + "icon": null, "is_short": false, - "label": "WiFi", + "label": "Connectivity", "sub_menu": { "items": [ { - "application": "ubo_app/services/030-wifi/pages/create_wireless_connection.py:CreateWirelessConnectionPage", "background_color": "#68B7FF", "color": [ 1, @@ -69,30 +68,100 @@ 1, 1 ], - "icon": "󱛃", + "icon": "󰖩", "is_short": false, - "label": "Add" - }, - { - "action": "", - "background_color": "#68B7FF", - "color": [ - 1, - 1, - 1, - 1 - ], - "icon": "󱖫", - "is_short": false, - "label": "Select" + "label": "WiFi", + "sub_menu": { + "items": [ + { + "application": "ubo_app/services/030-wifi/pages/create_wireless_connection.py:CreateWirelessConnectionPage", + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": "󱛃", + "is_short": false, + "label": "Add" + }, + { + "action": "", + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": "󱖫", + "is_short": false, + "label": "Select" + } + ], + "placeholder": null, + "title": "WiFi Settings" + } } ], - "placeholder": null, - "title": "WiFi Settings" + "placeholder": "No settings in this category", + "title": "Connectivity" + } + }, + { + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": null, + "is_short": false, + "label": "Interface", + "sub_menu": { + "items": [], + "placeholder": "No settings in this category", + "title": "Interface" + } + }, + { + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": null, + "is_short": false, + "label": "System", + "sub_menu": { + "items": [], + "placeholder": "No settings in this category", + "title": "System" + } + }, + { + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": null, + "is_short": false, + "label": "Apps", + "sub_menu": { + "items": [], + "placeholder": "No settings in this category", + "title": "Apps" } } ], - "placeholder": "No settings", + "placeholder": null, "title": "Settings" } }, @@ -161,7 +230,10 @@ "Dashboard", "Main", "Settings" - ] + ], + "settings_items_priorities": { + "WiFi": 2 + } }, "status_icons": { "icons": [ diff --git a/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-003.jsonc b/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-003.jsonc index 268e4825..e52f1863 100644 --- a/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-003.jsonc +++ b/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-003.jsonc @@ -55,13 +55,12 @@ 1, 1 ], - "icon": "󰖩", + "icon": null, "is_short": false, - "label": "WiFi", + "label": "Connectivity", "sub_menu": { "items": [ { - "application": "ubo_app/services/030-wifi/pages/create_wireless_connection.py:CreateWirelessConnectionPage", "background_color": "#68B7FF", "color": [ 1, @@ -69,30 +68,100 @@ 1, 1 ], - "icon": "󱛃", + "icon": "󰖩", "is_short": false, - "label": "Add" - }, - { - "action": "", - "background_color": "#68B7FF", - "color": [ - 1, - 1, - 1, - 1 - ], - "icon": "󱖫", - "is_short": false, - "label": "Select" + "label": "WiFi", + "sub_menu": { + "items": [ + { + "application": "ubo_app/services/030-wifi/pages/create_wireless_connection.py:CreateWirelessConnectionPage", + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": "󱛃", + "is_short": false, + "label": "Add" + }, + { + "action": "", + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": "󱖫", + "is_short": false, + "label": "Select" + } + ], + "placeholder": null, + "title": "WiFi Settings" + } } ], - "placeholder": null, - "title": "WiFi Settings" + "placeholder": "No settings in this category", + "title": "Connectivity" + } + }, + { + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": null, + "is_short": false, + "label": "Interface", + "sub_menu": { + "items": [], + "placeholder": "No settings in this category", + "title": "Interface" + } + }, + { + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": null, + "is_short": false, + "label": "System", + "sub_menu": { + "items": [], + "placeholder": "No settings in this category", + "title": "System" + } + }, + { + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": null, + "is_short": false, + "label": "Apps", + "sub_menu": { + "items": [], + "placeholder": "No settings in this category", + "title": "Apps" } } ], - "placeholder": "No settings", + "placeholder": null, "title": "Settings" } }, @@ -161,8 +230,11 @@ "Dashboard", "Main", "Settings", - "WiFi Settings" - ] + "Connectivity" + ], + "settings_items_priorities": { + "WiFi": 2 + } }, "status_icons": { "icons": [ diff --git a/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-004.jsonc b/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-004.jsonc index 34656ab6..0ef5ee33 100644 --- a/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-004.jsonc +++ b/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-004.jsonc @@ -55,13 +55,12 @@ 1, 1 ], - "icon": "󰖩", + "icon": null, "is_short": false, - "label": "WiFi", + "label": "Connectivity", "sub_menu": { "items": [ { - "application": "ubo_app/services/030-wifi/pages/create_wireless_connection.py:CreateWirelessConnectionPage", "background_color": "#68B7FF", "color": [ 1, @@ -69,30 +68,100 @@ 1, 1 ], - "icon": "󱛃", + "icon": "󰖩", "is_short": false, - "label": "Add" - }, - { - "action": "", - "background_color": "#68B7FF", - "color": [ - 1, - 1, - 1, - 1 - ], - "icon": "󱖫", - "is_short": false, - "label": "Select" + "label": "WiFi", + "sub_menu": { + "items": [ + { + "application": "ubo_app/services/030-wifi/pages/create_wireless_connection.py:CreateWirelessConnectionPage", + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": "󱛃", + "is_short": false, + "label": "Add" + }, + { + "action": "", + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": "󱖫", + "is_short": false, + "label": "Select" + } + ], + "placeholder": null, + "title": "WiFi Settings" + } } ], - "placeholder": null, - "title": "WiFi Settings" + "placeholder": "No settings in this category", + "title": "Connectivity" + } + }, + { + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": null, + "is_short": false, + "label": "Interface", + "sub_menu": { + "items": [], + "placeholder": "No settings in this category", + "title": "Interface" + } + }, + { + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": null, + "is_short": false, + "label": "System", + "sub_menu": { + "items": [], + "placeholder": "No settings in this category", + "title": "System" + } + }, + { + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": null, + "is_short": false, + "label": "Apps", + "sub_menu": { + "items": [], + "placeholder": "No settings in this category", + "title": "Apps" } } ], - "placeholder": "No settings", + "placeholder": null, "title": "Settings" } }, @@ -161,9 +230,12 @@ "Dashboard", "Main", "Settings", - "WiFi Settings", - "Wi-Fi" - ] + "Connectivity", + "WiFi Settings" + ], + "settings_items_priorities": { + "WiFi": 2 + } }, "status_icons": { "icons": [ diff --git a/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-005.jsonc b/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-005.jsonc index f8b55d20..ed48df7e 100644 --- a/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-005.jsonc +++ b/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-005.jsonc @@ -55,13 +55,12 @@ 1, 1 ], - "icon": "󰖩", + "icon": null, "is_short": false, - "label": "WiFi", + "label": "Connectivity", "sub_menu": { "items": [ { - "application": "ubo_app/services/030-wifi/pages/create_wireless_connection.py:CreateWirelessConnectionPage", "background_color": "#68B7FF", "color": [ 1, @@ -69,30 +68,100 @@ 1, 1 ], - "icon": "󱛃", + "icon": "󰖩", "is_short": false, - "label": "Add" - }, - { - "action": "", - "background_color": "#68B7FF", - "color": [ - 1, - 1, - 1, - 1 - ], - "icon": "󱖫", - "is_short": false, - "label": "Select" + "label": "WiFi", + "sub_menu": { + "items": [ + { + "application": "ubo_app/services/030-wifi/pages/create_wireless_connection.py:CreateWirelessConnectionPage", + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": "󱛃", + "is_short": false, + "label": "Add" + }, + { + "action": "", + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": "󱖫", + "is_short": false, + "label": "Select" + } + ], + "placeholder": null, + "title": "WiFi Settings" + } } ], - "placeholder": null, - "title": "WiFi Settings" + "placeholder": "No settings in this category", + "title": "Connectivity" + } + }, + { + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": null, + "is_short": false, + "label": "Interface", + "sub_menu": { + "items": [], + "placeholder": "No settings in this category", + "title": "Interface" + } + }, + { + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": null, + "is_short": false, + "label": "System", + "sub_menu": { + "items": [], + "placeholder": "No settings in this category", + "title": "System" + } + }, + { + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": null, + "is_short": false, + "label": "Apps", + "sub_menu": { + "items": [], + "placeholder": "No settings in this category", + "title": "Apps" } } ], - "placeholder": "No settings", + "placeholder": null, "title": "Settings" } }, @@ -161,8 +230,13 @@ "Dashboard", "Main", "Settings", - "WiFi Settings" - ] + "Connectivity", + "WiFi Settings", + "Wi-Fi" + ], + "settings_items_priorities": { + "WiFi": 2 + } }, "status_icons": { "icons": [ diff --git a/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-006.jsonc b/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-006.jsonc index 0a5f1127..e6e4be4a 100644 --- a/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-006.jsonc +++ b/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-006.jsonc @@ -55,13 +55,12 @@ 1, 1 ], - "icon": "󰖩", + "icon": null, "is_short": false, - "label": "WiFi", + "label": "Connectivity", "sub_menu": { "items": [ { - "application": "ubo_app/services/030-wifi/pages/create_wireless_connection.py:CreateWirelessConnectionPage", "background_color": "#68B7FF", "color": [ 1, @@ -69,30 +68,100 @@ 1, 1 ], - "icon": "󱛃", + "icon": "󰖩", "is_short": false, - "label": "Add" - }, - { - "action": "", - "background_color": "#68B7FF", - "color": [ - 1, - 1, - 1, - 1 - ], - "icon": "󱖫", - "is_short": false, - "label": "Select" + "label": "WiFi", + "sub_menu": { + "items": [ + { + "application": "ubo_app/services/030-wifi/pages/create_wireless_connection.py:CreateWirelessConnectionPage", + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": "󱛃", + "is_short": false, + "label": "Add" + }, + { + "action": "", + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": "󱖫", + "is_short": false, + "label": "Select" + } + ], + "placeholder": null, + "title": "WiFi Settings" + } } ], - "placeholder": null, - "title": "WiFi Settings" + "placeholder": "No settings in this category", + "title": "Connectivity" + } + }, + { + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": null, + "is_short": false, + "label": "Interface", + "sub_menu": { + "items": [], + "placeholder": "No settings in this category", + "title": "Interface" + } + }, + { + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": null, + "is_short": false, + "label": "System", + "sub_menu": { + "items": [], + "placeholder": "No settings in this category", + "title": "System" + } + }, + { + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": null, + "is_short": false, + "label": "Apps", + "sub_menu": { + "items": [], + "placeholder": "No settings in this category", + "title": "Apps" } } ], - "placeholder": "No settings", + "placeholder": null, "title": "Settings" } }, @@ -161,9 +230,12 @@ "Dashboard", "Main", "Settings", - "WiFi Settings", - "85776e9add84f39e71545a137a1d5006" - ] + "Connectivity", + "WiFi Settings" + ], + "settings_items_priorities": { + "WiFi": 2 + } }, "status_icons": { "icons": [ diff --git a/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-007.jsonc b/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-007.jsonc index 60c45251..85b0dd8d 100644 --- a/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-007.jsonc +++ b/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-007.jsonc @@ -55,13 +55,12 @@ 1, 1 ], - "icon": "󰖩", + "icon": null, "is_short": false, - "label": "WiFi", + "label": "Connectivity", "sub_menu": { "items": [ { - "application": "ubo_app/services/030-wifi/pages/create_wireless_connection.py:CreateWirelessConnectionPage", "background_color": "#68B7FF", "color": [ 1, @@ -69,30 +68,100 @@ 1, 1 ], - "icon": "󱛃", + "icon": "󰖩", "is_short": false, - "label": "Add" - }, - { - "action": "", - "background_color": "#68B7FF", - "color": [ - 1, - 1, - 1, - 1 - ], - "icon": "󱖫", - "is_short": false, - "label": "Select" + "label": "WiFi", + "sub_menu": { + "items": [ + { + "application": "ubo_app/services/030-wifi/pages/create_wireless_connection.py:CreateWirelessConnectionPage", + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": "󱛃", + "is_short": false, + "label": "Add" + }, + { + "action": "", + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": "󱖫", + "is_short": false, + "label": "Select" + } + ], + "placeholder": null, + "title": "WiFi Settings" + } } ], - "placeholder": null, - "title": "WiFi Settings" + "placeholder": "No settings in this category", + "title": "Connectivity" + } + }, + { + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": null, + "is_short": false, + "label": "Interface", + "sub_menu": { + "items": [], + "placeholder": "No settings in this category", + "title": "Interface" + } + }, + { + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": null, + "is_short": false, + "label": "System", + "sub_menu": { + "items": [], + "placeholder": "No settings in this category", + "title": "System" + } + }, + { + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": null, + "is_short": false, + "label": "Apps", + "sub_menu": { + "items": [], + "placeholder": "No settings in this category", + "title": "Apps" } } ], - "placeholder": "No settings", + "placeholder": null, "title": "Settings" } }, @@ -161,9 +230,13 @@ "Dashboard", "Main", "Settings", + "Connectivity", "WiFi Settings", "85776e9add84f39e71545a137a1d5006" - ] + ], + "settings_items_priorities": { + "WiFi": 2 + } }, "status_icons": { "icons": [ diff --git a/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-008.jsonc b/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-008.jsonc new file mode 100644 index 00000000..02b8957b --- /dev/null +++ b/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-008.jsonc @@ -0,0 +1,262 @@ +// store-008 +{ + "_id": "e3e70682c2094cac629f6fbed82c07cd", + "main": { + "menu": { + "items": [ + { + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": "󰍜", + "is_short": true, + "label": "", + "sub_menu": { + "items": [ + { + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": "󰀻", + "is_short": false, + "label": "Apps", + "sub_menu": { + "items": [], + "placeholder": "No apps", + "title": "Apps" + } + }, + { + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": "", + "is_short": false, + "label": "Settings", + "sub_menu": { + "items": [ + { + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": null, + "is_short": false, + "label": "Connectivity", + "sub_menu": { + "items": [ + { + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": "󰖩", + "is_short": false, + "label": "WiFi", + "sub_menu": { + "items": [ + { + "application": "ubo_app/services/030-wifi/pages/create_wireless_connection.py:CreateWirelessConnectionPage", + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": "󱛃", + "is_short": false, + "label": "Add" + }, + { + "action": "", + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": "󱖫", + "is_short": false, + "label": "Select" + } + ], + "placeholder": null, + "title": "WiFi Settings" + } + } + ], + "placeholder": "No settings in this category", + "title": "Connectivity" + } + }, + { + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": null, + "is_short": false, + "label": "Interface", + "sub_menu": { + "items": [], + "placeholder": "No settings in this category", + "title": "Interface" + } + }, + { + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": null, + "is_short": false, + "label": "System", + "sub_menu": { + "items": [], + "placeholder": "No settings in this category", + "title": "System" + } + }, + { + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": null, + "is_short": false, + "label": "Apps", + "sub_menu": { + "items": [], + "placeholder": "No settings in this category", + "title": "Apps" + } + } + ], + "placeholder": null, + "title": "Settings" + } + }, + { + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": "", + "is_short": false, + "label": "About", + "sub_menu": { + "heading": "Ubo v0.0.0", + "items": [ + { + "background_color": "#03F7AE", + "color": "#000000", + "icon": "󰄬", + "is_short": false, + "label": "Already up to date!" + } + ], + "placeholder": null, + "sub_heading": "A universal dashboard for your Raspberry Pi", + "title": "About" + } + } + ], + "placeholder": null, + "title": "Main" + } + }, + { + "background_color": "#68B7FF", + "color": "white", + "icon": "", + "is_short": true, + "label": "", + "sub_menu": { + "items": [], + "placeholder": "No notifications", + "title": "Notifications (not loaded)" + } + }, + { + "action": "", + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": "󰐥", + "is_short": true, + "label": "Turn off" + } + ], + "placeholder": null, + "title": "Dashboard" + }, + "path": [ + "Dashboard", + "Main", + "Settings", + "Connectivity", + "WiFi Settings", + "85776e9add84f39e71545a137a1d5006" + ], + "settings_items_priorities": { + "WiFi": 2 + } + }, + "status_icons": { + "icons": [ + { + "color": "white", + "id": "wifi:state", + "priority": -12, + "symbol": "󰖪" + } + ] + }, + "update_manager": { + "current_version": "0.0.0", + "latest_version": "0.0.0", + "serial_number": "", + "update_status": "up_to_date" + }, + "wifi": { + "connections": [], + "current_connection": null, + "state": "Disconnected" + } +} diff --git a/tests/end_to_end/results/test_wireless_flow/wireless_flow/window-002.hash b/tests/end_to_end/results/test_wireless_flow/wireless_flow/window-002.hash index 8d2ee21b..c9d57b13 100644 --- a/tests/end_to_end/results/test_wireless_flow/wireless_flow/window-002.hash +++ b/tests/end_to_end/results/test_wireless_flow/wireless_flow/window-002.hash @@ -1,2 +1,2 @@ // window-002 -55982f9969730241dc5001a4864ad62631f1ac54bf78bef17d221912595d0242 +0e93054205e42c551c706d9311ca578dc413eb8e3c1fa0a41d5acf15cba69fdf diff --git a/tests/end_to_end/results/test_wireless_flow/wireless_flow/window-003.hash b/tests/end_to_end/results/test_wireless_flow/wireless_flow/window-003.hash index 02f73c58..4d96c4c8 100644 --- a/tests/end_to_end/results/test_wireless_flow/wireless_flow/window-003.hash +++ b/tests/end_to_end/results/test_wireless_flow/wireless_flow/window-003.hash @@ -1,2 +1,2 @@ // window-003 -bbfea95ebf28a70c7d6c63d3a770385f89914ec36df8bac9cb01cd6e7ace75ed +e79efabd9138a2c428d7ffc00f25fc86f7ca72cdb419149d37efd7199fa157bb diff --git a/tests/end_to_end/results/test_wireless_flow/wireless_flow/window-004.hash b/tests/end_to_end/results/test_wireless_flow/wireless_flow/window-004.hash index 78a49e9c..7403e458 100644 --- a/tests/end_to_end/results/test_wireless_flow/wireless_flow/window-004.hash +++ b/tests/end_to_end/results/test_wireless_flow/wireless_flow/window-004.hash @@ -1,2 +1,2 @@ // window-004 -bb31ce816b7ac09784dd3acc509c9e830e6b21f125dece9c6679eadcc5501f18 +bbfea95ebf28a70c7d6c63d3a770385f89914ec36df8bac9cb01cd6e7ace75ed diff --git a/tests/end_to_end/results/test_wireless_flow/wireless_flow/window-005.hash b/tests/end_to_end/results/test_wireless_flow/wireless_flow/window-005.hash index 07eb3bd7..037f1d9b 100644 --- a/tests/end_to_end/results/test_wireless_flow/wireless_flow/window-005.hash +++ b/tests/end_to_end/results/test_wireless_flow/wireless_flow/window-005.hash @@ -1,2 +1,2 @@ // window-005 -bbfea95ebf28a70c7d6c63d3a770385f89914ec36df8bac9cb01cd6e7ace75ed +bb31ce816b7ac09784dd3acc509c9e830e6b21f125dece9c6679eadcc5501f18 diff --git a/tests/end_to_end/results/test_wireless_flow/wireless_flow/window-006.hash b/tests/end_to_end/results/test_wireless_flow/wireless_flow/window-006.hash index 552e9261..edd9a673 100644 --- a/tests/end_to_end/results/test_wireless_flow/wireless_flow/window-006.hash +++ b/tests/end_to_end/results/test_wireless_flow/wireless_flow/window-006.hash @@ -1,2 +1,2 @@ // window-006 -8393c183dce79e99979df40a76f2caae137ec9400ec79c6b92e19daf869acbe2 +bbfea95ebf28a70c7d6c63d3a770385f89914ec36df8bac9cb01cd6e7ace75ed diff --git a/tests/end_to_end/results/test_wireless_flow/wireless_flow/window-008.hash b/tests/end_to_end/results/test_wireless_flow/wireless_flow/window-008.hash new file mode 100644 index 00000000..8dd70d85 --- /dev/null +++ b/tests/end_to_end/results/test_wireless_flow/wireless_flow/window-008.hash @@ -0,0 +1,2 @@ +// window-008 +8393c183dce79e99979df40a76f2caae137ec9400ec79c6b92e19daf869acbe2 diff --git a/tests/end_to_end/test_wireless_flow.py b/tests/end_to_end/test_wireless_flow.py index 4429fb04..a2364492 100644 --- a/tests/end_to_end/test_wireless_flow.py +++ b/tests/end_to_end/test_wireless_flow.py @@ -62,6 +62,12 @@ def check_icon() -> None: window_snapshot.take() store_snapshot.take() + # Go to connectivity category + dispatch(KeypadKeyPressAction(key=Key.L1)) + await stability() + window_snapshot.take() + store_snapshot.take() + # Press the L1 key to open the wireless menu dispatch(KeypadKeyPressAction(key=Key.L1)) await stability() diff --git a/tests/fixtures/app.py b/tests/fixtures/app.py index d1d86bb6..130f1c14 100644 --- a/tests/fixtures/app.py +++ b/tests/fixtures/app.py @@ -103,6 +103,28 @@ async def app_context(request: SubRequest) -> AsyncGenerator[AppContext, None]: setup() + from kivy.lang.builder import Builder + + Builder.load_string(""" +<-Slider>: + canvas: + Color: + rgb: 0.6, 0.1, 0.1 + Rectangle: + pos: (self.x, self.center_y - self.background_width/6) if self.orientation \ +== 'horizontal' else (self.center_x - self.background_width/6, self.y) + size: (self.width, self.background_width/3) if self.orientation == 'horizon\ +tal' else (self.background_width/3, self.height) + Color: + rgb: 0.8, 0.3, 0.3 + RoundedRectangle: + pos: (root.value_pos[0] - root.cursor_width*0.4, root.center_y - root.curso\ +r_height*0.4) if root.orientation == 'horizontal' else (root.center_x - root.cursor_wid\ +th*0.4, root.value_pos[1] - root.cursor_height*0.4) + size: root.cursor_size[0] * 0.8, root.cursor_size[1] * 0.8 + radius: root.cursor_size[0] * 0.4, root.cursor_size[1] * 0.4 + """) + import headless_kivy_pi.config headless_kivy_pi.config.setup_headless_kivy({'automatic_fps': True}) diff --git a/tests/fixtures/snapshot.py b/tests/fixtures/snapshot.py index 463a216b..6fd90e2f 100644 --- a/tests/fixtures/snapshot.py +++ b/tests/fixtures/snapshot.py @@ -43,6 +43,7 @@ def __init__( make_screenshots: bool, ) -> None: """Create a new window snapshot context.""" + self.failed = False self.closed = False self.override = override self.make_screenshots = make_screenshots @@ -111,6 +112,7 @@ def take(self: WindowSnapshot, title: str | None = None) -> None: else: old_snapshot = None if old_snapshot != new_snapshot: + self.failed = True hash_mismatch_path.write_text( # pragma: no cover f'// MISMATCH: {filename}\n{new_snapshot}\n', ) @@ -129,12 +131,14 @@ def take(self: WindowSnapshot, title: str | None = None) -> None: def close(self: WindowSnapshot) -> None: """Close the snapshot context.""" + self.closed = True + if self.failed: + return 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() diff --git a/tests/integration/results/test_core/app_runs_and_exits/store-000.jsonc b/tests/integration/results/test_core/app_runs_and_exits/store-000.jsonc index 3b4cec26..5504ca5c 100644 --- a/tests/integration/results/test_core/app_runs_and_exits/store-000.jsonc +++ b/tests/integration/results/test_core/app_runs_and_exits/store-000.jsonc @@ -46,8 +46,77 @@ "is_short": false, "label": "Settings", "sub_menu": { - "items": [], - "placeholder": "No settings", + "items": [ + { + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": null, + "is_short": false, + "label": "Connectivity", + "sub_menu": { + "items": [], + "placeholder": "No settings in this category", + "title": "Connectivity" + } + }, + { + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": null, + "is_short": false, + "label": "Interface", + "sub_menu": { + "items": [], + "placeholder": "No settings in this category", + "title": "Interface" + } + }, + { + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": null, + "is_short": false, + "label": "System", + "sub_menu": { + "items": [], + "placeholder": "No settings in this category", + "title": "System" + } + }, + { + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": null, + "is_short": false, + "label": "Apps", + "sub_menu": { + "items": [], + "placeholder": "No settings in this category", + "title": "Apps" + } + } + ], + "placeholder": null, "title": "Settings" } }, @@ -114,7 +183,8 @@ }, "path": [ "Dashboard" - ] + ], + "settings_items_priorities": {} }, "status_icons": { "icons": [] diff --git a/tests/integration/results/test_services/all_services_register/store-000.jsonc b/tests/integration/results/test_services/all_services_register/store-000.jsonc index e5f9a16d..1c58e7e4 100644 --- a/tests/integration/results/test_services/all_services_register/store-000.jsonc +++ b/tests/integration/results/test_services/all_services_register/store-000.jsonc @@ -11,96 +11,76 @@ "home_assistant": { "container_ip": null, "docker_id": null, - "icon": "󰟐", "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": "󰘘", "id": "home_bridge", "ip_addresses": [ "192.168.1.1" ], - "label": "Home Bridge", - "path": "homebridge/homebridge:latest", "ports": [], "status": "not_available" }, "ngrok": { "container_ip": null, "docker_id": null, - "icon": "󰛶", "id": "ngrok", "ip_addresses": [ "192.168.1.1" ], - "label": "Ngrok", - "path": "ngrok/ngrok:latest", "ports": [], "status": "not_available" }, "ollama": { "container_ip": null, "docker_id": null, - "icon": "󰳆", "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": "󰾔", "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": "󰇖", "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": "", "id": "portainer", "ip_addresses": [ "192.168.1.1" ], - "label": "Portainer", - "path": "portainer/portainer-ce:latest", "ports": [], "status": "not_available" }, "service": { - "status": "not_running" + "status": "not_running", + "usernames": {} } }, "ip": { @@ -185,9 +165,9 @@ 1, 1 ], - "icon": "󰩟", + "icon": null, "is_short": false, - "label": "IP Addresses", + "label": "Connectivity", "sub_menu": { "items": [ { @@ -198,9 +178,66 @@ 1, 1 ], - "icon": "󰈀", + "icon": "󰖩", + "is_short": false, + "label": "WiFi", + "sub_menu": { + "items": [ + { + "application": "ubo_app/services/030-wifi/pages/create_wireless_connection.py:CreateWirelessConnectionPage", + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": "󱛃", + "is_short": false, + "label": "Add" + }, + { + "action": "", + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": "󱖫", + "is_short": false, + "label": "Select" + } + ], + "placeholder": null, + "title": "WiFi Settings" + } + }, + { + "action": "", + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": "[color=#008000]󰪥[/color]", + "is_short": false, + "label": "SSH" + }, + { + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": "󰩟", "is_short": false, - "label": "eth0", + "label": "IP Addresses", "sub_menu": { "items": [ { @@ -211,22 +248,39 @@ 1, 1 ], - "icon": "󰩠", + "icon": "󰈀", "is_short": false, - "label": "192.168.1.1" + "label": "eth0", + "sub_menu": { + "items": [ + { + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": "󰩠", + "is_short": false, + "label": "192.168.1.1" + } + ], + "placeholder": "No IP addresses", + "title": "IP Addresses - eth0" + } } ], "placeholder": "No IP addresses", - "title": "IP Addresses - eth0" + "title": "IP Addresses" } } ], - "placeholder": "No IP addresses", - "title": "IP Addresses" + "placeholder": "No settings in this category", + "title": "Connectivity" } }, { - "action": "", "background_color": "#68B7FF", "color": [ 1, @@ -234,22 +288,61 @@ 1, 1 ], - "icon": "[color=#008000]󰪥[/color]", - "is_short": false, - "label": "LightDM" - }, - { - "action": "", - "background_color": "#68B7FF", - "color": [ - 1, - 1, - 1, - 1 - ], - "icon": "[color=#008000]󰪥[/color]", + "icon": null, "is_short": false, - "label": "SSH" + "label": "Interface", + "sub_menu": { + "items": [ + { + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": "󰔊", + "is_short": false, + "label": "Voice", + "sub_menu": { + "heading": "󰔊 Picovoice", + "items": [ + { + "action": "", + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": "󰐲", + "is_short": false, + "label": "Set Access Key" + }, + { + "action": "", + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": "󰌊", + "is_short": false, + "label": "Clear Access Key" + } + ], + "placeholder": null, + "sub_heading": "Set the access key\n Current value: Fake", + "title": "Voice Settings" + } + } + ], + "placeholder": "No settings in this category", + "title": "Interface" + } }, { "background_color": "#68B7FF", @@ -261,9 +354,8 @@ ], "icon": null, "is_short": false, - "label": "Voice", + "label": "System", "sub_menu": { - "heading": "󰔊", "items": [ { "action": "", @@ -274,14 +366,13 @@ 1, 1 ], - "icon": "󰐲", + "icon": "[color=#008000]󰪥[/color]", "is_short": false, - "label": "Access Token" + "label": "LightDM" } ], - "placeholder": null, - "sub_heading": "Set the access token for picovoice service", - "title": "Voice Settings" + "placeholder": "No settings in this category", + "title": "System" } }, { @@ -292,26 +383,12 @@ 1, 1 ], - "icon": "󰖩", + "icon": null, "is_short": false, - "label": "WiFi", + "label": "Apps", "sub_menu": { "items": [ { - "application": "ubo_app/services/030-wifi/pages/create_wireless_connection.py:CreateWirelessConnectionPage", - "background_color": "#68B7FF", - "color": [ - 1, - 1, - 1, - 1 - ], - "icon": "󱛃", - "is_short": false, - "label": "Add" - }, - { - "action": "", "background_color": "#68B7FF", "color": [ 1, @@ -319,17 +396,38 @@ 1, 1 ], - "icon": "󱖫", + "icon": "󰡨", "is_short": false, - "label": "Select" + "label": "Docker", + "sub_menu": { + "heading": "󰡨 Docker", + "items": [ + { + "action": "", + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": "󰐲", + "is_short": false, + "label": "Set Access Key" + } + ], + "placeholder": null, + "sub_heading": "Login a registry:", + "title": "Docker Settings" + } } ], - "placeholder": null, - "title": "WiFi Settings" + "placeholder": "No settings in this category", + "title": "Apps" } } ], - "placeholder": "No settings", + "placeholder": null, "title": "Settings" } }, @@ -396,7 +494,15 @@ }, "path": [ "Dashboard" - ] + ], + "settings_items_priorities": { + "Docker": null, + "IP Addresses": 0, + "LightDM": 0, + "SSH": 1, + "Voice": 0, + "WiFi": 2 + } }, "notifications": { "notifications": [], diff --git a/tests/monkeypatch.py b/tests/monkeypatch.py index 82e92cb7..4e1b870e 100644 --- a/tests/monkeypatch.py +++ b/tests/monkeypatch.py @@ -7,7 +7,7 @@ import random import sys import tracemalloc -from typing import cast +from typing import Any, cast import pytest @@ -163,6 +163,8 @@ def _monkeypatch_subprocess(monkeypatch: pytest.MonkeyPatch) -> None: from ubo_app.utils.fake import Fake + original_subprocess_run = subprocess.run + def fake_subprocess_run( command: list[str], *args: object, @@ -171,6 +173,8 @@ def fake_subprocess_run( _ = args, kwargs if command == ['/usr/bin/env', 'systemctl', 'poweroff', '-i']: return Fake() + if command[0] in ('cat', 'file'): + return original_subprocess_run(command, *cast(Any, args), **kwargs) msg = f'Unexpected `subprocess.run` command in test environment: {command}' raise ValueError(msg) @@ -230,7 +234,19 @@ def _monkeypatch(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr('importlib.metadata.version', lambda _: '0.0.0') monkeypatch.setattr('ubo_app.constants.STORE_GRACE_TIME', 0.1) - + monkeypatch.setattr('ubo_app.utils.serializer.add_type_field', lambda _, y: y) + + sys.modules['ubo_app.utils.persistent_store'] = Fake( + _Fake__props={ + 'read_from_persistent_store': lambda key=None, + default=None, + object_type=None: ( + key, + default or (object_type() if object_type else None), + )[-1], + }, + ) + sys.modules['ubo_app.utils.secrets'] = Fake() sys.modules['pyaudio'] = Fake() _monkeypatch_socket(monkeypatch) diff --git a/ubo_app/constants.py b/ubo_app/constants.py index 170a3414..08c58f7c 100644 --- a/ubo_app/constants.py +++ b/ubo_app/constants.py @@ -6,6 +6,7 @@ from pathlib import Path import dotenv +import platformdirs dotenv.load_dotenv(Path(__file__).parent / '.env') @@ -28,8 +29,13 @@ # each time a UUID is generated. DEBUG_MODE_TEST_UUID = strtobool(os.environ.get('UBO_DEBUG_TEST_UUID', 'False')) == 1 -PICOVOICE_API_KEY = os.environ.get('PICOVOICE_API_KEY', None) +PICOVOICE_ACCESS_KEY = 'PICOVOICE_ACCESS_KEY' +DOCKER_CREDENTIALS_TEMPLATE = 'DOCKER_CREDENTIALS_{}' DEBUG_MODE_DOCKER = strtobool(os.environ.get('UBO_DEBUG_DOCKER', 'False')) == 1 DOCKER_PREFIX = os.environ.get('UBO_DOCKER_PREFIX', '') DOCKER_INSTALLATION_LOCK_FILE = Path('/var/run/ubo/docker_installation.lock') + +CONFIG_PATH = platformdirs.user_config_path(appname='ubo', ensure_exists=True) +SECRETS_PATH = CONFIG_PATH / '.secrets.env' +PERSISTENT_STORE_PATH = CONFIG_PATH / 'state.json' diff --git a/ubo_app/load_services.py b/ubo_app/load_services.py index 50b799d5..5c4b5290 100644 --- a/ubo_app/load_services.py +++ b/ubo_app/load_services.py @@ -147,7 +147,7 @@ def __init__( def register_reducer(self: UboServiceThread, reducer: ReducerType) -> None: from ubo_app import store - logger.info( + logger.debug( 'Registering ubo service reducer', extra={ 'service_id': self.service_id, @@ -197,7 +197,7 @@ def register( self.service_id = service_id self.setup = setup - logger.info( + logger.debug( 'Ubo service registered!', extra={ 'service_id': self.service_id, diff --git a/ubo_app/menu_app/menu_central.py b/ubo_app/menu_app/menu_central.py index dded5bbc..a4d3ade0 100644 --- a/ubo_app/menu_app/menu_central.py +++ b/ubo_app/menu_app/menu_central.py @@ -2,6 +2,7 @@ from __future__ import annotations import pathlib +import re import weakref from functools import cached_property from typing import TYPE_CHECKING @@ -119,7 +120,13 @@ def display_notification( color=notification.color, has_extra_information=notification.extra_information is not None, ) - info_application = NotificationInfo(text=notification.extra_information or '') + info_application = NotificationInfo( + text=re.sub( + r'\{[^{}|]*\|[^{}|]*\}', + lambda x: x.group()[1:].split('|')[0], + notification.extra_information or '', + ), + ) application.bind( on_dismiss=lambda _: ( diff --git a/ubo_app/services/000-sound/reducer.py b/ubo_app/services/000-sound/reducer.py index 1fef24d2..57f149c8 100644 --- a/ubo_app/services/000-sound/reducer.py +++ b/ubo_app/services/000-sound/reducer.py @@ -37,12 +37,7 @@ def reducer( ) -> ReducerResult[SoundState, Action, SoundEvent]: if state is None: if isinstance(action, InitAction): - return SoundState( - playback_volume=0.5, - is_playback_mute=False, - capture_volume=0.5, - is_capture_mute=False, - ) + return SoundState() raise InitializationActionError(action) if isinstance(action, SoundSetVolumeAction): diff --git a/ubo_app/services/000-sound/setup.py b/ubo_app/services/000-sound/setup.py index 2be363a3..058dc5e8 100644 --- a/ubo_app/services/000-sound/setup.py +++ b/ubo_app/services/000-sound/setup.py @@ -9,11 +9,17 @@ from ubo_app.store import autorun, dispatch, subscribe_event from ubo_app.store.services.sound import SoundPlayAudioEvent, SoundPlayChimeEvent from ubo_app.store.status_icons import StatusIconsRegisterAction +from ubo_app.utils.persistent_store import register_persistent_store def init_service() -> None: audio_manager = AudioManager() + register_persistent_store( + 'sound_playback_volume', + lambda state: state.sound.playback_volume, + ) + dispatch( StatusIconsRegisterAction( icon='󰍭', diff --git a/ubo_app/services/020-keyboard/setup.py b/ubo_app/services/020-keyboard/setup.py index 69073fe4..6e9706b1 100644 --- a/ubo_app/services/020-keyboard/setup.py +++ b/ubo_app/services/020-keyboard/setup.py @@ -6,7 +6,7 @@ from kivy.core.window import Keyboard, Window, WindowBase from redux import FinishAction, FinishEvent -from ubo_app.store import ScreenshotEvent, subscribe_event +from ubo_app.store import ScreenshotEvent, SnapshotEvent, subscribe_event from ubo_app.store.services.keypad import Key, KeypadKeyPressAction from ubo_app.store.services.sound import SoundDevice, SoundToggleMuteStatusAction @@ -52,6 +52,8 @@ def on_keyboard( # noqa: C901 ) elif key == Keyboard.keycodes['p']: dispatch(ScreenshotEvent()) + elif key == Keyboard.keycodes['s']: + dispatch(SnapshotEvent()) elif key == Keyboard.keycodes['q']: dispatch(FinishAction()) diff --git a/ubo_app/services/030-ip/setup.py b/ubo_app/services/030-ip/setup.py index 9537788d..47810181 100644 --- a/ubo_app/services/030-ip/setup.py +++ b/ubo_app/services/030-ip/setup.py @@ -11,7 +11,7 @@ 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 +from ubo_app.store.main import RegisterSettingAppAction, SettingsCategory from ubo_app.store.services.ip import ( IpNetworkInterface, IpUpdateAction, @@ -114,7 +114,13 @@ async def check_connection() -> bool: async def init_service() -> None: - dispatch(RegisterSettingAppAction(menu_item=IpMainMenu)) + dispatch( + RegisterSettingAppAction( + priority=0, + category=SettingsCategory.CONNECTIVITY, + menu_item=IpMainMenu, + ), + ) subscribe_event( IpUpdateRequestEvent, diff --git a/ubo_app/services/030-wifi/setup.py b/ubo_app/services/030-wifi/setup.py index cf8987b9..88fe7741 100644 --- a/ubo_app/services/030-wifi/setup.py +++ b/ubo_app/services/030-wifi/setup.py @@ -10,7 +10,7 @@ ) from ubo_app.store import dispatch, subscribe_event -from ubo_app.store.main import RegisterSettingAppAction +from ubo_app.store.main import RegisterSettingAppAction, SettingsCategory from ubo_app.store.services.wifi import ( ConnectionState, WiFiUpdateAction, @@ -57,6 +57,12 @@ def init_service() -> None: create_task(update_wifi_list()) create_task(setup_listeners()) - dispatch(RegisterSettingAppAction(menu_item=main.WiFiMainMenu)) + dispatch( + RegisterSettingAppAction( + priority=2, + category=SettingsCategory.CONNECTIVITY, + menu_item=main.WiFiMainMenu, + ), + ) subscribe_event(WiFiUpdateRequestEvent, lambda: create_task(request_scan())) diff --git a/ubo_app/services/040-camera/setup.py b/ubo_app/services/040-camera/setup.py index b582a242..02f28cf1 100644 --- a/ubo_app/services/040-camera/setup.py +++ b/ubo_app/services/040-camera/setup.py @@ -63,10 +63,15 @@ async def provide() -> None: await check_codes([data]) def run_provider() -> None: + from kivy.core.window import Window + + Window.opacity = 0.2 + def set_task(task: asyncio.Task) -> None: def stop() -> None: task.cancel() cancel_subscription() + Window.opacity = 1 cancel_subscription = subscribe_event( CameraStopViewfinderEvent, diff --git a/ubo_app/services/050-lightdm/setup.py b/ubo_app/services/050-lightdm/setup.py index c5ec0e80..e21d64a4 100644 --- a/ubo_app/services/050-lightdm/setup.py +++ b/ubo_app/services/050-lightdm/setup.py @@ -8,7 +8,7 @@ from ubo_gui.menu.types import ActionItem, HeadlessMenu, Item, Menu from ubo_app.store import autorun, dispatch -from ubo_app.store.main import RegisterSettingAppAction +from ubo_app.store.main import RegisterSettingAppAction, SettingsCategory from ubo_app.store.services.lightdm import ( LightDMClearEnabledStateAction, LightDMUpdateStateAction, @@ -140,6 +140,8 @@ def init_service() -> None: """Initialize the LightDM service.""" dispatch( RegisterSettingAppAction( + priority=0, + category=SettingsCategory.SYSTEM, menu_item=ActionItem( label='LightDM', icon=lightdm_icon, diff --git a/ubo_app/services/050-ssh/setup.py b/ubo_app/services/050-ssh/setup.py index a6b66957..99737526 100644 --- a/ubo_app/services/050-ssh/setup.py +++ b/ubo_app/services/050-ssh/setup.py @@ -18,7 +18,7 @@ from ubo_gui.prompt import PromptWidget from ubo_app.store import autorun, dispatch -from ubo_app.store.main import RegisterSettingAppAction +from ubo_app.store.main import RegisterSettingAppAction, SettingsCategory from ubo_app.store.services.notifications import ( Importance, Notification, @@ -95,9 +95,6 @@ async def act() -> None: icon='', display_type=NotificationDisplayType.STICKY, color=DANGER_COLOR, - extra_information='Note that in order to make ssh work, we had \ -to make sure password authentication for ssh server is enabled, you may want to \ -disable it later.', ), ), ) @@ -113,8 +110,8 @@ async def act() -> None: icon='', display_type=NotificationDisplayType.STICKY, extra_information='Note that in order to make things work for you, \ -we had to make sure password authentication for ssh server is enabled, you may want to \ -disable it later.', +we had to make sure password authentication for {ssh|EH S EH S EY CH} server is \ +enabled, you may want to disable it later.', color=SUCCESS_COLOR, ), ), @@ -262,6 +259,8 @@ def init_service() -> None: """Initialize the SSH service.""" dispatch( RegisterSettingAppAction( + priority=1, + category=SettingsCategory.CONNECTIVITY, menu_item=ActionItem( label='SSH', icon=ssh_icon, diff --git a/ubo_app/services/080-docker/image.py b/ubo_app/services/080-docker/image.py index 968b5bd1..8095f376 100644 --- a/ubo_app/services/080-docker/image.py +++ b/ubo_app/services/080-docker/image.py @@ -18,6 +18,7 @@ from ubo_gui.menu.types import ActionItem, HeadedMenu, HeadlessMenu, Item, SubMenuItem from ubo_gui.page import PageWidget +from ubo_app.constants import DOCKER_CREDENTIALS_TEMPLATE from ubo_app.logging import logger from ubo_app.store import autorun, dispatch, subscribe_event from ubo_app.store.services.docker import ( @@ -32,6 +33,7 @@ Notification, NotificationsAddAction, ) +from ubo_app.utils import secrets from ubo_app.utils.async_ import create_task, to_thread if TYPE_CHECKING: @@ -218,38 +220,51 @@ def get_docker_id(docker_id: str) -> str: to_thread(act) -def _fetch_image(image: ImageState) -> None: - def act() -> None: - dispatch( - DockerImageSetStatusAction( - image=image.id, - status=ImageStatus.FETCHING, - ), - ) - try: - logger.debug('Fetching image', extra={'image': image.path}) - docker_client = docker.from_env() - docker_client.images.pull(image.path) - docker_client.close() - except docker.errors.DockerException: - logger.exception( - 'Image error', - extra={'image': image.path}, - ) +@autorun(lambda state: state.docker.service.usernames) +def _reactive_fetch_image(usernames: dict[str, str]) -> Callable[[ImageState], None]: + def fetch_image(image: ImageState) -> None: + def act() -> None: dispatch( DockerImageSetStatusAction( image=image.id, - status=ImageStatus.ERROR, + status=ImageStatus.FETCHING, ), ) + try: + logger.debug('Fetching image', extra={'image': IMAGES[image.id].path}) + docker_client = docker.from_env() + for registry, username in usernames.items(): + if IMAGES[image.id].registry == registry: + docker_client.login( + username=username, + password=secrets.read_secret( + DOCKER_CREDENTIALS_TEMPLATE.format(registry), + ), + registry=registry, + ) + docker_client.images.pull(IMAGES[image.id].path) + docker_client.close() + except docker.errors.DockerException: + logger.exception( + 'Image error', + extra={'image': IMAGES[image.id].path}, + ) + dispatch( + DockerImageSetStatusAction( + image=image.id, + status=ImageStatus.ERROR, + ), + ) - to_thread(act) + to_thread(act) + + return fetch_image def _remove_image(image: ImageState) -> None: def act() -> None: docker_client = docker.from_env() - docker_client.images.remove(image.path, force=True) + docker_client.images.remove(IMAGES[image.id].path, force=True) docker_client.close() to_thread(act) @@ -300,7 +315,7 @@ def _run_container_generator(docker_state: DockerState) -> Callable[[ImageState] def run_container(image: ImageState) -> None: async def act() -> None: docker_client = docker.from_env() - container = find_container(docker_client, image=image.path) + container = find_container(docker_client, image=IMAGES[image.id].path) if container: if container.status != 'running': container.start() @@ -336,7 +351,7 @@ async def act() -> None: hosts[key] = value docker_client.containers.run( - image.path, + IMAGES[image.id].path, hostname=image.id, publish_all_ports=True, detach=True, @@ -358,7 +373,7 @@ async def act() -> None: def _stop_container(image: ImageState) -> None: def act() -> None: docker_client = docker.from_env() - container = find_container(docker_client, image=image.path) + container = find_container(docker_client, image=IMAGES[image.id].path) if container and container.status != 'exited': container.stop() docker_client.close() @@ -369,7 +384,7 @@ def act() -> None: def _remove_container(image: ImageState) -> None: def act() -> None: docker_client = docker.from_env() - container = find_container(docker_client, image=image.path) + container = find_container(docker_client, image=IMAGES[image.id].path) if container: container.remove(v=True, force=True) docker_client.close() @@ -412,7 +427,7 @@ def action() -> PageWidget: ActionItem( label='Fetch', icon='󰇚', - action=lambda: _fetch_image(image), + action=lambda: _reactive_fetch_image()(image), ), ) elif image.status == ImageStatus.FETCHING: @@ -477,8 +492,8 @@ def action() -> PageWidget: ) return HeadedMenu( - title=f'Docker - {image.label}', - heading=image.label, + title=f'Docker - {IMAGES[image.id].label}', + heading=IMAGES[image.id].label, sub_heading={ ImageStatus.NOT_AVAILABLE: 'Image needs to be fetched', ImageStatus.FETCHING: 'Image is being fetched', diff --git a/ubo_app/services/080-docker/reducer.py b/ubo_app/services/080-docker/reducer.py index e98e98b5..6220dd24 100644 --- a/ubo_app/services/080-docker/reducer.py +++ b/ubo_app/services/080-docker/reducer.py @@ -23,9 +23,11 @@ DockerImageEvent, DockerImageSetDockerIdAction, DockerImageSetStatusAction, + DockerRemoveUsernameAction, DockerServiceState, DockerSetStatusAction, DockerState, + DockerStoreUsernameAction, ImageState, ) from ubo_app.store.services.ip import IpUpdateAction @@ -50,6 +52,22 @@ def service_reducer( if isinstance(action, DockerSetStatusAction): return replace(state, status=action.status) + if isinstance(action, DockerStoreUsernameAction): + return replace( + state, + usernames={**state.usernames, action.registry: action.username}, + ) + + if isinstance(action, DockerRemoveUsernameAction): + return replace( + state, + usernames={ + registry: username + for registry, username in state.usernames.items() + if registry != action.registry + }, + ) + return state @@ -60,6 +78,7 @@ class ImageEntry(Immutable): label: str icon: str path: str + registry: str dependencies: list[str] | None = None ports: dict[str, str] = field(default_factory=dict) hosts: dict[str, str] = field(default_factory=dict) @@ -91,6 +110,7 @@ class ImageEntry(Immutable): label='Home Assistant', icon='󰟐', path=DOCKER_PREFIX + 'homeassistant/home-assistant:stable', + registry='docker.io', ports={'8123/tcp': '8123'}, ), ImageEntry( @@ -98,12 +118,14 @@ class ImageEntry(Immutable): label='Home Bridge', icon='󰘘', path=DOCKER_PREFIX + 'homebridge/homebridge:latest', + registry='docker.io', ), ImageEntry( id='portainer', label='Portainer', icon='', path=DOCKER_PREFIX + 'portainer/portainer-ce:latest', + registry='docker.io', volumes=['/var/run/docker.sock:/var/run/docker.sock'], ), ImageEntry( @@ -113,19 +135,22 @@ class ImageEntry(Immutable): environment_vairables={'WEBPASSWORD': 'admin'}, note='Password: admin', path=DOCKER_PREFIX + 'pihole/pihole:latest', + registry='docker.io', ), ImageEntry( id='ollama', label='Ollama', icon='󰳆', path=DOCKER_PREFIX + 'ollama/ollama:latest', + registry='docker.io', ports={'11434/tcp': '11434'}, ), ImageEntry( id='open_webui', label='Open WebUI', icon='󰾔', - path=DOCKER_PREFIX + 'ghcr.io/open-webui/open-webui:main', + path=DOCKER_PREFIX + 'open-webui/open-webui:main', + registry='ghcr.io', dependencies=['ollama'], ports={'8080/tcp': '8080'}, hosts={'host.docker.internal': 'ollama'}, @@ -136,6 +161,7 @@ class ImageEntry(Immutable): icon='󰛶', network_mode='host', path=DOCKER_PREFIX + 'ngrok/ngrok:latest', + registry='docker.io', environment_vairables={ 'NGROK_AUTHTOKEN': lambda: qrcode_input( r'^[a-zA-Z0-9]{20,30}_[a-zA-Z0-9]{20,30}$', @@ -156,6 +182,7 @@ class ImageEntry(Immutable): label='Alpine', icon='', path=DOCKER_PREFIX + 'alpine:latest', + registry='docker.io', ), ] if DEBUG_MODE_DOCKER @@ -174,12 +201,7 @@ def image_reducer( if state is None: if isinstance(action, CombineReducerInitAction): image = IMAGES[action.key] - return ImageState( - id=image.id, - label=image.label, - icon=image.icon, - path=image.path, - ) + return ImageState(id=image.id) raise InitializationActionError(action) if isinstance(action, IpUpdateAction): diff --git a/ubo_app/services/080-docker/setup.py b/ubo_app/services/080-docker/setup.py index dacf0f9b..e7d48309 100644 --- a/ubo_app/services/080-docker/setup.py +++ b/ubo_app/services/080-docker/setup.py @@ -12,22 +12,42 @@ import docker.errors from docker.models.containers import Container from docker.models.images import Image +from reducer import IMAGES +from redux.combine_reducers import functools from ubo_gui.menu.types import ActionItem, HeadedMenu, HeadlessMenu, Item, SubMenuItem -from ubo_app.constants import DOCKER_INSTALLATION_LOCK_FILE, SERVER_SOCKET_PATH +from ubo_app.constants import ( + DOCKER_CREDENTIALS_TEMPLATE, + DOCKER_INSTALLATION_LOCK_FILE, + SERVER_SOCKET_PATH, +) from ubo_app.store import autorun, dispatch -from ubo_app.store.main import RegisterRegularAppAction +from ubo_app.store.main import ( + RegisterRegularAppAction, + RegisterSettingAppAction, + SettingsCategory, +) from ubo_app.store.services.docker import ( + DockerRemoveUsernameAction, DockerSetStatusAction, DockerState, DockerStatus, + DockerStoreUsernameAction, +) +from ubo_app.store.services.notifications import ( + Importance, + Notification, + NotificationsAddAction, ) +from ubo_app.utils import secrets from ubo_app.utils.async_ import create_task from ubo_app.utils.monitor_unit import monitor_unit +from ubo_app.utils.persistent_store import register_persistent_store +from ubo_app.utils.qrcode import qrcode_input from ubo_app.utils.server import send_command if TYPE_CHECKING: - from collections.abc import Callable + from collections.abc import Callable, Sequence def install_docker() -> None: @@ -64,7 +84,6 @@ async def act() -> None: async def check_docker() -> None: """Check if Docker is installed.""" from image import update_container - from reducer import IMAGES process = await asyncio.create_subprocess_exec( '/usr/bin/env', @@ -205,8 +224,8 @@ def docker_menu_items(state: DockerState) -> list[Item]: title='Docker Containers', items=[ ActionItem( - label=getattr(state, image_id).label, - icon=getattr(state, image_id).icon, + label=IMAGES[image_id].label, + icon=IMAGES[image_id].icon, action=IMAGE_MENUS[image_id], ) for image_id in (field.name for field in fields(state)) @@ -235,9 +254,108 @@ def docker_menu_item_action() -> HeadlessMenu: ) +def input_credentials() -> None: + """Input the Docker credentials.""" + + async def act() -> None: + try: + credentials = ( + await qrcode_input( + r'^[^|]*\|[^|]*\|[^|]*$|^[^|]*|[^|]*$', + prompt='Write the credentials in this format: ' + '[i]SERVICE|USERNAME|PASSWORD[/i]\n' + 'Convert it to QR code and scan it.\n' + 'Example: [i]`docker.io|johndoe|secret`[/i]', + ) + )[0] + if credentials.count('|') == 1: + username, password = credentials.split('|') + registry = 'docker.io' + else: + registry, username, password = credentials.split('|') + docker_client = docker.from_env() + docker_client.login( + username=username, + password=password, + registry=registry, + ) + secrets.write_secret( + key=DOCKER_CREDENTIALS_TEMPLATE.format(registry), + value=password, + ) + dispatch( + DockerStoreUsernameAction(registry=registry, username=username), + ) + except asyncio.CancelledError: + pass + except docker.errors.APIError as exception: + dispatch( + NotificationsAddAction( + notification=Notification( + title='Docker Credentials Error', + content='Invalid credentials', + extra_information=exception.explanation + or ( + exception.response.content.decode('utf8') + if exception.response + else '' + ), + importance=Importance.HIGH, + ), + ), + ) + + create_task(act()) + + +def clear_credentials(registry: str) -> None: + """Clear an entry in docker credentials.""" + secrets.clear_secret(DOCKER_CREDENTIALS_TEMPLATE.format(registry)) + dispatch(DockerRemoveUsernameAction(registry=registry)) + + +@autorun(lambda state: state.docker.service.usernames) +def settings_menu_items(usernames: dict[str, str]) -> Sequence[Item]: + """Get the settings menu items for the Docker service.""" + return [ + ActionItem( + label='Set Access Key', + icon='󰐲', + action=input_credentials, + ), + *[ + ActionItem( + label=registry, + icon='󰌊', + action=functools.partial(clear_credentials, registry), + ) + for registry in usernames + ], + ] + + def init_service() -> None: """Initialize the service.""" + register_persistent_store( + 'docker_usernames', + lambda state: state.docker.service.usernames, + ) dispatch(RegisterRegularAppAction(menu_item=docker_main_menu)) + dispatch( + RegisterSettingAppAction( + category=SettingsCategory.APPS, + menu_item=SubMenuItem( + label='Docker', + icon='󰡨', + sub_menu=HeadedMenu( + title='Docker Settings', + heading='󰡨 Docker', + sub_heading='Login a registry:', + items=settings_menu_items, + ), + ), + ), + ) create_task( monitor_unit( 'docker.socket', diff --git a/ubo_app/services/090-voice/setup.py b/ubo_app/services/090-voice/setup.py index 08756ba4..46922985 100644 --- a/ubo_app/services/090-voice/setup.py +++ b/ubo_app/services/090-voice/setup.py @@ -2,72 +2,109 @@ from __future__ import annotations +from asyncio import CancelledError + import pvorca from redux import FinishEvent from ubo_gui.menu.types import ActionItem, HeadedMenu, SubMenuItem -from ubo_app.constants import PICOVOICE_API_KEY +from ubo_app.constants import PICOVOICE_ACCESS_KEY from ubo_app.store import dispatch, subscribe_event -from ubo_app.store.main import RegisterSettingAppAction +from ubo_app.store.main import RegisterSettingAppAction, SettingsCategory from ubo_app.store.services.sound import SoundPlayAudioAction from ubo_app.store.services.voice import VoiceSynthesizeTextEvent -from ubo_app.utils.async_ import create_task +from ubo_app.utils import secrets +from ubo_app.utils.async_ import create_task, to_thread from ubo_app.utils.qrcode import qrcode_input +class _Context: + orca_instance: pvorca.Orca | None = None + + def cleanup(self: _Context) -> None: + if self.orca_instance: + self.orca_instance.delete() + self.orca_instance = None + + def set_access_key(self: _Context, access_key: str | None) -> None: + self.cleanup() + if access_key: + self.orca_instance = pvorca.create(access_key) + + +_context = _Context() + + +def input_access_key() -> None: + """Input the Picovoice access key.""" + + async def act() -> None: + try: + access_key = ( + await qrcode_input( + '.*', + prompt='Convert the Picovoice access key to a QR code and ' + 'scan it.', + ) + )[0] + secrets.write_secret(key=PICOVOICE_ACCESS_KEY, value=access_key) + _context.set_access_key(access_key) + except CancelledError: + pass + + create_task(act()) + + +def clear_access_key() -> None: + """Clear the Picovoice access key.""" + _context.cleanup() + secrets.clear_secret(PICOVOICE_ACCESS_KEY) + + +def synthesize(event: VoiceSynthesizeTextEvent) -> None: + """Synthesize the text.""" + if not _context.orca_instance: + return + rate = _context.orca_instance.sample_rate + audio_sequence = _context.orca_instance.synthesize( + text=event.text, + speech_rate=event.speech_rate, + ) + + dispatch( + SoundPlayAudioAction(sample=audio_sequence, channels=1, rate=rate, width=2), + ) + + def init_service() -> None: - """Initialize vocie service.""" - orca: pvorca.Orca | None = None - - if PICOVOICE_API_KEY: - orca = pvorca.create(access_key=PICOVOICE_API_KEY) - - def input_access_token() -> None: - async def act() -> None: - nonlocal orca - orca = pvorca.create( - access_key=( - await qrcode_input( - '.*', - prompt='Enter the Picovoice API key', - ) - )[0], - ) - - create_task(act()) - - def synthesize(event: VoiceSynthesizeTextEvent) -> None: - if not orca: - return - audio_sequence = orca.synthesize( - text=event.text, - speech_rate=event.speech_rate, - ) - - dispatch( - SoundPlayAudioAction( - sample=audio_sequence, - channels=1, - rate=orca.sample_rate, - width=2, - ), - ) + """Initialize voice service.""" + access_key = secrets.read_secret(PICOVOICE_ACCESS_KEY) + to_thread(_context.set_access_key, access_key) subscribe_event(VoiceSynthesizeTextEvent, synthesize) dispatch( RegisterSettingAppAction( + category=SettingsCategory.INTERFACE, + priority=0, menu_item=SubMenuItem( label='Voice', + icon='󰔊', sub_menu=HeadedMenu( title='Voice Settings', - heading='󰔊', - sub_heading='Set the access token for picovoice service', + heading='󰔊 Picovoice', + sub_heading='Set the access key\n Current value: ' + f'{secrets.read_covered_secret(PICOVOICE_ACCESS_KEY)}', items=[ ActionItem( - label='Access Token', + label='Set Access Key', icon='󰐲', - action=input_access_token, + action=input_access_key, + ), + ActionItem( + label='Clear Access Key', + icon='󰌊', + action=clear_access_key, ), ], ), @@ -75,8 +112,4 @@ def synthesize(event: VoiceSynthesizeTextEvent) -> None: ), ) - def cleanup() -> None: - if orca: - orca.delete() - - subscribe_event(FinishEvent, cleanup) + subscribe_event(FinishEvent, _context.cleanup) diff --git a/ubo_app/side_effects.py b/ubo_app/side_effects.py index dee8248d..9e472447 100644 --- a/ubo_app/side_effects.py +++ b/ubo_app/side_effects.py @@ -3,6 +3,7 @@ from __future__ import annotations import atexit +import json import subprocess from pathlib import Path from typing import TYPE_CHECKING @@ -10,7 +11,13 @@ from debouncer import DebounceOptions, debounce from redux import FinishAction -from ubo_app.store import ScreenshotEvent, dispatch, subscribe_event +from ubo_app.store import ( + ScreenshotEvent, + SnapshotEvent, + dispatch, + store, + 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 @@ -72,6 +79,12 @@ def take_screenshot() -> None: write_image(path, headless_kivy_pi.config._display.raw_data) # noqa: SLF001 +def take_snapshot() -> None: + """Take a snapshot of the store.""" + path = Path('snapshot.json') + path.write_text(json.dumps(store.snapshot, indent=2)) + + def setup_side_effects() -> None: """Set up the application.""" turn_on_screen() @@ -81,6 +94,7 @@ def setup_side_effects() -> None: subscribe_event(UpdateManagerUpdateEvent, update) subscribe_event(UpdateManagerCheckEvent, check_version) subscribe_event(ScreenshotEvent, take_screenshot) + subscribe_event(SnapshotEvent, take_snapshot) @debounce( wait=10, diff --git a/ubo_app/store/__init__.py b/ubo_app/store/__init__.py index 4f0ad336..1ce39035 100644 --- a/ubo_app/store/__init__.py +++ b/ubo_app/store/__init__.py @@ -1,12 +1,16 @@ # ruff: noqa: D100, D101, D102, D103, D104, D107 from __future__ import annotations +import base64 import sys from dataclasses import replace from pathlib import Path from threading import current_thread, main_thread -from typing import TYPE_CHECKING +from types import GenericAlias +from typing import TYPE_CHECKING, Any, TypeVar, cast, get_origin, overload +import dill +from immutable import Immutable from redux import ( BaseCombineReducerState, BaseEvent, @@ -21,13 +25,13 @@ from ubo_app.logging import logger from ubo_app.store.main import MainAction, MainState from ubo_app.store.main.reducer import reducer as main_reducer -from ubo_app.store.services.camera import CameraAction, CameraEvent +from ubo_app.store.services.camera import CameraAction, CameraEvent, CameraState from ubo_app.store.services.docker import DockerAction, DockerState from ubo_app.store.services.ip import IpAction, IpEvent, IpState from ubo_app.store.services.keypad import KeypadEvent from ubo_app.store.services.lightdm import LightDMAction, LightDMState from ubo_app.store.services.notifications import NotificationsAction, NotificationsState -from ubo_app.store.services.rgb_ring import RgbRingAction +from ubo_app.store.services.rgb_ring import RgbRingAction, RgbRingState from ubo_app.store.services.sensors import SensorsAction, SensorsState from ubo_app.store.services.sound import SoundAction, SoundState from ubo_app.store.services.ssh import SSHAction, SSHState @@ -37,6 +41,8 @@ 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.fake import Fake +from ubo_app.utils.serializer import add_type_field if TYPE_CHECKING: from collections.abc import Callable, Coroutine @@ -60,6 +66,8 @@ def scheduler(callback: Callable[[], None], *, interval: bool) -> None: class RootState(BaseCombineReducerState): main: MainState + camera: CameraState + rgb_ring: RgbRingState lightdm: LightDMState status_icons: StatusIconsState update_manager: UpdateManagerState @@ -76,6 +84,9 @@ class RootState(BaseCombineReducerState): class ScreenshotEvent(BaseEvent): ... +class SnapshotEvent(BaseEvent): ... + + ActionType = ( CombineReducerAction | StatusIconsAction @@ -93,7 +104,9 @@ class ScreenshotEvent(BaseEvent): ... | RgbRingAction | VoiceAction ) -EventType = KeypadEvent | CameraEvent | WiFiEvent | IpEvent | ScreenshotEvent +EventType = ( + KeypadEvent | CameraEvent | WiFiEvent | IpEvent | ScreenshotEvent | SnapshotEvent +) root_reducer, root_reducer_id = combine_reducers( state_type=RootState, @@ -104,6 +117,9 @@ class ScreenshotEvent(BaseEvent): ... update_manager=update_manager_reducer, ) +T = TypeVar('T') +LoadedObject = int | float | str | bool | None | Immutable | list['LoadedObject'] + class UboStore(Store[RootState, ActionType, EventType]): @classmethod @@ -119,8 +135,63 @@ def serialize_value(cls: type[UboStore], obj: object | type) -> SnapshotAtom: return f'{obj.__module__}:{obj.__name__}' if isinstance(obj, ActionItem): obj = replace(obj, action='') + if isinstance(obj, dict): + return {k: cls.serialize_value(v) for k, v in obj.items()} + if isinstance(obj, Fake): + return 'FAKE' return super().serialize_value(obj) + @classmethod + def _serialize_dataclass_to_dict( + cls: type[UboStore], + obj: Immutable, + ) -> dict[str, Any]: + result = super()._serialize_dataclass_to_dict(obj) + return add_type_field(obj, result) + + @overload + def load_object( + self: UboStore, + data: SnapshotAtom, + ) -> int | float | str | bool | None | Immutable: ... + @overload + def load_object( + self: UboStore, + data: SnapshotAtom, + *, + object_type: type[T], + ) -> T: ... + + def load_object( + self: UboStore, + data: Any, + *, + object_type: type[T] | None = None, + ) -> LoadedObject | T: + if isinstance(data, int | float | str | bool | None): + return data + if isinstance(data, list): + return [self.load_object(i) for i in data] + if ( + isinstance(data, dict) + and '_type' in data + and isinstance(type_ := data.pop('_type'), str) + ): + class_ = dill.loads(base64.b64decode(type_.encode('utf-8'))) # noqa: S301 + parameters = {key: self.load_object(value) for key, value in data.items()} + + return class_(**parameters) + if not object_type or isinstance( + data, + get_origin(object_type) # pyright: ignore [reportArgumentType] + if isinstance(object_type, GenericAlias) + else object_type, + ): + return cast(T, data) + + msg = f'Invalid data type {type(data)}' + raise TypeError(msg) + def create_task( coro: Coroutine, @@ -143,25 +214,29 @@ def stop_app() -> None: mainthread(App.get_running_app().stop)() +def action_middleware(action: ActionType) -> ActionType: + logger.debug( + 'Action dispatched', + extra={'action': action}, + ) + return action + + +def event_middleware(event: EventType) -> EventType: + logger.debug( + 'Event dispatched', + extra={'event': event}, + ) + return event + + store = UboStore( root_reducer, CreateStoreOptions( auto_init=False, scheduler=scheduler, - action_middlewares=[ - lambda action: logger.debug( - 'Action dispatched', - extra={'action': action}, - ) - or action, - ], - event_middlewares=[ - lambda event: logger.debug( - 'Event dispatched', - extra={'event': event}, - ) - or event, - ], + action_middlewares=[action_middleware], + event_middlewares=[event_middleware], task_creator=create_task, on_finish=stop_app, grace_time_in_seconds=STORE_GRACE_TIME, @@ -176,6 +251,8 @@ def stop_app() -> None: dispatch(InitAction()) if DEBUG_MODE: - subscribe(lambda state: logger.verbose('State updated', extra={'state': state})) + subscribe( + lambda state: logger.verbose('State updated', extra={'state': state}), + ) __all__ = ('autorun', 'dispatch', 'subscribe', 'subscribe_event') diff --git a/ubo_app/store/main/__init__.py b/ubo_app/store/main/__init__.py index 9a6ced39..c8e06ee3 100644 --- a/ubo_app/store/main/__init__.py +++ b/ubo_app/store/main/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations from dataclasses import field +from enum import StrEnum from typing import TYPE_CHECKING from immutable import Immutable @@ -10,6 +11,14 @@ from ubo_app.store.services.keypad import KeypadAction from ubo_app.store.status_icons import StatusIconsAction + +class SettingsCategory(StrEnum): + CONNECTIVITY = 'Connectivity' + INTERFACE = 'Interface' + SYSTEM = 'System' + APPS = 'Apps' + + if TYPE_CHECKING: from collections.abc import Sequence from typing import TypeAlias @@ -32,7 +41,9 @@ class UpdateLightDMState(BaseAction): class RegisterRegularAppAction(RegisterAppAction): ... -class RegisterSettingAppAction(RegisterAppAction): ... +class RegisterSettingAppAction(RegisterAppAction): + category: SettingsCategory + priority: int | None = None class PowerOffAction(BaseAction): ... @@ -44,6 +55,7 @@ class PowerOffEvent(BaseEvent): ... class MainState(Immutable): menu: Menu | None = None path: Sequence[str] = field(default_factory=list) + settings_items_priorities: dict[str, int] = field(default_factory=dict) class SetMenuPathAction(BaseAction): diff --git a/ubo_app/store/main/_menus.py b/ubo_app/store/main/_menus.py index d078a42d..a56746c9 100644 --- a/ubo_app/store/main/_menus.py +++ b/ubo_app/store/main/_menus.py @@ -14,7 +14,7 @@ from ubo_gui.notification import NotificationWidget from ubo_app.store import autorun, dispatch -from ubo_app.store.main import PowerOffAction +from ubo_app.store.main import PowerOffAction, SettingsCategory from ubo_app.store.services.notifications import Notification, NotificationsClearAction from ubo_app.store.update_manager.utils import CURRENT_VERSION, about_menu_items @@ -32,8 +32,17 @@ SETTINGS_MENU = HeadlessMenu( title='Settings', - items=[], - placeholder='No settings', + items=[ + SubMenuItem( + label=category, + sub_menu=HeadlessMenu( + title=category, + items=[], + placeholder='No settings in this category', + ), + ) + for category in SettingsCategory + ], ) diff --git a/ubo_app/store/main/reducer.py b/ubo_app/store/main/reducer.py index 4be6f624..c98197af 100644 --- a/ubo_app/store/main/reducer.py +++ b/ubo_app/store/main/reducer.py @@ -11,7 +11,6 @@ InitializationActionError, ReducerResult, ) -from ubo_gui.menu.types import Item, Menu, SubMenuItem, menu_items from ubo_app.store.main import ( InitEvent, @@ -19,8 +18,8 @@ MainState, PowerOffAction, PowerOffEvent, - RegisterAppAction, RegisterRegularAppAction, + RegisterSettingAppAction, SetMenuPathAction, ) from ubo_app.store.services.keypad import ( @@ -42,6 +41,8 @@ def reducer( SoundChangeVolumeAction, KeypadEvent | InitEvent | PowerOffEvent, ]: + from ubo_gui.menu.types import Item, Menu, SubMenuItem, menu_items + if state is None: if isinstance(action, InitAction): from ._menus import HOME_MENU @@ -78,52 +79,130 @@ def reducer( events=[KeypadKeyReleaseEvent(key=action.key)], ) - if isinstance(action, RegisterAppAction): + if isinstance(action, RegisterSettingAppAction): + parent_index = 1 menu = state.menu - parent_index = 0 if isinstance(action, RegisterRegularAppAction) else 1 - if not menu: return state - root_menu_items = menu_items(menu) + main_menu_item = cast(SubMenuItem, root_menu_items[0]) + main_menu_items = menu_items(cast(Menu, main_menu_item.sub_menu)) + + settings_menu_item = cast(SubMenuItem, main_menu_items[parent_index]) + settings_menu_items = menu_items(cast(Menu, settings_menu_item.sub_menu)) + + category_menu_item = cast( + SubMenuItem, + next(item for item in settings_menu_items if item.label == action.category), + ) + + label = ( + action.menu_item.label() + if callable(action.menu_item.label) + else action.menu_item.label + ) + + priorities = { + **state.settings_items_priorities, + label: action.priority, + } + + def sort_key(item: Item) -> tuple[int, str]: + label = item.label() if callable(item.label) else item.label + return (-(priorities.get(label, 0) or 0), label) + + new_items = sorted( + [ + *cast(Sequence[Item], cast(Menu, category_menu_item.sub_menu).items), + action.menu_item, + ], + key=sort_key, + ) + + new_category_menu_item = replace( + category_menu_item, + sub_menu=replace( + cast(Menu, category_menu_item.sub_menu), + items=new_items, + ), + ) + + new_settings_menu_item = replace( + settings_menu_item, + sub_menu=replace( + cast(Menu, settings_menu_item.sub_menu), + items=[ + new_category_menu_item if item == category_menu_item else item + for item in settings_menu_items + ], + ), + ) + + new_main_menu_item = replace( + main_menu_item, + sub_menu=replace( + cast(Menu, main_menu_item.sub_menu), + items=[ + new_settings_menu_item if item == settings_menu_item else item + for item in main_menu_items + ], + ), + ) + return replace( + state, + settings_items_priorities=priorities, + menu=replace( + menu, + items=[ + new_main_menu_item if item == main_menu_item else item + for item in root_menu_items + ], + ), + ) + + if isinstance(action, RegisterRegularAppAction): + parent_index = 0 + menu = state.menu + if not menu: + return state + root_menu_items = menu_items(menu) main_menu_item: Item = root_menu_items[0] + if not isinstance(main_menu_item, SubMenuItem): msg = 'Main menu item is not a `SubMenuItem`' raise TypeError(msg) main_menu_items = menu_items(cast(Menu, main_menu_item.sub_menu)) - desired_menu_item = main_menu_items[parent_index] - if not isinstance(desired_menu_item, SubMenuItem): - menu_title = ( - 'Applications' - if isinstance(action, RegisterRegularAppAction) - else 'Settings' - ) - msg = f'{menu_title} menu item is not a `SubMenuItem`' + apps_menu_item = main_menu_items[parent_index] + + if not isinstance(apps_menu_item, SubMenuItem): + msg = 'Applications menu item is not a `SubMenuItem`' raise TypeError(msg) new_items = sorted( [ - *cast(Sequence[Item], cast(Menu, desired_menu_item.sub_menu).items), + *cast(Sequence[Item], cast(Menu, apps_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, + + apps_menu_item = replace( + apps_menu_item, sub_menu=replace( - cast(Menu, desired_menu_item.sub_menu), + cast(Menu, apps_menu_item.sub_menu), items=new_items, ), ) + main_menu_item = replace( main_menu_item, sub_menu=replace( cast(Menu, main_menu_item.sub_menu), items=[ - desired_menu_item if index == parent_index else item + apps_menu_item if index == parent_index else item for index, item in enumerate(main_menu_items) ], ), diff --git a/ubo_app/store/services/docker.py b/ubo_app/store/services/docker.py index b5d92538..77c75c72 100644 --- a/ubo_app/store/services/docker.py +++ b/ubo_app/store/services/docker.py @@ -2,12 +2,15 @@ from __future__ import annotations +import functools from dataclasses import field from enum import StrEnum, auto from immutable import Immutable from redux import BaseAction, BaseCombineReducerState, BaseEvent +from ubo_app.utils.persistent_store import read_from_persistent_store + class DockerStatus(StrEnum): """Docker status.""" @@ -36,11 +39,24 @@ class DockerAction(BaseAction): class DockerSetStatusAction(DockerAction): - """Docker set status action.""" + """Set status of docker service.""" status: DockerStatus +class DockerStoreUsernameAction(DockerAction): + """Store username for a registry.""" + + registry: str + username: str + + +class DockerRemoveUsernameAction(DockerAction): + """Remove a registry for stored usernames.""" + + registry: str + + class DockerImageAction(DockerAction): """Docker image action.""" @@ -69,6 +85,13 @@ class DockerServiceState(Immutable): """Docker service state.""" status: DockerStatus = DockerStatus.UNKNOWN + usernames: dict[str, str] = field( + default_factory=functools.partial( + read_from_persistent_store, + 'docker_usernames', + object_type=dict[str, str], + ), + ) class DockerImageEvent(DockerEvent): @@ -81,9 +104,6 @@ class ImageState(Immutable): """Image state.""" id: str - label: str - icon: str - path: str status: ImageStatus = ImageStatus.NOT_AVAILABLE container_ip: str | None = None docker_id: str | None = None diff --git a/ubo_app/store/services/sound.py b/ubo_app/store/services/sound.py index e03f59b3..affc7694 100644 --- a/ubo_app/store/services/sound.py +++ b/ubo_app/store/services/sound.py @@ -1,12 +1,16 @@ # ruff: noqa: D100, D101, D102, D103, D104, D107, N999 from __future__ import annotations +import functools +from dataclasses import field from enum import StrEnum from typing import TYPE_CHECKING from immutable import Immutable from redux import BaseAction, BaseEvent +from ubo_app.utils.persistent_store import read_from_persistent_store + if TYPE_CHECKING: from collections.abc import Sequence @@ -64,7 +68,35 @@ class SoundPlayAudioEvent(SoundEvent): class SoundState(Immutable): - playback_volume: float - is_playback_mute: bool - capture_volume: float - is_capture_mute: bool + playback_volume: float = field( + default_factory=functools.partial( + read_from_persistent_store, + key='sound_playback_volume', + object_type=float, + default=0.5, + ), + ) + is_playback_mute: bool = field( + default_factory=functools.partial( + read_from_persistent_store, + key='sound_is_playback_mute', + object_type=bool, + default=False, + ), + ) + capture_volume: float = field( + default_factory=functools.partial( + read_from_persistent_store, + key='sound_capture_volume', + object_type=float, + default=0.5, + ), + ) + is_capture_mute: bool = field( + default_factory=functools.partial( + read_from_persistent_store, + key='sound_is_capture_mute', + object_type=bool, + default=False, + ), + ) diff --git a/ubo_app/store/update_manager/reducer.py b/ubo_app/store/update_manager/reducer.py index 6faa1bf6..74571480 100644 --- a/ubo_app/store/update_manager/reducer.py +++ b/ubo_app/store/update_manager/reducer.py @@ -11,7 +11,6 @@ InitializationActionError, ReducerResult, ) -from ubo_gui.constants import SECONDARY_COLOR from ubo_app.store.services.notifications import ( Chime, @@ -42,6 +41,8 @@ def reducer( UpdateManagerSetStatusAction | NotificationsAddAction, UpdateManagerEvent, ]: + from ubo_gui.constants import SECONDARY_COLOR + if state is None: if isinstance(action, InitAction): return UpdateManagerState() diff --git a/ubo_app/utils/persistent_store.py b/ubo_app/utils/persistent_store.py new file mode 100644 index 00000000..8074924f --- /dev/null +++ b/ubo_app/utils/persistent_store.py @@ -0,0 +1,89 @@ +"""Utility functions to work with the persistent storage.""" + +from __future__ import annotations + +import json +from asyncio import Lock +from pathlib import Path +from typing import TYPE_CHECKING, TypeVar, cast, overload + +from redux import FinishEvent + +from ubo_app.constants import PERSISTENT_STORE_PATH + +if TYPE_CHECKING: + from collections.abc import Callable + + from ubo_app.store import RootState + +T = TypeVar('T') + +persistent_store_lock = Lock() + + +def register_persistent_store( + key: str, + selector: Callable[[RootState], T], +) -> None: + """Register a part of the store to be persistent in the filesystem.""" + from ubo_app.store import autorun, store, subscribe_event + + @autorun(selector) + async def write(value: T) -> None: + if value is None: + return + async with persistent_store_lock: + try: + current_state = json.loads(Path(PERSISTENT_STORE_PATH).read_text()) + except FileNotFoundError: + current_state = {} + serialized_value = json.dumps(store.serialize_value(value), indent=2) + current_state[key] = serialized_value + Path(PERSISTENT_STORE_PATH).write_text(json.dumps(current_state)) + + subscribe_event(FinishEvent, write.unsubscribe) + + +@overload +def read_from_persistent_store(key: str) -> None: ... +@overload +def read_from_persistent_store( + key: str, + *, + object_type: type[T], +) -> T: ... +@overload +def read_from_persistent_store( + key: str, + *, + default: T, +) -> T: ... +@overload +def read_from_persistent_store( + key: str, + *, + default: T, + object_type: type[T], +) -> T: ... + + +def read_from_persistent_store( + key: str, + *, + default: T | None = None, + object_type: type[T] | None = None, +) -> T | None: + """Read a part of the store from the filesystem.""" + from ubo_app.store import store + + try: + current_state = json.loads(Path(PERSISTENT_STORE_PATH).read_text()) + except FileNotFoundError: + return default or (None if object_type is None else object_type()) + serialized_value = current_state.get(key) + if serialized_value is None: + return default or (None if object_type is None else object_type()) + return store.load_object( + json.loads(serialized_value), + object_type=cast(type[T], object_type), + ) diff --git a/ubo_app/utils/secrets.py b/ubo_app/utils/secrets.py new file mode 100644 index 00000000..c070aeb9 --- /dev/null +++ b/ubo_app/utils/secrets.py @@ -0,0 +1,50 @@ +"""Module to manage secrets in a .env file.""" + +from __future__ import annotations + +import os + +import dotenv + +from ubo_app.constants import SECRETS_PATH + +SECRETS_PATH.touch(mode=0o600, exist_ok=True) + +uid = os.getuid() +gid = os.getgid() +os.chown(SECRETS_PATH, uid, gid) + +SECRETS_PATH.chmod(0o600) + + +def write_secret(*, key: str, value: str) -> None: + """Write a key-value pair to the secrets environment variables file.""" + dotenv.set_key( + dotenv_path=SECRETS_PATH, + key_to_set=key, + value_to_set=value, + ) + + +def read_secret(key: str) -> str | None: + """Read a key-value pair from the secrets environment variables file.""" + return dotenv.get_key( + dotenv_path=SECRETS_PATH, + key_to_get=key, + ) + + +def read_covered_secret(key: str) -> str | None: + """Read a key-value pair from the secrets environment variables file.""" + value = read_secret(key) + if value: + return f'***{value[-4:]}' + return '' + + +def clear_secret(key: str) -> None: + """Clear a key-value pair from the secrets environment variables file.""" + dotenv.unset_key( + dotenv_path=SECRETS_PATH, + key_to_unset=key, + ) diff --git a/ubo_app/utils/serializer.py b/ubo_app/utils/serializer.py new file mode 100644 index 00000000..10699e91 --- /dev/null +++ b/ubo_app/utils/serializer.py @@ -0,0 +1,16 @@ +"""Contains the serialization functions for the immutable objects.""" + +import base64 +from typing import Any + +import dill +from immutable import Immutable + + +def add_type_field( + obj: Immutable, + serialized: dict[str, Any], +) -> dict[str, Any]: + """Add the type field to the serialized object.""" + serialized['_type'] = base64.b64encode(dill.dumps(obj.__class__)).decode('utf-8') + return serialized