updates2mqtt 1.7.3__py3-none-any.whl → 1.8.1__py3-none-any.whl

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.
updates2mqtt/app.py CHANGED
@@ -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()
updates2mqtt/cli.py CHANGED
@@ -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
  )
updates2mqtt/config.py CHANGED
@@ -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
updates2mqtt/helpers.py CHANGED
@@ -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
@@ -19,6 +19,7 @@ from updates2mqtt.config import (
19
19
  UNKNOWN_VERSION,
20
20
  VERSION_RE,
21
21
  DockerConfig,
22
+ GitHubConfig,
22
23
  NodeConfig,
23
24
  PackageUpdateInfo,
24
25
  PublishPolicy,
@@ -127,6 +128,8 @@ class DockerProvider(ReleaseProvider):
127
128
  self,
128
129
  cfg: DockerConfig,
129
130
  node_cfg: NodeConfig,
131
+ packages: dict[str, PackageUpdateInfo] | None = None,
132
+ github_cfg: GitHubConfig | None = None,
130
133
  self_bounce: Event | None = None,
131
134
  ) -> None:
132
135
  super().__init__(node_cfg, "docker")
@@ -136,8 +139,9 @@ class DockerProvider(ReleaseProvider):
136
139
  # TODO: refresh discovered packages periodically
137
140
  self.throttler = Throttler(self.cfg.default_api_backoff, self.log, self.stopped)
138
141
  self.self_bounce: Event | None = self_bounce
142
+
139
143
  self.pkg_enrichers: list[PackageEnricher] = [
140
- CommonPackageEnricher(self.cfg),
144
+ CommonPackageEnricher(self.cfg, packages),
141
145
  LinuxServerIOPackageEnricher(self.cfg),
142
146
  DefaultPackageEnricher(self.cfg),
143
147
  ]
@@ -145,12 +149,13 @@ class DockerProvider(ReleaseProvider):
145
149
  self.client, self.throttler, self.cfg.registry, self.cfg.default_api_backoff
146
150
  )
147
151
  self.registry_image_lookup = ContainerDistributionAPIVersionLookup(self.throttler, self.cfg.registry)
148
- self.release_enricher = SourceReleaseEnricher()
152
+ self.release_enricher = SourceReleaseEnricher(github_cfg)
149
153
  self.local_info_builder = LocalContainerInfo()
150
154
 
151
155
  def initialize(self) -> None:
152
156
  for enricher in self.pkg_enrichers:
153
157
  enricher.initialize()
158
+ self.log.debug("Docker provider initialized")
154
159
 
155
160
  def update(self, discovery: Discovery) -> bool:
156
161
  logger: Any = self.log.bind(container=discovery.name, action="update")
@@ -267,11 +272,11 @@ class DockerProvider(ReleaseProvider):
267
272
  )
268
273
 
269
274
  def rescan(self, discovery: Discovery) -> Discovery | None:
270
- logger = self.log.bind(container=discovery.name, action="rescan")
275
+ logger: Any = self.log.bind(container=discovery.name, action="rescan")
271
276
  try:
272
277
  c: Container = self.client.containers.get(discovery.name)
273
278
  if c:
274
- rediscovery = self.analyze(c, discovery.session, previous_discovery=discovery)
279
+ rediscovery: Discovery | None = self.analyze(c, discovery.session, previous_discovery=discovery)
275
280
  if rediscovery and not rediscovery.throttled:
276
281
  self.discoveries[rediscovery.name] = rediscovery
277
282
  return rediscovery
@@ -296,9 +301,7 @@ class DockerProvider(ReleaseProvider):
296
301
  if customization.ignore:
297
302
  logger.info("Container ignored due to UPD2MQTT_IGNORE setting")
298
303
  return None
299
- version_policy: VersionPolicy = (
300
- self.cfg.version_policy if not customization.version_policy else customization.version_policy
301
- )
304
+
302
305
  if customization.update == UpdatePolicy.AUTO:
303
306
  logger.debug("Auto update policy detected")
304
307
  update_policy: UpdatePolicy = customization.update or UpdatePolicy.PASSIVE
@@ -308,6 +311,20 @@ class DockerProvider(ReleaseProvider):
308
311
  local_info, service_info = self.local_info_builder.build_image_info(c)
309
312
  pkg_info: PackageUpdateInfo = self.default_metadata(local_info)
310
313
 
314
+ version_policy: VersionPolicy
315
+ if customization.version_policy:
316
+ logger.debug("Overriding version_policy to local customization: %s", customization.version_policy)
317
+ version_policy = customization.version_policy
318
+ else:
319
+ if self.cfg.version_policy == VersionPolicy.AUTO and pkg_info.docker:
320
+ logger.debug(
321
+ "Version policy, pkg level %s, config level: %s", pkg_info.docker.version_policy, self.cfg.version_policy
322
+ )
323
+ version_policy = pkg_info.docker.version_policy or self.cfg.version_policy
324
+ else:
325
+ logger.debug("Version policy, fixed config level: %s", self.cfg.version_policy)
326
+ version_policy = self.cfg.version_policy
327
+
311
328
  try:
312
329
  service_info.git_repo_path = customization.git_repo_path
313
330
 
@@ -516,7 +533,7 @@ class DockerProvider(ReleaseProvider):
516
533
 
517
534
  def default_metadata(self, image_info: DockerImageInfo) -> PackageUpdateInfo:
518
535
  for enricher in self.pkg_enrichers:
519
- pkg_info = enricher.enrich(image_info)
536
+ pkg_info: PackageUpdateInfo | None = enricher.enrich(image_info)
520
537
  if pkg_info is not None:
521
538
  return pkg_info
522
539
  raise ValueError("No enricher could provide metadata, not even default enricher")
@@ -583,30 +600,41 @@ def select_versions(version_policy: VersionPolicy, installed: DockerImageInfo, l
583
600
  basis("version-digest"),
584
601
  )
585
602
 
603
+ if (
604
+ version_policy == VersionPolicy.TIMESTAMP
605
+ and installed.created
606
+ and latest.created
607
+ and (
608
+ (latest.created > installed.created and latest.short_digest != installed.short_digest)
609
+ or (latest.created == installed.created and latest.short_digest == installed.short_digest)
610
+ )
611
+ ):
612
+ return installed.created, latest.created, basis("timestamp")
613
+
586
614
  phase = 1
587
615
  if version_policy == VersionPolicy.AUTO and (
588
616
  (installed.version == latest.version and installed.short_digest == latest.short_digest)
589
617
  or (installed.version != latest.version and installed.short_digest != latest.short_digest)
590
618
  ):
591
- # detect semver, or casual semver (e.g. v1.030)
619
+ # detect semver, or v semver (e.g. v1.030)
592
620
  # only use this if both version and digest are consistently agreeing or disagreeing
593
621
  # if the strict conditions work, people see nice version numbers on screen rather than hashes
594
622
  if (
595
623
  installed.version
596
- and re.match(SEMVER_RE, installed.version or "")
624
+ and re.fullmatch(SEMVER_RE, installed.version or "")
597
625
  and latest.version
598
- and re.match(SEMVER_RE, latest.version or "")
626
+ and re.fullmatch(SEMVER_RE, latest.version or "")
599
627
  ):
600
628
  # Smells like semver, override if not using version_policy
601
629
  return installed.version, latest.version, basis("semver")
602
630
  if (
603
631
  installed.version
604
- and re.match(VERSION_RE, installed.version or "")
632
+ and re.fullmatch(VERSION_RE, installed.version or "")
605
633
  and latest.version
606
- and re.match(VERSION_RE, latest.version or "")
634
+ and re.fullmatch(VERSION_RE, latest.version or "")
607
635
  ):
608
636
  # Smells like casual semver, override if not using version_policy
609
- return installed.version, latest.version, basis("causualver")
637
+ return installed.version, latest.version, basis("casualver")
610
638
 
611
639
  # AUTO or fallback
612
640
  phase = 2
@@ -629,6 +657,15 @@ def select_versions(version_policy: VersionPolicy, installed: DockerImageInfo, l
629
657
 
630
658
  # Fall back to digests, image or repo index
631
659
  phase = 4
660
+ if (
661
+ installed.created
662
+ and latest.created
663
+ and (
664
+ (latest.created > installed.created and latest.short_digest != installed.short_digest)
665
+ or (latest.created == installed.created and latest.short_digest == installed.short_digest)
666
+ )
667
+ ):
668
+ return installed.created, latest.created, basis("timestamp")
632
669
  if installed_digest_available and latest_digest_available:
633
670
  return installed.short_digest, latest.short_digest, basis("digest") # type: ignore[return-value]
634
671
  if installed.version and not latest.version and not latest.short_digest and not latest.repo_digest:
@@ -655,7 +692,8 @@ def select_versions(version_policy: VersionPolicy, installed: DockerImageInfo, l
655
692
  return condense_repo_id(latest), condense_repo_id(latest), basis("repo-digest")
656
693
 
657
694
  if installed_digest_available and not latest_digest_available:
658
- return installed.short_digest, latest.short_digest, basis("digest") # type: ignore[return-value]
695
+ # no new digest, so latest is the current
696
+ return installed.short_digest, installed.short_digest, basis("digest") # type: ignore[return-value]
659
697
 
660
698
  log.warn("No versions can be determined for %s", installed.ref)
661
699
  phase = 999