updates2mqtt 1.7.3__tar.gz → 1.8.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: updates2mqtt
3
- Version: 1.7.3
3
+ Version: 1.8.1
4
4
  Summary: System update and docker image notification and execution over MQTT
5
5
  Keywords: mqtt,docker,oci,container,updates,automation,home-assistant,homeassistant,selfhosting
6
6
  Author: jey burrows
@@ -72,12 +72,12 @@ Read the release notes, and optionally click *Update* to trigger a Docker *pull*
72
72
 
73
73
  Updates2MQTT perioidically checks for new versions of components being available, and publishes new version info to MQTT. HomeAssistant auto discovery is supported, so all updates can be seen in the same place as Home Assistant's own components and add-ins.
74
74
 
75
- Currently only Docker containers are supported, either via an image registry check (using either v1 Docker APIs or the OCI v2 API), or a git repo for source (see [Local Builds](local_builds.md)), with specific handling for Docker, Github Container Registry, Gitlab, Codeberg, Microsoft Container Registry and LinuxServer Registry, with adaptive behaviour to cope with most
75
+ Currently only Docker containers are supported, either via an image registry check (using either v1 Docker APIs or the OCI v2 API), or a git repo for source (see [Local Builds](local_builds.md)), with specific handling for Docker, Github Container Registry, Gitlab, Codeberg, Microsoft Container Registry, Quay and LinuxServer Registry, with adaptive behaviour to cope with most
76
76
  others. The design is modular, so other update sources can be added, at least for notification. The next anticipated is **apt** for Debian based systems.
77
77
 
78
78
  Components can also be updated, either automatically or triggered via MQTT, for example by hitting the *Install* button in the HomeAssistant update dialog. Icons and release notes can be specified for a better HA experience. See [Home Assistant Integration](home_assistant.md) for details.
79
79
 
80
- To get started, read the [Installation](installation.md) and [Configuration](configuration.md) pages.
80
+ To get started, read the [Installation](installation.md) and [Configuration](configuration/index.md) pages.
81
81
 
82
82
  For a quick spin, try this:
83
83
 
@@ -91,6 +91,9 @@ or without Docker, using [uv](https://docs.astral.sh/uv/)
91
91
  export MQTT_HOST=192.168.1.1;export MQTT_USER=user1;export MQTT_PASS=user1;uv run --with updates2mqtt python -m updates2mqtt
92
92
  ```
93
93
 
94
+ It also comes with a basic command line tool that will perform the analysis for a single running container, or fetch
95
+ manifests, JSON blobs and lists of tags from remote registries (known to work with GitHub, GitLab, Codeberg, Quay, LSCR and Microsoft MCR).
96
+
94
97
  ## Release Support
95
98
 
96
99
  Presently only Docker containers are supported, although others are planned, probably with priority for `apt`.
@@ -128,7 +131,7 @@ restarter:
128
131
  While `updates2mqtt` will discover and monitor all containers running under the Docker daemon,
129
132
  there are some options to make to those containers to tune how it works.
130
133
 
131
- These happen by adding environment variables to the containers, typically inside an `.env`
134
+ These happen by adding environment variables or docker labels to the containers, typically inside an `.env`
132
135
  file, or as `environment` options inside `docker-compose.yaml`.
133
136
 
134
137
  ### Automated updates
@@ -148,45 +151,6 @@ restarter:
148
151
  Automated updates can also apply to local builds, where a `git_repo_path` has been defined - if there are remote
149
152
  commits available to pull, then a `git pull`, `docker compose build` and `docker compose up` will be executed.
150
153
 
151
- ### Environment Variables
152
-
153
- The following environment variables can be used to configure containers for `updates2mqtt`:
154
-
155
- | Env Var | Description | Default |
156
- |----------------------------|----------------------------------------------------------------------------------------------|-----------------|
157
- | `UPD2MQTT_UPDATE` | Update mode, either `Passive` or `Auto`. If `Auto`, updates will be installed automatically. | `Passive` |
158
- | `UPD2MQTT_PICTURE` | URL to an icon to use in Home Assistant. | Docker logo URL |
159
- | `UPD2MQTT_RELNOTES` | URL to release notes for the package. | |
160
- | `UPD2MQTT_GIT_REPO_PATH` | Relative path to a local git repo if the image is built locally. | |
161
- | `UPD2MQTT_IGNORE` | If set to `True`, the container will be ignored by Updates2MQTT. | False |
162
- | |
163
- | `UPD2MQTT_VERSION_POLICY` | Change how version derived from container label or image hash, `Version`,`Digest`,`Version_Digest` with default of `Auto`|
164
- | `UPD2MQTT_REGISTRY_TOKEN` | Access token for authentication to container distribution API, as alternative to making a call to `token` service |
165
-
166
- ### Docker Labels
167
-
168
- Alternatively, use Docker labels
169
-
170
- | Label | Env Var |
171
- |--------------------------------|----------------------------|
172
- | `updates2mqtt.update` | `UPD2MQTT_UPDATE` |
173
- | `updates2mqtt.picture` | `UPD2MQTT_PCITURE` |
174
- | `updates2mqtt.relnotes` | `UPD2MQTT_RELNOTES` |
175
- | `updates2mqtt.git_repo_path` | `UPD2MQTT_GIT_REPO_PATH` |
176
- | `updates2mqtt.ignore` | `UPD2MQTT_IGNORE` |
177
- | `updates2mqtt.version_policy` | `UPD2MQTT_VERSION_POLICY` |
178
- | `updates2mqtt.registry_token` | `UPD2MQTT_REGISTRY_TOKEN` |
179
-
180
-
181
-
182
- ```yaml title="Example Compose Snippet"
183
- restarter:
184
- image: docker:cli
185
- command: ["/bin/sh", "-c", "while true; do sleep 86400; docker restart mailserver; done"]
186
- labels:
187
- updates2mqtt.relnotes: https://component.my.com/release_notes
188
- ```
189
-
190
154
 
191
155
  ## Related Projects
192
156
 
@@ -196,7 +160,7 @@ Other apps useful for self-hosting with the help of MQTT:
196
160
 
197
161
  Find more at [awesome-mqtt](https://github.com/rhizomatics/awesome-mqtt)
198
162
 
199
- For a more powerful Docker update manager, try [What's Up Docker](https://getwud.github.io/wud/)
163
+ For a more powerful Docker focussed update manager, try [What's Up Docker](https://getwud.github.io/wud/)
200
164
 
201
165
  ## Development
202
166
 
@@ -34,12 +34,12 @@ Read the release notes, and optionally click *Update* to trigger a Docker *pull*
34
34
 
35
35
  Updates2MQTT perioidically checks for new versions of components being available, and publishes new version info to MQTT. HomeAssistant auto discovery is supported, so all updates can be seen in the same place as Home Assistant's own components and add-ins.
36
36
 
37
- Currently only Docker containers are supported, either via an image registry check (using either v1 Docker APIs or the OCI v2 API), or a git repo for source (see [Local Builds](local_builds.md)), with specific handling for Docker, Github Container Registry, Gitlab, Codeberg, Microsoft Container Registry and LinuxServer Registry, with adaptive behaviour to cope with most
37
+ Currently only Docker containers are supported, either via an image registry check (using either v1 Docker APIs or the OCI v2 API), or a git repo for source (see [Local Builds](local_builds.md)), with specific handling for Docker, Github Container Registry, Gitlab, Codeberg, Microsoft Container Registry, Quay and LinuxServer Registry, with adaptive behaviour to cope with most
38
38
  others. The design is modular, so other update sources can be added, at least for notification. The next anticipated is **apt** for Debian based systems.
39
39
 
40
40
  Components can also be updated, either automatically or triggered via MQTT, for example by hitting the *Install* button in the HomeAssistant update dialog. Icons and release notes can be specified for a better HA experience. See [Home Assistant Integration](home_assistant.md) for details.
41
41
 
42
- To get started, read the [Installation](installation.md) and [Configuration](configuration.md) pages.
42
+ To get started, read the [Installation](installation.md) and [Configuration](configuration/index.md) pages.
43
43
 
44
44
  For a quick spin, try this:
45
45
 
@@ -53,6 +53,9 @@ or without Docker, using [uv](https://docs.astral.sh/uv/)
53
53
  export MQTT_HOST=192.168.1.1;export MQTT_USER=user1;export MQTT_PASS=user1;uv run --with updates2mqtt python -m updates2mqtt
54
54
  ```
55
55
 
56
+ It also comes with a basic command line tool that will perform the analysis for a single running container, or fetch
57
+ manifests, JSON blobs and lists of tags from remote registries (known to work with GitHub, GitLab, Codeberg, Quay, LSCR and Microsoft MCR).
58
+
56
59
  ## Release Support
57
60
 
58
61
  Presently only Docker containers are supported, although others are planned, probably with priority for `apt`.
@@ -90,7 +93,7 @@ restarter:
90
93
  While `updates2mqtt` will discover and monitor all containers running under the Docker daemon,
91
94
  there are some options to make to those containers to tune how it works.
92
95
 
93
- These happen by adding environment variables to the containers, typically inside an `.env`
96
+ These happen by adding environment variables or docker labels to the containers, typically inside an `.env`
94
97
  file, or as `environment` options inside `docker-compose.yaml`.
95
98
 
96
99
  ### Automated updates
@@ -110,45 +113,6 @@ restarter:
110
113
  Automated updates can also apply to local builds, where a `git_repo_path` has been defined - if there are remote
111
114
  commits available to pull, then a `git pull`, `docker compose build` and `docker compose up` will be executed.
112
115
 
113
- ### Environment Variables
114
-
115
- The following environment variables can be used to configure containers for `updates2mqtt`:
116
-
117
- | Env Var | Description | Default |
118
- |----------------------------|----------------------------------------------------------------------------------------------|-----------------|
119
- | `UPD2MQTT_UPDATE` | Update mode, either `Passive` or `Auto`. If `Auto`, updates will be installed automatically. | `Passive` |
120
- | `UPD2MQTT_PICTURE` | URL to an icon to use in Home Assistant. | Docker logo URL |
121
- | `UPD2MQTT_RELNOTES` | URL to release notes for the package. | |
122
- | `UPD2MQTT_GIT_REPO_PATH` | Relative path to a local git repo if the image is built locally. | |
123
- | `UPD2MQTT_IGNORE` | If set to `True`, the container will be ignored by Updates2MQTT. | False |
124
- | |
125
- | `UPD2MQTT_VERSION_POLICY` | Change how version derived from container label or image hash, `Version`,`Digest`,`Version_Digest` with default of `Auto`|
126
- | `UPD2MQTT_REGISTRY_TOKEN` | Access token for authentication to container distribution API, as alternative to making a call to `token` service |
127
-
128
- ### Docker Labels
129
-
130
- Alternatively, use Docker labels
131
-
132
- | Label | Env Var |
133
- |--------------------------------|----------------------------|
134
- | `updates2mqtt.update` | `UPD2MQTT_UPDATE` |
135
- | `updates2mqtt.picture` | `UPD2MQTT_PCITURE` |
136
- | `updates2mqtt.relnotes` | `UPD2MQTT_RELNOTES` |
137
- | `updates2mqtt.git_repo_path` | `UPD2MQTT_GIT_REPO_PATH` |
138
- | `updates2mqtt.ignore` | `UPD2MQTT_IGNORE` |
139
- | `updates2mqtt.version_policy` | `UPD2MQTT_VERSION_POLICY` |
140
- | `updates2mqtt.registry_token` | `UPD2MQTT_REGISTRY_TOKEN` |
141
-
142
-
143
-
144
- ```yaml title="Example Compose Snippet"
145
- restarter:
146
- image: docker:cli
147
- command: ["/bin/sh", "-c", "while true; do sleep 86400; docker restart mailserver; done"]
148
- labels:
149
- updates2mqtt.relnotes: https://component.my.com/release_notes
150
- ```
151
-
152
116
 
153
117
  ## Related Projects
154
118
 
@@ -158,7 +122,7 @@ Other apps useful for self-hosting with the help of MQTT:
158
122
 
159
123
  Find more at [awesome-mqtt](https://github.com/rhizomatics/awesome-mqtt)
160
124
 
161
- For a more powerful Docker update manager, try [What's Up Docker](https://getwud.github.io/wud/)
125
+ For a more powerful Docker focussed update manager, try [What's Up Docker](https://getwud.github.io/wud/)
162
126
 
163
127
  ## Development
164
128
 
@@ -7,7 +7,7 @@ authors = [
7
7
  ]
8
8
 
9
9
  requires-python = ">=3.13"
10
- version = "1.7.3"
10
+ version = "1.8.1"
11
11
  license="Apache-2.0"
12
12
  keywords=["mqtt", "docker", "oci","container","updates", "automation","home-assistant","homeassistant","selfhosting"]
13
13
 
@@ -60,7 +60,8 @@ dev = [
60
60
  "pytest-subprocess>=1.5.3",
61
61
  "coverage",
62
62
  "icdiff",
63
- "genbadge[all]"
63
+ "genbadge[all]",
64
+ "actionlint-py"
64
65
  ]
65
66
  docs=[
66
67
  "mkdocs",
@@ -69,12 +70,11 @@ docs=[
69
70
  "mkdocs-autorefs",
70
71
  "mkdocs-pagetree-plugin",
71
72
  "mkdocs-coverage",
72
- "mkdocstrings[python]",
73
73
  "pymdown-extensions",
74
- "mkdocs-git-revision-date-localized-plugin",
75
74
  "mkdocs-meta-descriptions-plugin",
76
75
  "pngquant",
77
- "mkdocs-mermaid2-plugin"
76
+ "mkdocs-mermaid2-plugin",
77
+ "mkdocs-htmlproofer-plugin"
78
78
  ]
79
79
 
80
80
  [build-system]
@@ -21,7 +21,6 @@ from .mqtt import MqttPublisher
21
21
  log = structlog.get_logger()
22
22
 
23
23
  CONF_FILE = Path("conf/config.yaml")
24
- PKG_INFO_FILE = Path("./common_packages.yaml")
25
24
  UPDATE_INTERVAL = 60 * 60 * 4
26
25
 
27
26
  # #TODO:
@@ -56,7 +55,15 @@ class App:
56
55
  self.scan_count: int = 0
57
56
  self.last_scan: str | None = None
58
57
  if self.cfg.docker.enabled:
59
- self.scanners.append(DockerProvider(self.cfg.docker, self.cfg.node, self.self_bounce))
58
+ self.scanners.append(
59
+ DockerProvider(
60
+ self.cfg.docker,
61
+ self.cfg.node,
62
+ packages=self.cfg.packages,
63
+ github_cfg=self.cfg.github,
64
+ self_bounce=self.self_bounce,
65
+ )
66
+ )
60
67
  self.stopped = Event()
61
68
  self.healthcheck_topic = self.cfg.node.healthcheck.topic_template.format(node_name=self.cfg.node.name)
62
69
 
@@ -71,9 +78,6 @@ class App:
71
78
  session = uuid.uuid4().hex
72
79
  for scanner in self.scanners:
73
80
  slog = log.bind(source_type=scanner.source_type, session=session)
74
- slog.info("Cleaning topics before scan")
75
- if self.scan_count == 0:
76
- await self.publisher.clean_topics(scanner, None, force=True)
77
81
  if self.stopped.is_set():
78
82
  break
79
83
  slog.info("Scanning ...")
@@ -84,7 +88,7 @@ class App:
84
88
  if self.stopped.is_set():
85
89
  slog.debug("Breaking scan loop on stopped event")
86
90
  break
87
- await self.publisher.clean_topics(scanner, session, force=False)
91
+ await self.publisher.clean_topics(scanner)
88
92
  self.scan_count += 1
89
93
  slog.info(f"Scan #{self.scan_count} complete")
90
94
  self.last_scan_timestamp = datetime.now(UTC).isoformat()
@@ -4,7 +4,7 @@ import structlog
4
4
  from omegaconf import DictConfig, OmegaConf
5
5
  from rich import print_json
6
6
 
7
- from updates2mqtt.config import DockerConfig, NodeConfig, RegistryConfig
7
+ from updates2mqtt.config import DockerConfig, GitHubConfig, NodeConfig, RegistryConfig
8
8
  from updates2mqtt.helpers import Throttler
9
9
  from updates2mqtt.integrations.docker import DockerProvider
10
10
  from updates2mqtt.integrations.docker_enrich import (
@@ -24,18 +24,20 @@ log = structlog.get_logger()
24
24
  """
25
25
  Super simple CLI
26
26
 
27
- python updates2mqtt.cli container=frigate
27
+ Command can be `container`,`tags`,`manifest` or `blob`
28
28
 
29
- python updates2mqtt.cli container=frigate api=docker_client log_level=DEBUG
29
+ * `container=container-name`
30
+ * `container=hash`
31
+ * `tags=ghcr.io/
32
+ * `blob=mcr.microsoft.com/dotnet/sdk:latest`
33
+ * `tags=quay.io/linuxserver.io/babybuddy`
34
+ * `blob=ghcr.io/blakeblackshear/frigate@sha256:759c36ee869e3e60258350a2e221eae1a4ba1018613e0334f1bc84eb09c4bbbc`
30
35
 
31
- ython3 updates2mqtt/cli.py blob=ghcr.io/homarr-labs/homarr@sha256:af79a3339de5ed8ef7f5a0186ff3deb86f40b213ba75249291f2f68aef082a25 | jq '.config.Labels'
36
+ In addition, a `log_level=DEBUG` or other level can be added, `github_token` to try a personal access
37
+ token for GitHub release info retrieval, or `api=docker_client` to use the older API (defaults to `api=OCI_V2`)
32
38
 
33
- python3 updates2mqtt/cli.py manifest=ghcr.io/blakeblackshear/frigate:stable
34
39
 
35
- python3 updates2mqtt/cli.py blob=ghcr.io/blakeblackshear/frigate@sha256:ef8d56a7d50b545af176e950ce328aec7f0b7bc5baebdca189fe661d97924980
36
-
37
- python3 updates2mqtt/cli.py manifest=ghcr.io/blakeblackshear/frigate@sha256:c68fd78fd3237c9ba81b5aa927f17b54f46705990f43b4b5d5596cfbbb626af4
38
- """ # noqa: E501
40
+ """
39
41
 
40
42
  OCI_MANIFEST_TYPES: list[str] = [
41
43
  "application/vnd.oci.image.manifest.v1+json",
@@ -91,7 +93,9 @@ ALL_OCI_MEDIA_TYPES: list[str] = (
91
93
  )
92
94
 
93
95
 
94
- def dump_url(doc_type: str, img_ref: str) -> None:
96
+ def dump_url(doc_type: str, img_ref: str, cli_conf: DictConfig) -> None:
97
+ structlog.configure(wrapper_class=structlog.make_filtering_bound_logger(cli_conf.get("log_level", "WARNING")))
98
+
95
99
  lookup = ContainerDistributionAPIVersionLookup(Throttler(), RegistryConfig())
96
100
  img_info = DockerImageInfo(img_ref)
97
101
  if not img_info.index_name or not img_info.name:
@@ -110,35 +114,49 @@ def dump_url(doc_type: str, img_ref: str) -> None:
110
114
  log.warning("No tag or digest found in %s", img_ref)
111
115
  return
112
116
  url = f"https://{api_host}/v2/{img_info.name}/manifests/{img_info.tag_or_digest}"
117
+ elif doc_type == "tags":
118
+ url = f"https://{api_host}/v2/{img_info.name}/tags/list"
113
119
  else:
114
120
  return
115
121
 
116
122
  token: str | None = lookup.fetch_token(img_info.index_name, img_info.name)
117
123
 
118
124
  response: Response | None = fetch_url(url, bearer_token=token, follow_redirects=True, response_type=ALL_OCI_MEDIA_TYPES)
119
- if response:
125
+ if response and response.is_error:
126
+ log.warning(f"{response.status_code}: {url}")
127
+ log.warning(response.text)
128
+ elif response and response.is_success:
120
129
  log.debug(f"{response.status_code}: {url}")
121
130
  log.debug("HEADERS")
122
131
  for k, v in response.headers.items():
123
132
  log.debug(f"{k}: {v}")
124
133
  log.debug("CONTENTS")
134
+
125
135
  print_json(response.text)
126
136
 
127
137
 
128
138
  def main() -> None:
129
139
  # will be a proper cli someday
130
140
  cli_conf: DictConfig = OmegaConf.from_cli()
131
- structlog.configure(wrapper_class=structlog.make_filtering_bound_logger(cli_conf.get("log_level", "WARNING")))
132
141
 
133
142
  if cli_conf.get("blob"):
134
- dump_url("blob", cli_conf.get("blob"))
143
+ dump_url("blob", cli_conf.get("blob"), cli_conf)
135
144
  elif cli_conf.get("manifest"):
136
- dump_url("manifest", cli_conf.get("manifest"))
145
+ dump_url("manifest", cli_conf.get("manifest"), cli_conf)
146
+ elif cli_conf.get("tags"):
147
+ dump_url("tags", cli_conf.get("tags"), cli_conf)
137
148
 
138
149
  else:
150
+ structlog.configure(wrapper_class=structlog.make_filtering_bound_logger(cli_conf.get("log_level", "INFO")))
151
+
139
152
  docker_scanner = DockerProvider(
140
- DockerConfig(registry=RegistryConfig(api=cli_conf.get("api", "OCI_V2"))), NodeConfig(), None
153
+ DockerConfig(registry=RegistryConfig(api=cli_conf.get("api", "OCI_V2"))),
154
+ NodeConfig(),
155
+ packages={},
156
+ github_cfg=GitHubConfig(access_token=cli_conf.get("github_token")),
157
+ self_bounce=None,
141
158
  )
159
+ docker_scanner.initialize()
142
160
  discovery: Discovery | None = docker_scanner.rescan(
143
161
  Discovery(docker_scanner, cli_conf.get("container", "frigate"), "cli", "manual")
144
162
  )
@@ -67,6 +67,11 @@ class MqttConfig:
67
67
  protocol: str = "${oc.env:MQTT_VERSION,3.11}"
68
68
 
69
69
 
70
+ @dataclass
71
+ class GitHubConfig:
72
+ access_token: str | None = None
73
+
74
+
70
75
  @dataclass
71
76
  class MetadataSourceConfig:
72
77
  enabled: bool = True
@@ -84,6 +89,21 @@ class VersionPolicy(StrEnum):
84
89
  VERSION = "VERSION"
85
90
  DIGEST = "DIGEST"
86
91
  VERSION_DIGEST = "VERSION_DIGEST"
92
+ TIMESTAMP = "TIMESTAMP"
93
+
94
+
95
+ @dataclass
96
+ class DockerPackageUpdateInfo:
97
+ image_name: str = MISSING # untagged image ref
98
+ version_policy: VersionPolicy = VersionPolicy.AUTO
99
+
100
+
101
+ @dataclass
102
+ class PackageUpdateInfo:
103
+ docker: DockerPackageUpdateInfo | None = field(default_factory=DockerPackageUpdateInfo)
104
+ logo_url: str | None = None
105
+ release_notes_url: str | None = None
106
+ source_repo_url: str | None = None
87
107
 
88
108
 
89
109
  @dataclass
@@ -149,25 +169,14 @@ class Config:
149
169
  mqtt: MqttConfig = field(default_factory=MqttConfig) # pyright: ignore[reportArgumentType, reportCallIssue]
150
170
  homeassistant: HomeAssistantConfig = field(default_factory=HomeAssistantConfig)
151
171
  docker: DockerConfig = field(default_factory=DockerConfig)
172
+ github: GitHubConfig = field(default_factory=GitHubConfig)
152
173
  scan_interval: int = 60 * 60 * 3
174
+ packages: dict[str, PackageUpdateInfo] = field(default_factory=dict)
153
175
 
154
176
 
155
177
  @dataclass
156
- class DockerPackageUpdateInfo:
157
- image_name: str = MISSING # untagged image ref
158
-
159
-
160
- @dataclass
161
- class PackageUpdateInfo:
162
- docker: DockerPackageUpdateInfo | None = field(default_factory=DockerPackageUpdateInfo)
163
- logo_url: str | None = None
164
- release_notes_url: str | None = None
165
- source_repo_url: str | None = None
166
-
167
-
168
- @dataclass
169
- class UpdateInfoConfig:
170
- common_packages: dict[str, PackageUpdateInfo] = field(default_factory=lambda: {})
178
+ class CommonPackages:
179
+ common_packages: dict[str, PackageUpdateInfo] = field(default_factory=dict)
171
180
 
172
181
 
173
182
  class IncompleteConfigException(BaseException):
@@ -188,13 +197,13 @@ def load_app_config(conf_file_path: Path, return_invalid: bool = False) -> Confi
188
197
  try:
189
198
  log.debug(f"Creating config directory {conf_file_path.parent} if not already present")
190
199
  conf_file_path.parent.mkdir(parents=True, exist_ok=True)
191
- except Exception:
192
- log.warning("Unable to create config directory", path=conf_file_path.parent)
200
+ except Exception as e:
201
+ log.warning("Unable to create config directory: %s", e, path=conf_file_path.parent)
193
202
  try:
194
203
  conf_file_path.write_text(OmegaConf.to_yaml(base_cfg))
195
204
  log.info(f"Auto-generated a new config file at {conf_file_path}")
196
- except Exception:
197
- log.warning("Unable to write config file", path=conf_file_path)
205
+ except Exception as e:
206
+ log.warning("Unable to write config file: %s", e, path=conf_file_path)
198
207
  cfg = base_cfg
199
208
  else:
200
209
  cfg = base_cfg
@@ -145,8 +145,8 @@ class APIStats:
145
145
  """Log line friendly string summary"""
146
146
  return (
147
147
  f"fetches: {self.fetches}, cache ratio: {self.hit_ratio():.2%}, revalidated: {self.revalidated}, "
148
- + f"errors: {', '.join(f'{status_code}:{fails}' for status_code, fails in self.failed.items())}, "
149
- + f"oldest cache hit: {self.max_cache_age:.2f}, avg elapsed: {self.average_elapsed()}"
148
+ + f"errors: {', '.join(f'{status_code}:{fails}' for status_code, fails in self.failed.items()) or '0'}, "
149
+ + f"oldest cache hit: {self.max_cache_age:.2f}s, avg elapsed: {self.average_elapsed()}s"
150
150
  )
151
151
 
152
152
 
@@ -196,8 +196,9 @@ def fetch_url(
196
196
  allow_stale=allow_stale,
197
197
  )
198
198
  )
199
+ log_headers: list[tuple[str, str]] = [h for h in headers if len(h) > 1 and h[0] != "Authorization"]
199
200
  with SyncCacheClient(headers=headers, follow_redirects=follow_redirects, policy=cache_policy) as client:
200
- log.debug(f"Fetching URL {url}, redirects={follow_redirects}, headers={headers}, cache_ttl={cache_ttl}")
201
+ log.debug(f"Fetching URL {url}, redirects={follow_redirects}, headers={log_headers}, cache_ttl={cache_ttl}")
201
202
  response: Response = client.request(method=method, url=url, extensions={"hishel_ttl": cache_ttl})
202
203
  cache_metadata: CacheMetadata = CacheMetadata(response)
203
204
  if not response.is_success:
@@ -221,6 +222,45 @@ def fetch_url(
221
222
  return None
222
223
 
223
224
 
224
- def validate_url(url: str, cache_ttl: int = 300) -> bool:
225
+ def validate_url(url: str, cache_ttl: int = 1500) -> bool:
225
226
  response: Response | None = fetch_url(url, method="HEAD", cache_ttl=cache_ttl, follow_redirects=True)
226
227
  return response is not None and response.status_code != 404
228
+
229
+
230
+ def sanitize_name(name: str, replacement: str = "_", max_len: int = 64) -> str:
231
+ """Strict sanitization that removes/replaces common problematic characters for MQTT or HA
232
+
233
+ - Replaces spaces with underscores
234
+ - Removes control characters
235
+ - Ensures alphanumeric safety for broader compatibility
236
+
237
+ Args:
238
+ name: The topic component string to sanitize
239
+ replacement: Character to replace invalid characters with (default: "_")
240
+ max_len: Largest acceptable name size
241
+
242
+ Returns:
243
+ Sanitized topic string safe for most MQTT brokers
244
+
245
+ """
246
+ if not name:
247
+ raise ValueError("Name cannot be empty")
248
+ orig_name: str = name
249
+ name = re.sub(r"[^A-Za-z0-9_\-\.]+", replacement, name)
250
+
251
+ # Replace multiple consecutive replacement chars with single one
252
+ if replacement:
253
+ pattern = re.escape(replacement) + "+"
254
+ name = re.sub(pattern, replacement, name)
255
+
256
+ # Trim to max length
257
+ topic_bytes = name.encode("utf-8")
258
+ if len(topic_bytes) > max_len:
259
+ name = topic_bytes[:max_len].decode("utf-8", errors="ignore")
260
+
261
+ if not name:
262
+ raise ValueError("Topic became empty after sanitization")
263
+ if name != orig_name:
264
+ log.info("Component name %s changed to %s for MQTT/HA compatibility", orig_name, name)
265
+
266
+ return name