updates2mqtt 1.4.0__tar.gz → 1.4.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/.github/workflows/docker-publish.yml +2 -2
  2. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/.github/workflows/pypi-publish.yml +4 -1
  3. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/.github/workflows/python-package.yml +23 -2
  4. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/.gitignore +1 -0
  5. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/CHANGELOG.md +5 -0
  6. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/PKG-INFO +5 -1
  7. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/README.md +4 -0
  8. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/conftest.py +68 -5
  9. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/docs/installation.md +13 -0
  10. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/docs/troubleshooting.md +72 -2
  11. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/pyproject.toml +5 -2
  12. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/src/updates2mqtt/app.py +15 -10
  13. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/src/updates2mqtt/integrations/docker.py +10 -4
  14. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/src/updates2mqtt/model.py +4 -0
  15. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/src/updates2mqtt/mqtt.py +6 -5
  16. updates2mqtt-1.4.1/tests/test_app.py +130 -0
  17. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/tests/test_mqtt.py +6 -6
  18. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/uv.lock +108 -1
  19. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/.dockerignore +0 -0
  20. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/.github/dependabot.yml +0 -0
  21. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/.hintrc +0 -0
  22. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/.pre-commit-config.yaml +0 -0
  23. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/.python-version +0 -0
  24. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/.safety-project.ini +0 -0
  25. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/Dockerfile +0 -0
  26. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/LICENSE +0 -0
  27. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/SECURITY.md +0 -0
  28. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/common_packages.yaml +0 -0
  29. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/docs/configuration.md +0 -0
  30. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/docs/examples/config_maximal.md +0 -0
  31. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/docs/examples/config_minimal.md +0 -0
  32. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/docs/examples/docker_compose.md +0 -0
  33. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/docs/home_assistant.md +0 -0
  34. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/docs/images/ha_entities.png +0 -0
  35. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/docs/images/ha_mqtt_discovery.png +0 -0
  36. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/docs/images/ha_update_detail.png +0 -0
  37. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/docs/images/ha_update_dialog.png +0 -0
  38. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/docs/images/ha_update_page.png +0 -0
  39. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/docs/images/logo-blank-256x256.png +0 -0
  40. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/docs/index.md +0 -0
  41. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/docs/local_builds.md +0 -0
  42. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/examples/config.yaml.maximal +0 -0
  43. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/examples/config.yaml.minimal +0 -0
  44. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/examples/docker-compose.yaml +0 -0
  45. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/mkdocs.yml +0 -0
  46. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/no_config.yaml +0 -0
  47. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/refresh-deps.sh +0 -0
  48. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/scripts/healthcheck.sh +0 -0
  49. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/src/updates2mqtt/__init__.py +0 -0
  50. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/src/updates2mqtt/__main__.py +0 -0
  51. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/src/updates2mqtt/config.py +0 -0
  52. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/src/updates2mqtt/hass_formatter.py +0 -0
  53. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/src/updates2mqtt/integrations/__init__.py +0 -0
  54. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/src/updates2mqtt/integrations/git_utils.py +0 -0
  55. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/src/updates2mqtt/py.typed +0 -0
  56. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/tests/__init__.py +0 -0
  57. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/tests/test_config.py +0 -0
  58. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/tests/test_docker.py +0 -0
  59. {updates2mqtt-1.4.0 → updates2mqtt-1.4.1}/tests/test_git_utils.py +0 -0
@@ -25,7 +25,7 @@ jobs:
25
25
  #
26
26
  steps:
27
27
  - name: Checkout repository
28
- uses: actions/checkout@v5
28
+ uses: actions/checkout@v6
29
29
  # Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here.
30
30
  - name: Log in to the Container registry
31
31
  uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef
@@ -42,7 +42,7 @@ jobs:
42
42
  uses: docker/setup-buildx-action@v3
43
43
  - name: Extract metadata (tags, labels) for Docker
44
44
  id: meta
45
- uses: docker/metadata-action@8d8c7c12f7b958582a5cb82ba16d5903cb27976a
45
+ uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051
46
46
  with:
47
47
  images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
48
48
  tags: |
@@ -2,13 +2,16 @@ name: Publish Python 🐍 distribution 📦 to PyPI and TestPyPI
2
2
 
3
3
  on: push
4
4
 
5
+ permissions:
6
+ contents: read
7
+
5
8
  jobs:
6
9
  build:
7
10
  name: Build distribution 📦
8
11
  runs-on: ubuntu-latest
9
12
 
10
13
  steps:
11
- - uses: actions/checkout@v5
14
+ - uses: actions/checkout@v6
12
15
  with:
13
16
  persist-credentials: false
14
17
  - name: uv-setup
@@ -15,7 +15,7 @@ jobs:
15
15
  permissions:
16
16
  contents: write
17
17
  steps:
18
- - uses: actions/checkout@v5
18
+ - uses: actions/checkout@v6
19
19
  - name: uv-setup
20
20
  # Install a specific uv version using the installer
21
21
  run: curl -LsSf https://astral.sh/uv/install.sh | sh
@@ -27,11 +27,32 @@ jobs:
27
27
  run: uv sync --all-extras --dev
28
28
 
29
29
  - name: Ruff
30
- uses: chartboost/ruff-action@v1
30
+ uses: astral-sh/ruff-action@v3
31
31
  - name: Test with pytest
32
32
  run: uv run pytest tests
33
33
  - name: Build a binary wheel and a source tarball
34
34
  run: uv build
35
+ - name: Generate badges
36
+ env:
37
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
38
+ run: |
39
+ uv run genbadge coverage -i cov.xml -o badges/coverage.svg
40
+ uv run genbadge tests -i junit/test-results.xml -o badges/tests.svg
41
+
42
+ git config --global user.name "github-actions[bot]"
43
+ git config --global user.email "github-actions[bot]@users.noreply.github.com"
44
+ TEMP_DIR=$(mktemp -d)
45
+ cd $TEMP_DIR
46
+ git clone --single-branch --branch badges https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git . 2>/dev/null
47
+ cp -r ${{ github.workspace }}/badges .
48
+ git add badges
49
+ if git commit -m "Update badges [skip ci]"; then
50
+ git push origin badges --force
51
+ echo "Badges updated successfully"
52
+ else
53
+ echo "No changes to badges"
54
+ fi
55
+
35
56
  - name: Prep for mkdocs
36
57
  run: uv pip compile pyproject.toml -o requirements_docs.txt --group mkdocs
37
58
  - name: Deploy docs
@@ -11,6 +11,7 @@ htmlcov/
11
11
  lcov.info
12
12
  .vscode/
13
13
  .DS_Store
14
+ junit/
14
15
 
15
16
  # C extensions
16
17
  *.so
@@ -1,5 +1,10 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 1.4.1
4
+ - More logging for Docker discovery on why Home Assistant doesn't show an update button
5
+ - More test cases
6
+ - `MqttClient` is now `MqttPublisher` to avoid confusion with actual MQTT client
7
+ - Task cleanup now only interrupts explicit list of tasks - healthcheck and discovery tasks
3
8
  ## 1.4.0
4
9
  - MQTT protocol can now be set, to one of `3.1`,`3.11` or `5`
5
10
  - Debug messages now provided for `on_subscribe` and `on_unsubscribe` callbacks
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: updates2mqtt
3
- Version: 1.4.0
3
+ Version: 1.4.1
4
4
  Summary: System update and docker image notification and execution over MQTT
5
5
  Project-URL: Homepage, https://updates2mqtt.rhizomatics.org.uk
6
6
  Project-URL: Repository, https://github.com/rhizomatics/updates2mqtt
@@ -39,8 +39,12 @@ Description-Content-Type: text/markdown
39
39
 
40
40
  # updates2mqtt
41
41
 
42
+ [![Rhizomatics Open Source](https://img.shields.io/badge/rhizomatics%20open%20source-lightseagreen)](https://github.com/rhizomatics)
43
+
42
44
  [![PyPI - Version](https://img.shields.io/pypi/v/updates2mqtt)](https://pypi.org/project/updates2mqtt/)
43
45
  [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/rhizomatics/supernotify)
46
+ ![Coverage](https://raw.githubusercontent.com/rhizomatics/updates2mqtt/ff84fbfac358820cf9c9e9e582c063cd1a137116/badges/coverage.svg)
47
+ ![Tests](https://raw.githubusercontent.com/rhizomatics/updates2mqtt/ff84fbfac358820cf9c9e9e582c063cd1a137116/badges/tests.svg)
44
48
  [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/rhizomatics/updates2mqtt/main.svg)](https://results.pre-commit.ci/latest/github/rhizomatics/updates2mqtt/main)
45
49
  [![Publish Python 🐍 distribution 📦 to PyPI and TestPyPI](https://github.com/rhizomatics/updates2mqtt/actions/workflows/pypi-publish.yml/badge.svg)](https://github.com/rhizomatics/updates2mqtt/actions/workflows/pypi-publish.yml)
46
50
  [![Github Deploy](https://github.com/rhizomatics/updates2mqtt/actions/workflows/python-package.yml/badge.svg?branch=main)](https://github.com/rhizomatics/updates2mqtt/actions/workflows/python-package.yml)
@@ -2,8 +2,12 @@
2
2
 
3
3
  # updates2mqtt
4
4
 
5
+ [![Rhizomatics Open Source](https://img.shields.io/badge/rhizomatics%20open%20source-lightseagreen)](https://github.com/rhizomatics)
6
+
5
7
  [![PyPI - Version](https://img.shields.io/pypi/v/updates2mqtt)](https://pypi.org/project/updates2mqtt/)
6
8
  [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/rhizomatics/supernotify)
9
+ ![Coverage](https://raw.githubusercontent.com/rhizomatics/updates2mqtt/ff84fbfac358820cf9c9e9e582c063cd1a137116/badges/coverage.svg)
10
+ ![Tests](https://raw.githubusercontent.com/rhizomatics/updates2mqtt/ff84fbfac358820cf9c9e9e582c063cd1a137116/badges/tests.svg)
7
11
  [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/rhizomatics/updates2mqtt/main.svg)](https://results.pre-commit.ci/latest/github/rhizomatics/updates2mqtt/main)
8
12
  [![Publish Python 🐍 distribution 📦 to PyPI and TestPyPI](https://github.com/rhizomatics/updates2mqtt/actions/workflows/pypi-publish.yml/badge.svg)](https://github.com/rhizomatics/updates2mqtt/actions/workflows/pypi-publish.yml)
9
13
  [![Github Deploy](https://github.com/rhizomatics/updates2mqtt/actions/workflows/python-package.yml/badge.svg?branch=main)](https://github.com/rhizomatics/updates2mqtt/actions/workflows/python-package.yml)
@@ -1,26 +1,89 @@
1
- from unittest.mock import MagicMock, Mock, patch
1
+ # python
2
+ from collections.abc import AsyncGenerator, Callable
3
+ from typing import Any
4
+ from unittest.mock import AsyncMock, MagicMock, Mock, patch
2
5
 
3
6
  import paho.mqtt.client
4
7
  import pytest
5
8
  from docker import DockerClient
6
9
  from docker.models.containers import Container, ContainerCollection
7
10
  from docker.models.images import Image, RegistryData
11
+ from omegaconf import DictConfig, OmegaConf
8
12
 
13
+ import updates2mqtt.app
14
+ from updates2mqtt.app import (
15
+ App, # relative import as required
16
+ MqttPublisher,
17
+ )
18
+ from updates2mqtt.config import Config
9
19
  from updates2mqtt.model import Discovery, ReleaseProvider
10
20
 
11
21
 
22
+ @pytest.fixture
23
+ def app_with_mocked_external_dependencies(
24
+ monkeypatch, # noqa: ANN001
25
+ mock_provider_class: type,
26
+ mock_publisher_class: type,
27
+ ) -> App:
28
+ cfg: DictConfig = OmegaConf.structured(Config)
29
+ monkeypatch.setattr(updates2mqtt.app, "load_app_config", lambda *_args, **__kwargs: cfg)
30
+ monkeypatch.setattr(updates2mqtt.app, "DockerProvider", mock_provider_class)
31
+ monkeypatch.setattr(updates2mqtt.app, "MqttPublisher", mock_publisher_class)
32
+ app: App = App()
33
+ return app
34
+
35
+
36
+ @pytest.fixture
37
+ def mock_discoveries(mock_provider: ReleaseProvider) -> list[Discovery]:
38
+ return [Discovery(mock_provider, "thing-1", "test001")]
39
+
40
+
41
+ @pytest.fixture
42
+ def mock_discovery_generator(mock_discoveries: list[Discovery]) -> Callable[..., AsyncGenerator[Discovery, Any]]:
43
+ async def g(*args: Any) -> AsyncGenerator[Discovery]: # noqa: ARG001
44
+ for d in mock_discoveries:
45
+ yield d
46
+
47
+ return g
48
+
49
+
50
+ @pytest.fixture
51
+ def mock_provider_class(mock_provider: ReleaseProvider) -> type:
52
+ class MockReleaseProvider(ReleaseProvider):
53
+ def __new__(cls, *args: Any, **kwargs: Any) -> ReleaseProvider: # type: ignore[misc] # noqa: ARG004
54
+ return mock_provider
55
+
56
+ return MockReleaseProvider
57
+
58
+
59
+ @pytest.fixture
60
+ def mock_publisher_class(mock_publisher: MqttPublisher) -> type:
61
+ class MockPublisher(MqttPublisher):
62
+ def __new__(cls, *args: Any, **kwargs: Any) -> MqttPublisher: # type: ignore[misc] # noqa: ARG004
63
+ return mock_publisher
64
+
65
+ return MockPublisher
66
+
67
+
12
68
  @pytest.fixture
13
69
  def mock_provider() -> ReleaseProvider:
14
- provider = Mock(spec=ReleaseProvider)
70
+ provider: ReleaseProvider = AsyncMock(spec=ReleaseProvider)
15
71
  provider.source_type = "unit_test"
16
- provider.command.return_value = True
17
- provider.resolve.return_value = Discovery(
72
+ provider.command.return_value = True # type: ignore[attr-defined]
73
+ provider.resolve.return_value = Discovery( # type: ignore[attr-defined]
18
74
  provider, "fooey", session="test-mqtt-123", current_version="v2", latest_version="v2"
19
75
  )
20
- provider.hass_state_format.return_value = {"fixture": "test_exec"}
76
+ provider.hass_state_format.return_value = {"fixture": "test_exec"} # type: ignore[attr-defined]
21
77
  return provider
22
78
 
23
79
 
80
+ @pytest.fixture
81
+ def mock_publisher(mock_mqtt_client: paho.mqtt.client.Client) -> MqttPublisher:
82
+ publisher: MqttPublisher = AsyncMock(MqttPublisher)
83
+ publisher.client = mock_mqtt_client
84
+ return publisher
85
+
86
+
24
87
  @pytest.fixture
25
88
  def mock_mqtt_client() -> paho.mqtt.client.Client:
26
89
  return MagicMock(spec=paho.mqtt.client.Client, name="MQTT Client Fixture")
@@ -12,6 +12,19 @@ See `examples` directory for a working `docker-compose.yaml`.
12
12
 
13
13
  If you want to update and restart containers, then the file system paths to the location of the directory where the docker compose file lives must be available in the updates2mqtt container.
14
14
 
15
+ ```yaml
16
+ volumes:
17
+ # Must have config directory mapped
18
+ - ./conf:/app/conf
19
+ # Must have the Docker daemon socket mapped
20
+ - /var/run/docker.sock:/var/run/docker.sock
21
+ # This list of paths is only needed when containers are to be updated
22
+ # The paths here are completely dependent on where your docker-compose files live
23
+ - /home/containers:/home/containers
24
+ - /dev.containers:/dev.containers
25
+ - /containers:/containers
26
+ ```
27
+
15
28
  The example `docker-compose.yaml` mounts `/home/containers` for this purpose, so if your containers are in
16
29
  `/home/containers/app1`, `/home/containers/app2` etc, then updates2mqtt will be able to find them. Map as many root paths as needed.
17
30
 
@@ -1,14 +1,52 @@
1
1
  # Troubleshooting
2
2
 
3
- ## Log Level
3
+ ## General
4
4
 
5
- Update the `config.yaml` and change the log level to DEBUG
5
+ Things that need to work:
6
+
7
+ - Docker API access to list and inspect containers
8
+ - MQTT Publication of results
9
+ - Home Assistant discovering the Update entities on MQTT
10
+ - Home Assistant generating update notices in UI when there's a new version
11
+ - Command Topic message sent by Home Assistant when Update button clicked
12
+ - Docker Compose available from shell to make updates and restart
13
+ - Git command available in shell to check for local repo updates and pull
14
+
15
+ ## Updates2MQTT Logs
16
+
17
+ If running under docker, and following the container naming guidance, then see the
18
+ logs using:
19
+
20
+ `docker logs updates2mqtt`
21
+
22
+ or change to the directory where the `docker-compose.yaml` is installed and do `docker compose logs`
23
+
24
+ ### Changing Log Level
25
+
26
+ Update the `config.yaml` and change the log level to DEBUG, which will show much
27
+ more diagnostic information.
6
28
 
7
29
  ```yaml
8
30
  log:
9
31
  level: DEBUG
10
32
  ```
11
33
 
34
+ When you have everything working, its best to change the log level back, so
35
+ your container isn't generating big logs.
36
+
37
+ ### Going Inside Container
38
+
39
+ From the `docker-compose.yaml` directory, execute
40
+
41
+ `docker compose exec -it updates2mqtt bash`
42
+
43
+ (if you have an old Docker install, you may need to use `docker-compose` instead of `docker compose`)
44
+
45
+ This will give you shell access inside the container, which is a good way of checking
46
+ for path issues, permissio issues etc. For example, if you have compose directories in the
47
+ `/containers` directory, you could `cd /containers` and validate that Updates2MQTT can see the
48
+ other compose directories, `ls` the contents, and run `docker compose` or `git` actions there.
49
+
12
50
  ## MQTT
13
51
 
14
52
  If the host, port, user and password for MQTT are incorrect then usually this
@@ -58,6 +96,38 @@ Oddly, the Paho MQTT client used by Updates2MQTT is known to [report success eve
58
96
 
59
97
  There's also an alternative to MQTT Discovery in HA, using plain yaml, the [MQTT Update Integration](https://www.home-assistant.io/integrations/update.mqtt/#configuration). The [BBQKees Boiler Gateway](https://bbqkees-electronics.nl/wiki/home-automations/home-assistant-configuration.html) has some detailed steps and examples for MQTT Discovery too.
60
98
 
99
+ #### No Update Button
100
+
101
+ If there's an update showing, but no *Update* button present, then there's a few reasons, which
102
+ can be checked directly from the config and log:
103
+
104
+ - Config has the `allow_pull`,`allow_restart` and `allow_build` all overridden to `False`
105
+ - A new version reference can't be found
106
+ - The compose working directory can't be found
107
+ - This is sourced from the `com.docker.compose.project.working_dir` label, which can be seen in `docker inspect`
108
+ - This only stops restart, not pull, so if `allow_pull` is on, the Update button will still show
109
+ - The git repo path can't be found for a local build
110
+
111
+ The current state of this can be seen in MQTT, the config message will have two extra
112
+ values as below:
113
+
114
+ ```yaml
115
+ "command_topic": "updates2mqtt/dockernuc/docker",
116
+ "payload_install": "docker|homarr|install"
117
+ ```
118
+
119
+ #### Home Assistant Logs
120
+
121
+ Use the [System Log](https://www.home-assistant.io/integrations/system_log/) to check
122
+ for MQTT errors, or for positive confirmation that Update entities have been discovered. The
123
+ *raw* log will show more, and allow you to scroll back for hours.
124
+
125
+ The [Logger Integration](https://www.home-assistant.io/integrations/logger/#viewing-logs)
126
+ lets you tweak the levels of specific integrations. This is less useful for Updates2MQTT,
127
+ since all the work is happening outside of Home Assistant, however it can be useful
128
+ for general MQTT issues, and the examples in the Home Assistant documentation shows how
129
+ to tune the MQTT integration.
130
+
61
131
  ## Docker
62
132
 
63
133
  More detailed information on the Docker API and compatibility with Docker engine versions can be found at Docker's own [Docker Engine API](https://docs.docker.com/reference/api/engine/) reference.
@@ -7,7 +7,7 @@ authors = [
7
7
  ]
8
8
 
9
9
  requires-python = ">=3.13"
10
- version = "1.4.0"
10
+ version = "1.4.1"
11
11
  license="Apache-2.0"
12
12
  keywords=["mqtt", "docker", "updates", "automation","home-assistant","homeassistant","selfhosting"]
13
13
 
@@ -56,6 +56,8 @@ dev = [
56
56
  "pytest-mqtt>=0.6.0",
57
57
  "pytest-subprocess>=1.5.3",
58
58
  "coverage",
59
+ "icdiff",
60
+ "genbadge[all]"
59
61
  ]
60
62
  mkdocs=[
61
63
  "mkdocs-material",
@@ -154,10 +156,11 @@ norecursedirs = [
154
156
  ".git",
155
157
  "templates",
156
158
  ]
159
+
157
160
  pythonpath = [
158
161
  "."
159
162
  ]
160
- addopts = "--cov --cov-report=lcov:lcov.info --cov-report=term --import-mode=importlib"
163
+ addopts = "--junitxml=junit/test-results.xml --cov --cov-report=lcov:lcov.info --cov-report=xml --cov-report=html --cov-report=term --import-mode=importlib"
161
164
 
162
165
  [tool.coverage.run]
163
166
  branch = true
@@ -16,7 +16,7 @@ from updates2mqtt.model import Discovery, ReleaseProvider
16
16
 
17
17
  from .config import Config, load_app_config, load_package_info
18
18
  from .integrations.docker import DockerProvider
19
- from .mqtt import MqttClient
19
+ from .mqtt import MqttPublisher
20
20
 
21
21
  log = structlog.get_logger()
22
22
 
@@ -48,7 +48,7 @@ class App:
48
48
  log.debug("Logging initialized", level=self.cfg.log.level)
49
49
  self.common_pkg = load_package_info(PKG_INFO_FILE)
50
50
 
51
- self.publisher = MqttClient(self.cfg.mqtt, self.cfg.node, self.cfg.homeassistant)
51
+ self.publisher = MqttPublisher(self.cfg.mqtt, self.cfg.node, self.cfg.homeassistant)
52
52
 
53
53
  self.scanners: list[ReleaseProvider] = []
54
54
  self.scan_count: int = 0
@@ -74,7 +74,7 @@ class App:
74
74
  break
75
75
  log.info("Scanning", source=scanner.source_type, session=session)
76
76
  async with asyncio.TaskGroup() as tg:
77
- async for discovery in scanner.scan(session): # type: ignore[attr-defined]
77
+ async for discovery in scanner.scan(session): # xtype: ignore[attr-defined]
78
78
  tg.create_task(self.on_discovery(discovery), name=f"discovery-{discovery.name}")
79
79
  if self.stopped.is_set():
80
80
  break
@@ -83,7 +83,7 @@ class App:
83
83
  log.info("Scan complete", source_type=scanner.source_type)
84
84
  self.last_scan_timestamp = datetime.now(UTC).isoformat()
85
85
 
86
- async def run(self) -> None:
86
+ async def main_loop(self) -> None:
87
87
  log.debug("Starting run loop")
88
88
  self.publisher.start()
89
89
 
@@ -93,7 +93,7 @@ class App:
93
93
  f"Setting up healthcheck every {self.cfg.node.healthcheck.interval} seconds to topic {self.healthcheck_topic}"
94
94
  )
95
95
  self.healthcheck_loop_task = asyncio.create_task(
96
- repeated_call(self.healthcheck, interval=self.cfg.node.healthcheck.interval)
96
+ repeated_call(self.healthcheck, interval=self.cfg.node.healthcheck.interval), name="healthcheck"
97
97
  )
98
98
 
99
99
  for scanner in self.scanners:
@@ -145,7 +145,8 @@ class App:
145
145
  log.info(f"Cancelling {len(running_tasks)} tasks")
146
146
  for t in running_tasks:
147
147
  log.debug("Cancelling task", task=t.get_name())
148
- t.cancel()
148
+ if t.get_name() == "healthcheck" or t.get_name().startswith("discovery-"):
149
+ t.cancel()
149
150
  await asyncio.gather(*running_tasks, return_exceptions=True)
150
151
  log.debug("Cancellation task completed")
151
152
 
@@ -154,7 +155,11 @@ class App:
154
155
  self.stopped.set()
155
156
  for scanner in self.scanners:
156
157
  scanner.stop()
157
- interrupt_task = asyncio.get_event_loop().create_task(self.interrupt_tasks(), eager_start=True) # type: ignore[call-arg] # pyright: ignore[reportCallIssue]
158
+ interrupt_task = asyncio.get_event_loop().create_task(
159
+ self.interrupt_tasks(),
160
+ eager_start=True, # type: ignore[call-arg] # pyright: ignore[reportCallIssue]
161
+ name="interrupt",
162
+ )
158
163
  for t in asyncio.all_tasks():
159
164
  log.debug("Tasks waiting = %s", t)
160
165
  self.publisher.stop()
@@ -168,7 +173,7 @@ class App:
168
173
  self.publisher.publish(
169
174
  topic=self.healthcheck_topic,
170
175
  payload={
171
- "version": updates2mqtt.version,
176
+ "version": updates2mqtt.version, # pyright: ignore[reportAttributeAccessIssue]
172
177
  "node": self.cfg.node.name,
173
178
  "heartbeat_raw": time.time(),
174
179
  "heartbeat_stamp": datetime.now(UTC).isoformat(),
@@ -197,12 +202,12 @@ def run() -> None:
197
202
 
198
203
  from .app import App
199
204
 
200
- log.debug(f"Starting updates2mqtt v{updates2mqtt.version}")
205
+ log.debug(f"Starting updates2mqtt v{updates2mqtt.version}") # pyright: ignore[reportAttributeAccessIssue]
201
206
  app = App()
202
207
 
203
208
  signal.signal(signal.SIGTERM, app.shutdown)
204
209
  try:
205
- asyncio.run(app.run(), debug=False)
210
+ asyncio.run(app.main_loop(), debug=False)
206
211
  log.debug("App exited gracefully")
207
212
  except asyncio.CancelledError:
208
213
  log.debug("App exited on cancelled task")
@@ -135,8 +135,8 @@ class DockerProvider(ReleaseProvider):
135
135
  image_ref = None
136
136
  image_name = None
137
137
  local_versions = None
138
- if c.attrs is None:
139
- logger.warn("No container attributes found, discovery rejected") # type: ignore[unreachable]
138
+ if c.attrs is None or not c.attrs:
139
+ logger.warn("No container attributes found, discovery rejected")
140
140
  return None
141
141
  if c.name is None:
142
142
  logger.warn("No container name found, discovery rejected")
@@ -145,7 +145,7 @@ class DockerProvider(ReleaseProvider):
145
145
  def env_override(env_var: str, default: Any) -> Any | None:
146
146
  return default if c_env.get(env_var) is None else c_env.get(env_var)
147
147
 
148
- env_str = c.attrs["Config"]["Env"]
148
+ env_str = c.attrs.get("Config", {}).get("Env")
149
149
  c_env = dict(env.split("=", maxsplit=1) for env in env_str if "==" not in env)
150
150
  ignore_container: str | None = env_override("UPD2MQTT_IGNORE", "FALSE")
151
151
  if ignore_container and ignore_container.upper() in ("1", "TRUE"):
@@ -247,11 +247,17 @@ class DockerProvider(ReleaseProvider):
247
247
  can_build: bool = self.cfg.allow_build and custom.get("git_repo_path") is not None
248
248
  can_restart: bool = self.cfg.allow_restart and custom.get("compose_path") is not None
249
249
  can_update: bool = False
250
+ if self.cfg.allow_pull and not can_pull and not can_build:
251
+ logger.info(
252
+ f"Pull not available, image_ref:{image_ref},local_version:{local_version},latest_version:{latest_version}"
253
+ )
250
254
  if can_pull or can_build or can_restart:
251
255
  # public install-neutral capabilities and Home Assistant features
252
256
  can_update = True
253
257
  features.append("INSTALL")
254
258
  features.append("PROGRESS")
259
+ elif any((self.cfg.allow_build, self.cfg.allow_restart, self.cfg.allow_pull)):
260
+ logger.info(f"Update not available, can_pull:{can_pull}, can_build:{can_build},can_restart{can_restart}")
255
261
  if relnotes_url:
256
262
  features.append("RELEASE_NOTES")
257
263
  custom["can_pull"] = can_pull
@@ -279,7 +285,7 @@ class DockerProvider(ReleaseProvider):
279
285
  logger.exception("Docker Discovery Failure", container_attrs=c.attrs)
280
286
  return None
281
287
 
282
- async def scan(self, session: str) -> AsyncGenerator[Discovery]: # type: ignore # noqa: PGH003
288
+ async def scan(self, session: str) -> AsyncGenerator[Discovery]:
283
289
  logger = self.log.bind(session=session, action="scan")
284
290
  containers = results = 0
285
291
  for c in self.client.containers.list():
@@ -80,6 +80,10 @@ class ReleaseProvider:
80
80
  @abstractmethod
81
81
  async def scan(self, session: str) -> AsyncGenerator[Discovery]:
82
82
  """Scan for components to monitor"""
83
+ raise NotImplementedError
84
+ # force recognition as an async generator
85
+ if False: # type: ignore[unreachable]
86
+ yield 0
83
87
 
84
88
  def hass_config_format(self, discovery: Discovery) -> dict:
85
89
  _ = discovery
@@ -28,7 +28,7 @@ class LocalMessage:
28
28
  payload: str | None = field(default=None)
29
29
 
30
30
 
31
- class MqttClient:
31
+ class MqttPublisher:
32
32
  def __init__(self, cfg: MqttConfig, node_cfg: NodeConfig, hass_cfg: HomeAssistantConfig) -> None:
33
33
  self.cfg: MqttConfig = cfg
34
34
  self.node_cfg: NodeConfig = node_cfg
@@ -90,7 +90,7 @@ class MqttClient:
90
90
  self.client = None
91
91
 
92
92
  def is_available(self) -> bool:
93
- return not self.fatal_failure.is_set()
93
+ return self.client is not None and not self.fatal_failure.is_set()
94
94
 
95
95
  def on_connect(
96
96
  self, _client: mqtt.Client, _userdata: Any, _flags: mqtt.ConnectFlags, rc: ReasonCode, _props: Properties | None
@@ -103,8 +103,8 @@ class MqttClient:
103
103
  log.error("Invalid MQTT credentials", result_code=rc)
104
104
 
105
105
  self.log.info("Connected to broker", result_code=rc)
106
- for topic in self.providers_by_topic:
107
- self.log.info("(Re)subscribing", topic=topic)
106
+ for topic, provider in self.providers_by_topic.items():
107
+ self.log.info("(Re)subscribing", topic=topic, provider=provider.source_type)
108
108
  self.client.subscribe(topic)
109
109
 
110
110
  def on_disconnect(
@@ -272,7 +272,8 @@ class MqttClient:
272
272
  if msg.topic in self.providers_by_topic:
273
273
  self.handle_message(msg)
274
274
  else:
275
- self.log.warn("Unhandled message: %s", msg.topic)
275
+ # apparently the root non-wildcard sub sometimes brings in child topics
276
+ self.log.debug("Unhandled message #%s on %s:%s", msg.mid, msg.topic, msg.payload)
276
277
 
277
278
  def handle_message(self, msg: mqtt.MQTTMessage | LocalMessage) -> None:
278
279
  def update_start(discovery: Discovery) -> None:
@@ -0,0 +1,130 @@
1
+ # python
2
+ import asyncio
3
+ import signal
4
+ import time
5
+ import types
6
+ from collections.abc import AsyncGenerator, Coroutine
7
+ from typing import Any, NoReturn
8
+ from unittest.mock import ANY, call
9
+
10
+ import pytest
11
+
12
+ from updates2mqtt.app import App, run # relative import as required
13
+ from updates2mqtt.model import Discovery
14
+
15
+
16
+ async def test_scan(
17
+ app_with_mocked_external_dependencies: App,
18
+ mock_discoveries: list[Discovery],
19
+ mock_discovery_generator: AsyncGenerator[Discovery],
20
+ monkeypatch, # noqa: ANN001
21
+ ) -> None:
22
+ uut: App = app_with_mocked_external_dependencies
23
+ monkeypatch.setattr(uut.scanners[0], "scan", mock_discovery_generator)
24
+ await uut.scan()
25
+ uut.publisher.clean_topics.assert_has_calls( # type: ignore[attr-defined]
26
+ [call(uut.scanners[0], None, force=True), call(uut.scanners[0], ANY, force=False)]
27
+ )
28
+ uut.publisher.publish_hass_state.assert_has_calls([call(d) for d in mock_discoveries]) # type: ignore[attr-defined]
29
+
30
+
31
+ async def test_main_loop(
32
+ app_with_mocked_external_dependencies: App,
33
+ mock_discovery_generator: AsyncGenerator[Discovery],
34
+ monkeypatch, # noqa: ANN001
35
+ ) -> None:
36
+ uut: App = app_with_mocked_external_dependencies
37
+ monkeypatch.setattr(uut.scanners[0], "scan", mock_discovery_generator)
38
+ start_time = time.time()
39
+ monkeypatch.setattr(uut.publisher, "is_available", lambda: time.time() > start_time + 3)
40
+
41
+ with pytest.raises(SystemExit):
42
+ await uut.main_loop()
43
+ uut.publisher.assert_has_calls( # type: ignore[attr-defined]
44
+ [
45
+ call.start(),
46
+ call.subscribe_hass_command(uut.scanners[0]),
47
+ call.stop(),
48
+ ]
49
+ ) # pyright: ignore[reportAttributeAccessIssue]
50
+
51
+
52
+ class DummyApp:
53
+ """Dummy App to replace updates2mqtt.app.App during tests.
54
+
55
+ Records the created instance on DummyApp.instance for assertions.
56
+ """
57
+
58
+ instance = None
59
+
60
+ def __init__(self) -> None:
61
+ DummyApp.instance = self
62
+ self.run_called = False
63
+ self.shutdown_called = False
64
+
65
+ async def main_loop(self) -> None:
66
+ # an async method to mirror the real App.run signature
67
+ self.run_called = True
68
+
69
+ def shutdown(self, *args: Any, **kwargs: Any) -> None: # noqa: ARG002
70
+ self.shutdown_called = True
71
+
72
+
73
+ def test_run_sets_signal_and_calls_asyncio_run(monkeypatch) -> None: # noqa: ANN001
74
+ calls: dict[str, Any] = {}
75
+
76
+ # Replace signal.signal so we capture its arguments
77
+ def fake_signal(sig: int, handler: types.MethodType) -> None:
78
+ calls["sig"] = sig
79
+ calls["handler"] = handler
80
+
81
+ monkeypatch.setattr(signal, "signal", fake_signal)
82
+
83
+ # Patch the App class in the updates2mqtt.app module to DummyApp
84
+ import updates2mqtt.app as app_module
85
+
86
+ monkeypatch.setattr(app_module, "App", DummyApp)
87
+
88
+ # Patch asyncio.run to record that it was called with a coroutine and debug flag
89
+ def fake_asyncio_run(coro: Coroutine, debug: bool = False) -> None:
90
+ calls["coro"] = coro
91
+ calls["debug"] = debug
92
+ return
93
+
94
+ monkeypatch.setattr(asyncio, "run", fake_asyncio_run)
95
+
96
+ # Execute the run function under test
97
+ run()
98
+
99
+ # Assertions:
100
+ # - signal.signal was called with SIGTERM and the DummyApp.instance.shutdown bound method
101
+ assert calls.get("sig") == signal.SIGTERM
102
+ assert DummyApp.instance is not None
103
+ assert calls.get("handler") == DummyApp.instance.shutdown
104
+ # debug flag forwarded as False in run()
105
+ assert calls.get("debug") is False
106
+ # - asyncio.run was called with a coroutine object (the result of DummyApp.instance.run())
107
+ coro = calls.get("coro")
108
+ assert isinstance(coro, types.CoroutineType)
109
+
110
+
111
+ def test_run_handles_asyncio_cancellederror(monkeypatch) -> None: # noqa: ANN001
112
+ # Ensure App is replaced so run() will create DummyApp without side effects
113
+ import updates2mqtt.app as app_module
114
+
115
+ monkeypatch.setattr(app_module, "App", DummyApp)
116
+
117
+ # Patch signal.signal to a noop to avoid altering test process handlers
118
+ monkeypatch.setattr(signal, "signal", lambda *args, **kwargs: None) # noqa: ARG005
119
+
120
+ # Patch asyncio.run to raise CancelledError to exercise the except branch
121
+ def raising_asyncio_run(_coro: Coroutine, debug: bool = False) -> NoReturn: # noqa: ARG001
122
+ raise asyncio.CancelledError()
123
+
124
+ monkeypatch.setattr(asyncio, "run", raising_asyncio_run)
125
+
126
+ # Call run(); should not raise CancelledError (handled inside run)
127
+ run()
128
+
129
+ # If we reached here, the CancelledError was handled; also ensure DummyApp was instantiated
130
+ assert DummyApp.instance is not None
@@ -10,7 +10,7 @@ from paho.mqtt.client import MQTTMessage
10
10
 
11
11
  from updates2mqtt.config import HomeAssistantConfig, MqttConfig, NodeConfig
12
12
  from updates2mqtt.model import Discovery, ReleaseProvider
13
- from updates2mqtt.mqtt import MqttClient
13
+ from updates2mqtt.mqtt import MqttPublisher
14
14
 
15
15
 
16
16
  @pytest.mark.parametrize("protocol", ["3", "3.1", "5", "?"])
@@ -20,7 +20,7 @@ def test_publish(mock_mqtt_client: Mock, protocol: str) -> None:
20
20
  node_config = NodeConfig()
21
21
 
22
22
  with patch.object(paho.mqtt.client.Client, "__new__", lambda *_args, **_kwargs: mock_mqtt_client):
23
- uut = MqttClient(config, node_config, hass_config)
23
+ uut = MqttPublisher(config, node_config, hass_config)
24
24
  uut.start()
25
25
 
26
26
  uut.publish("test.topic.123", {"foo": "a8", "bar": False})
@@ -35,7 +35,7 @@ async def test_handler(mock_mqtt_client: Mock) -> None:
35
35
  node_config = NodeConfig()
36
36
  node_config.name = "testing"
37
37
  with patch("updates2mqtt.mqtt.mqtt.Client", new=mock_mqtt_client):
38
- uut = MqttClient(config, node_config, hass_config)
38
+ uut = MqttPublisher(config, node_config, hass_config)
39
39
  uut.start(event_loop=asyncio.get_running_loop())
40
40
 
41
41
  provider = Mock(spec=ReleaseProvider)
@@ -63,7 +63,7 @@ async def test_execute_command_remote(mock_mqtt_client: Mock, mock_provider: Rel
63
63
  node_config = NodeConfig("TESTBED")
64
64
 
65
65
  with patch.object(paho.mqtt.client.Client, "__new__", lambda *_args, **_kwargs: mock_mqtt_client):
66
- uut = MqttClient(config, node_config, hass_config)
66
+ uut = MqttPublisher(config, node_config, hass_config)
67
67
  uut.start(event_loop=asyncio.get_running_loop())
68
68
 
69
69
  uut.subscribe_hass_command(mock_provider)
@@ -95,7 +95,7 @@ async def test_execute_command_local(mock_mqtt_client: Mock, mock_provider: Rele
95
95
  node_config = NodeConfig("TESTBED")
96
96
 
97
97
  with patch.object(paho.mqtt.client.Client, "__new__", lambda *_args, **_kwargs: mock_mqtt_client):
98
- uut = MqttClient(config, node_config, hass_config)
98
+ uut = MqttPublisher(config, node_config, hass_config)
99
99
 
100
100
  uut.start(event_loop=asyncio.get_running_loop())
101
101
 
@@ -127,7 +127,7 @@ async def test_stop(mock_mqtt_client: Mock, mock_provider: ReleaseProvider) -> N
127
127
  node_config = NodeConfig()
128
128
 
129
129
  with patch.object(paho.mqtt.client.Client, "__new__", lambda *_args, **_kwargs: mock_mqtt_client):
130
- uut = MqttClient(config, node_config, hass_config)
130
+ uut = MqttPublisher(config, node_config, hass_config)
131
131
 
132
132
  uut.start(event_loop=asyncio.get_running_loop())
133
133
 
@@ -202,6 +202,15 @@ version = "0.9.5"
202
202
  source = { registry = "https://pypi.org/simple" }
203
203
  sdist = { url = "https://files.pythonhosted.org/packages/f1/2a/8c3ac3d8bc94e6de8d7ae270bb5bc437b210bb9d6d9e46630c98f4abd20c/csscompressor-0.9.5.tar.gz", hash = "sha256:afa22badbcf3120a4f392e4d22f9fff485c044a1feda4a950ecc5eba9dd31a05", size = 237808, upload-time = "2017-11-26T21:13:08.238Z" }
204
204
 
205
+ [[package]]
206
+ name = "defusedxml"
207
+ version = "0.7.1"
208
+ source = { registry = "https://pypi.org/simple" }
209
+ sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" }
210
+ wheels = [
211
+ { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" },
212
+ ]
213
+
205
214
  [[package]]
206
215
  name = "distlib"
207
216
  version = "0.4.0"
@@ -234,6 +243,55 @@ wheels = [
234
243
  { url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" },
235
244
  ]
236
245
 
246
+ [[package]]
247
+ name = "flake8"
248
+ version = "7.3.0"
249
+ source = { registry = "https://pypi.org/simple" }
250
+ dependencies = [
251
+ { name = "mccabe" },
252
+ { name = "pycodestyle" },
253
+ { name = "pyflakes" },
254
+ ]
255
+ sdist = { url = "https://files.pythonhosted.org/packages/9b/af/fbfe3c4b5a657d79e5c47a2827a362f9e1b763336a52f926126aa6dc7123/flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872", size = 48326, upload-time = "2025-06-20T19:31:35.838Z" }
256
+ wheels = [
257
+ { url = "https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e", size = 57922, upload-time = "2025-06-20T19:31:34.425Z" },
258
+ ]
259
+
260
+ [[package]]
261
+ name = "flake8-html"
262
+ version = "0.4.3"
263
+ source = { registry = "https://pypi.org/simple" }
264
+ dependencies = [
265
+ { name = "flake8" },
266
+ { name = "jinja2" },
267
+ { name = "pygments" },
268
+ ]
269
+ sdist = { url = "https://files.pythonhosted.org/packages/3e/44/faae17d4b4e00c8c041e9b90b0ea546f3aef8b799212e71a7bb835ff51e1/flake8-html-0.4.3.tar.gz", hash = "sha256:8b870299620cc4a06f73644a1b4d457799abeca1cc914c62ae71ec5bf65c79a5", size = 13670, upload-time = "2022-12-06T19:06:20.505Z" }
270
+ wheels = [
271
+ { url = "https://files.pythonhosted.org/packages/c3/b0/bfd58118e2b9c1d0c5ac156c458c9a33cb599925b658df3ab038b55d30d7/flake8_html-0.4.3-py2.py3-none-any.whl", hash = "sha256:8f126748b1b0edd6cd39e87c6192df56e2f8655b0aa2bb00ffeac8cf27be4325", size = 13120, upload-time = "2022-12-06T19:06:18.917Z" },
272
+ ]
273
+
274
+ [[package]]
275
+ name = "genbadge"
276
+ version = "1.1.3"
277
+ source = { registry = "https://pypi.org/simple" }
278
+ dependencies = [
279
+ { name = "click" },
280
+ { name = "pillow" },
281
+ { name = "requests" },
282
+ { name = "setuptools" },
283
+ ]
284
+ sdist = { url = "https://files.pythonhosted.org/packages/88/08/686a720bd9f407a2b689c50a94e53b2d26f6ddc6f921ae45ec15c401ee67/genbadge-1.1.3.tar.gz", hash = "sha256:2292ea9cc20af4463dfde952c6b15544fdab9d6e50945f63a42cc400c521fa74", size = 138264, upload-time = "2025-11-24T14:55:01.342Z" }
285
+ wheels = [
286
+ { url = "https://files.pythonhosted.org/packages/40/cc/e67b1fe7a9d76a316e9149855a953c37c463caf1e351b1a0abf7f2fb9e38/genbadge-1.1.3-py2.py3-none-any.whl", hash = "sha256:6e4316c171c6f0f84becae4eb116258340bdc054458632abc622d36b8040655e", size = 101262, upload-time = "2025-11-24T14:54:59.925Z" },
287
+ ]
288
+
289
+ [package.optional-dependencies]
290
+ all = [
291
+ { name = "defusedxml" },
292
+ { name = "flake8-html" },
293
+ ]
294
+
237
295
  [[package]]
238
296
  name = "ghp-import"
239
297
  version = "2.1.0"
@@ -323,6 +381,15 @@ wheels = [
323
381
  { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
324
382
  ]
325
383
 
384
+ [[package]]
385
+ name = "icdiff"
386
+ version = "2.0.7"
387
+ source = { registry = "https://pypi.org/simple" }
388
+ sdist = { url = "https://files.pythonhosted.org/packages/fa/e4/43341832be5f2bcae71eb3ef08a07aaef9b74f74fe0b3675f62bd12057fe/icdiff-2.0.7.tar.gz", hash = "sha256:f79a318891adbf59a45e3a7694f5e1f18c5407065264637072ac8363b759866f", size = 16394, upload-time = "2023-08-21T15:00:55.742Z" }
389
+ wheels = [
390
+ { url = "https://files.pythonhosted.org/packages/7c/2a/b3178baa75a3ec75a33588252296c82a1332d2b83cd01061539b74bde9dd/icdiff-2.0.7-py3-none-any.whl", hash = "sha256:f05d1b3623223dd1c70f7848da7d699de3d9a2550b902a8234d9026292fb5762", size = 17018, upload-time = "2023-08-21T15:00:54.634Z" },
391
+ ]
392
+
326
393
  [[package]]
327
394
  name = "identify"
328
395
  version = "2.6.15"
@@ -441,6 +508,15 @@ wheels = [
441
508
  { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
442
509
  ]
443
510
 
511
+ [[package]]
512
+ name = "mccabe"
513
+ version = "0.7.0"
514
+ source = { registry = "https://pypi.org/simple" }
515
+ sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" }
516
+ wheels = [
517
+ { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" },
518
+ ]
519
+
444
520
  [[package]]
445
521
  name = "mdurl"
446
522
  version = "0.1.2"
@@ -824,6 +900,24 @@ wheels = [
824
900
  { url = "https://files.pythonhosted.org/packages/91/ed/1e347d85d05b37a8b9a039ca832e5747e1e5248d0bd66042783ef48b4a37/puremagic-1.30-py3-none-any.whl", hash = "sha256:5eeeb2dd86f335b9cfe8e205346612197af3500c6872dffebf26929f56e9d3c1", size = 43304, upload-time = "2025-07-04T18:48:34.801Z" },
825
901
  ]
826
902
 
903
+ [[package]]
904
+ name = "pycodestyle"
905
+ version = "2.14.0"
906
+ source = { registry = "https://pypi.org/simple" }
907
+ sdist = { url = "https://files.pythonhosted.org/packages/11/e0/abfd2a0d2efe47670df87f3e3a0e2edda42f055053c85361f19c0e2c1ca8/pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783", size = 39472, upload-time = "2025-06-20T18:49:48.75Z" }
908
+ wheels = [
909
+ { url = "https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d", size = 31594, upload-time = "2025-06-20T18:49:47.491Z" },
910
+ ]
911
+
912
+ [[package]]
913
+ name = "pyflakes"
914
+ version = "3.4.0"
915
+ source = { registry = "https://pypi.org/simple" }
916
+ sdist = { url = "https://files.pythonhosted.org/packages/45/dc/fd034dc20b4b264b3d015808458391acbf9df40b1e54750ef175d39180b1/pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58", size = 64669, upload-time = "2025-06-20T18:45:27.834Z" }
917
+ wheels = [
918
+ { url = "https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f", size = 63551, upload-time = "2025-06-20T18:45:26.937Z" },
919
+ ]
920
+
827
921
  [[package]]
828
922
  name = "pygments"
829
923
  version = "2.19.2"
@@ -1079,6 +1173,15 @@ wheels = [
1079
1173
  { url = "https://files.pythonhosted.org/packages/1d/d2/1637f4360ada6a368d3265bf39f2cf737a0aaab15ab520fc005903e883f8/ruff-0.14.7-py3-none-win_arm64.whl", hash = "sha256:be4d653d3bea1b19742fcc6502354e32f65cd61ff2fbdb365803ef2c2aec6228", size = 13609215, upload-time = "2025-11-28T20:55:15.375Z" },
1080
1174
  ]
1081
1175
 
1176
+ [[package]]
1177
+ name = "setuptools"
1178
+ version = "80.9.0"
1179
+ source = { registry = "https://pypi.org/simple" }
1180
+ sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" }
1181
+ wheels = [
1182
+ { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" },
1183
+ ]
1184
+
1082
1185
  [[package]]
1083
1186
  name = "six"
1084
1187
  version = "1.17.0"
@@ -1117,7 +1220,7 @@ wheels = [
1117
1220
 
1118
1221
  [[package]]
1119
1222
  name = "updates2mqtt"
1120
- version = "1.4.0"
1223
+ version = "1.4.1"
1121
1224
  source = { editable = "." }
1122
1225
  dependencies = [
1123
1226
  { name = "docker" },
@@ -1133,6 +1236,8 @@ dependencies = [
1133
1236
  [package.dev-dependencies]
1134
1237
  dev = [
1135
1238
  { name = "coverage" },
1239
+ { name = "genbadge", extra = ["all"] },
1240
+ { name = "icdiff" },
1136
1241
  { name = "pre-commit" },
1137
1242
  { name = "pytest" },
1138
1243
  { name = "pytest-asyncio" },
@@ -1169,6 +1274,8 @@ requires-dist = [
1169
1274
  [package.metadata.requires-dev]
1170
1275
  dev = [
1171
1276
  { name = "coverage" },
1277
+ { name = "genbadge", extras = ["all"] },
1278
+ { name = "icdiff" },
1172
1279
  { name = "pre-commit", specifier = ">=4.4.0" },
1173
1280
  { name = "pytest", specifier = ">=8.4.2" },
1174
1281
  { name = "pytest-asyncio", specifier = ">=1.2.0" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes