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.
@@ -14,6 +14,7 @@ from updates2mqtt.model import DiscoveryArtefactDetail, DiscoveryInstallationDet
14
14
 
15
15
  if typing.TYPE_CHECKING:
16
16
  from docker.models.images import RegistryData
17
+ from omegaconf.dictconfig import DictConfig
17
18
  from http import HTTPStatus
18
19
 
19
20
  import docker
@@ -21,23 +22,35 @@ import docker.errors
21
22
 
22
23
  from updates2mqtt.config import (
23
24
  PKG_INFO_FILE,
25
+ CommonPackages,
24
26
  DockerConfig,
25
27
  DockerPackageUpdateInfo,
28
+ GitHubConfig,
26
29
  PackageUpdateInfo,
27
30
  RegistryConfig,
28
- UpdateInfoConfig,
31
+ VersionPolicy,
29
32
  )
30
33
 
31
- log = structlog.get_logger()
34
+ log: Any = structlog.get_logger()
32
35
 
33
36
  SOURCE_PLATFORM_GITHUB = "GitHub"
34
37
  SOURCE_PLATFORM_CODEBERG = "CodeBerg"
35
- SOURCE_PLATFORMS = {SOURCE_PLATFORM_GITHUB: r"https://github.com/.*"}
38
+ SOURCE_PLATFORM_GITLAB = "GitLab"
39
+ SOURCE_PLATFORMS = {
40
+ SOURCE_PLATFORM_GITHUB: r"https://github.com/.*",
41
+ SOURCE_PLATFORM_GITLAB: r"https://gitlab.com/.*",
42
+ SOURCE_PLATFORM_CODEBERG: r"https://codeberg.org/.*",
43
+ }
36
44
  DIFF_URL_TEMPLATES = {
37
45
  SOURCE_PLATFORM_GITHUB: "{repo}/commit/{revision}",
38
46
  }
39
- RELEASE_URL_TEMPLATES = {SOURCE_PLATFORM_GITHUB: "{repo}/releases/tag/{version}"}
40
- UNKNOWN_RELEASE_URL_TEMPLATES = {SOURCE_PLATFORM_GITHUB: "{repo}/releases"}
47
+ RELEASE_URL_TEMPLATES = {
48
+ SOURCE_PLATFORM_GITHUB: "{repo}/releases/tag/{version}",
49
+ }
50
+ UNKNOWN_RELEASE_URL_TEMPLATES = {
51
+ SOURCE_PLATFORM_GITHUB: "{repo}/releases",
52
+ SOURCE_PLATFORM_GITLAB: "{repo}/container_registry",
53
+ }
41
54
  MISSING_VAL = "**MISSING**"
42
55
  UNKNOWN_REGISTRY = "**UNKNOWN_REGISTRY**"
43
56
 
@@ -45,18 +58,27 @@ HEADER_DOCKER_DIGEST = "docker-content-digest"
45
58
  HEADER_DOCKER_API = "docker-distribution-api-version"
46
59
 
47
60
  TOKEN_URL_TEMPLATE = "https://{auth_host}/token?scope=repository:{image_name}:pull&service={service}" # noqa: S105 # nosec
48
- REGISTRIES = {
49
- # registry: (auth_host, api_host, service, url_template)
50
- "docker.io": ("auth.docker.io", "registry-1.docker.io", "registry.docker.io", TOKEN_URL_TEMPLATE),
51
- "mcr.microsoft.com": (None, "mcr.microsoft.com", "mcr.microsoft.com", TOKEN_URL_TEMPLATE),
52
- "ghcr.io": ("ghcr.io", "ghcr.io", "ghcr.io", TOKEN_URL_TEMPLATE),
53
- "lscr.io": ("ghcr.io", "lscr.io", "ghcr.io", TOKEN_URL_TEMPLATE),
54
- "codeberg.org": ("codeberg.org", "codeberg.org", "container_registry", TOKEN_URL_TEMPLATE),
61
+
62
+ REGISTRIES: dict[str, tuple[str | None, str, str, str | None, str | None]] = {
63
+ # registry: (auth_host, api_host, service, url_template, repo_template)
64
+ "docker.io": ("auth.docker.io", "registry-1.docker.io", "registry.docker.io", TOKEN_URL_TEMPLATE, None),
65
+ "mcr.microsoft.com": (None, "mcr.microsoft.com", "mcr.microsoft.com", None, None),
66
+ "quay.io": (None, "quay.io", "quay.io", TOKEN_URL_TEMPLATE, None),
67
+ "ghcr.io": ("ghcr.io", "ghcr.io", "ghcr.io", TOKEN_URL_TEMPLATE, "https://github.com/{image_name}"),
68
+ "lscr.io": ("ghcr.io", "lscr.io", "ghcr.io", TOKEN_URL_TEMPLATE, None),
69
+ "codeberg.org": (
70
+ "codeberg.org",
71
+ "codeberg.org",
72
+ "container_registry",
73
+ TOKEN_URL_TEMPLATE,
74
+ "https://codeberg.org/{image_name}",
75
+ ),
55
76
  "registry.gitlab.com": (
56
77
  "www.gitlab.com",
57
78
  "registry.gitlab.com",
58
79
  "container_registry",
59
80
  "https://{auth_host}/jwt/auth?service={service}&scope=repository:{image_name}:pull&offline_token=true&client_id=docker",
81
+ "https://gitlab.com/{image_name}",
60
82
  ),
61
83
  }
62
84
 
@@ -83,7 +105,9 @@ class DockerImageInfo(DiscoveryArtefactDetail):
83
105
  annotations: dict[str, Any] | None = None,
84
106
  platform: str | None = None, # test harness simplification
85
107
  version: str | None = None, # test harness simplification
108
+ created: str | None = None,
86
109
  ) -> None:
110
+ super().__init__()
87
111
  self.ref: str = ref
88
112
  self.version: str | None = version
89
113
  self.image_digest: str | None = image_digest
@@ -105,6 +129,7 @@ class DockerImageInfo(DiscoveryArtefactDetail):
105
129
  self.error: str | None = None
106
130
  self.platform: str | None = platform
107
131
  self.custom: dict[str, str | float | int | bool | None] = {}
132
+ self.created: str | None = created
108
133
 
109
134
  self.local_build: bool = not self.repo_digests
110
135
  self.index_name, remote_name = resolve_repository_name(ref)
@@ -190,16 +215,20 @@ class DockerImageInfo(DiscoveryArtefactDetail):
190
215
  digest = digest.split(":")[1] if ":" in digest else digest # remove digest type prefix
191
216
  return digest[0:12]
192
217
  return digest
193
- except Exception:
218
+ except Exception as e:
219
+ log.warning("Unable to condense digest %s: %s", digest, e)
194
220
  return None
195
221
 
196
222
  def reuse(self) -> "DockerImageInfo":
197
- cloned = DockerImageInfo(self.ref, self.image_digest, self.tags, self.attributes, self.annotations, self.version)
223
+ cloned = DockerImageInfo(
224
+ self.ref, self.image_digest, self.tags, self.attributes, self.annotations, self.version, self.created
225
+ )
198
226
  cloned.origin = "REUSED"
199
227
  return cloned
200
228
 
201
229
  def as_dict(self, minimal: bool = True) -> dict[str, str | list | dict | bool | int | None]:
202
230
  result: dict[str, str | list | dict | bool | int | None] = {
231
+ "captured": self.captured.isoformat(),
203
232
  "image_ref": self.ref,
204
233
  "name": self.name,
205
234
  "version": self.version,
@@ -246,7 +275,7 @@ def _select_annotation(
246
275
 
247
276
 
248
277
  def cherrypick_annotations(
249
- local_info: DockerImageInfo | None, registry_info: DockerImageInfo | None
278
+ local_info: DockerImageInfo, registry_info: DockerImageInfo | None
250
279
  ) -> dict[str, str | float | int | bool | None]:
251
280
  """https://github.com/opencontainers/image-spec/blob/main/annotations.md"""
252
281
  results: dict[str, str | float | int | bool | None] = {}
@@ -258,11 +287,25 @@ def cherrypick_annotations(
258
287
  ("image_created", "org.opencontainers.image.created"),
259
288
  ("image_version", "org.opencontainers.image.version"),
260
289
  ("image_revision", "org.opencontainers.image.revision"),
290
+ ("ref_name", "org.opencontainers.image.ref.name"),
261
291
  ("title", "org.opencontainers.image.title"),
262
292
  ("vendor", "org.opencontainers.image.vendor"),
263
293
  ("source", "org.opencontainers.image.source"),
264
294
  ]:
265
295
  results.update(_select_annotation(either_name, either_label, local_info, registry_info))
296
+ if (
297
+ results.get("ref_name") == "ubuntu"
298
+ and local_info.name != "ubuntu"
299
+ and results.get("image_version")
300
+ and re.fullmatch(r"^2\d\.\d\d$", cast("str", results["image_version"]))
301
+ ):
302
+ log.debug(
303
+ "Suppressing %s base %s version leaking into image version: %s",
304
+ local_info.name,
305
+ results["ref_name"],
306
+ results["image_version"],
307
+ )
308
+ del results["image_version"]
266
309
  return results
267
310
 
268
311
 
@@ -322,14 +365,17 @@ class LocalContainerInfo:
322
365
  labels: dict[str, str | float | int | bool | None] = cherrypick_annotations(image_info, None)
323
366
  # capture container labels/annotations, not image ones
324
367
  labels = labels or {}
368
+ if container.image and container.image.attrs:
369
+ image_info.created = container.image.attrs.get("Created")
325
370
  image_info.custom = labels
326
371
  image_info.version = cast("str|None", labels.get("image_version"))
372
+
327
373
  return image_info, service_info
328
374
 
329
375
 
330
376
  class PackageEnricher:
331
- def __init__(self, docker_cfg: DockerConfig) -> None:
332
- self.pkgs: dict[str, PackageUpdateInfo] = {}
377
+ def __init__(self, docker_cfg: DockerConfig, packages: dict[str, PackageUpdateInfo] | None = None) -> None:
378
+ self.pkgs: dict[str, PackageUpdateInfo] = packages or {}
333
379
  self.cfg: DockerConfig = docker_cfg
334
380
  self.log: Any = structlog.get_logger().bind(integration="docker")
335
381
 
@@ -362,7 +408,7 @@ class DefaultPackageEnricher(PackageEnricher):
362
408
  def enrich(self, image_info: DockerImageInfo) -> PackageUpdateInfo | None:
363
409
  self.log.debug("Default pkg info", image_name=image_info.untagged_ref, image_ref=image_info.ref)
364
410
  return PackageUpdateInfo(
365
- DockerPackageUpdateInfo(image_info.untagged_ref or image_info.ref),
411
+ DockerPackageUpdateInfo(image_info.untagged_ref or image_info.ref, version_policy=VersionPolicy.AUTO),
366
412
  logo_url=self.cfg.default_entity_picture_url,
367
413
  release_notes_url=None,
368
414
  )
@@ -370,19 +416,25 @@ class DefaultPackageEnricher(PackageEnricher):
370
416
 
371
417
  class CommonPackageEnricher(PackageEnricher):
372
418
  def initialize(self) -> None:
419
+ base_cfg: DictConfig = OmegaConf.structured(CommonPackages)
373
420
  if PKG_INFO_FILE.exists():
374
- log.debug("Loading common package update info", path=PKG_INFO_FILE)
375
- cfg = OmegaConf.load(PKG_INFO_FILE)
421
+ self.log.debug("Loading common package update info", path=PKG_INFO_FILE)
422
+ cfg: DictConfig = typing.cast("DictConfig", OmegaConf.merge(base_cfg, OmegaConf.load(PKG_INFO_FILE)))
423
+
424
+ OmegaConf.to_container(cfg, throw_on_missing=True)
425
+ OmegaConf.set_readonly(cfg, True)
376
426
  else:
377
- log.warn("No common package update info found", path=PKG_INFO_FILE)
378
- cfg = OmegaConf.structured(UpdateInfoConfig)
427
+ self.log.warn("No common package update info found", path=PKG_INFO_FILE)
428
+ cfg = base_cfg
379
429
  try:
430
+ common_config: CommonPackages = typing.cast("CommonPackages", cfg)
380
431
  # omegaconf broken-ness on optional fields and converting to backclasses
381
- self.pkgs: dict[str, PackageUpdateInfo] = {
382
- pkg: PackageUpdateInfo(**pkg_cfg) for pkg, pkg_cfg in cfg.common_packages.items()
383
- }
432
+ self.pkgs = common_config.common_packages
433
+ # self.pkgs: dict[str, PackageUpdateInfo] = {
434
+ # pkg: PackageUpdateInfo(**pkg_cfg) for pkg, pkg_cfg in cfg.common_packages.items() if pkg not in self.pkgs
435
+ # }
384
436
  except (MissingMandatoryValue, ValidationError) as e:
385
- log.error("Configuration error %s", e, path=PKG_INFO_FILE.as_posix())
437
+ self.log.serror("Configuration error %s", e, path=PKG_INFO_FILE.as_posix())
386
438
  raise
387
439
 
388
440
 
@@ -392,7 +444,7 @@ class LinuxServerIOPackageEnricher(PackageEnricher):
392
444
  if cfg is None or not cfg.enabled:
393
445
  return
394
446
 
395
- log.debug(f"Fetching linuxserver.io metadata from API, cache_ttl={cfg.cache_ttl}")
447
+ self.log.debug(f"Fetching linuxserver.io metadata from API, cache_ttl={cfg.cache_ttl}")
396
448
  response: Response | None = fetch_url(
397
449
  "https://api.linuxserver.io/api/v1/images?include_config=false&include_deprecated=false",
398
450
  cache_ttl=cfg.cache_ttl,
@@ -413,26 +465,38 @@ class LinuxServerIOPackageEnricher(PackageEnricher):
413
465
  release_notes_url=f"{repo['github_url']}/releases",
414
466
  )
415
467
  added += 1
416
- log.debug("Added linuxserver.io package", pkg=image_name)
417
- log.info(f"Added {added} linuxserver.io package details")
468
+ self.log.info(f"Added {added} linuxserver.io package details")
418
469
 
419
470
 
420
471
  class SourceReleaseEnricher:
421
- def __init__(self) -> None:
472
+ def __init__(self, gh_cfg: GitHubConfig | None = None) -> None:
422
473
  self.log: Any = structlog.get_logger().bind(integration="docker")
474
+ self.gh_cfg: GitHubConfig | None = gh_cfg
423
475
 
424
476
  def enrich(
425
477
  self, registry_info: DockerImageInfo, source_repo_url: str | None = None, notes_url: str | None = None
426
478
  ) -> ReleaseDetail | None:
427
- if not registry_info.annotations and not source_repo_url and not notes_url:
428
- return None
429
-
430
479
  detail = ReleaseDetail()
431
480
 
432
481
  detail.notes_url = notes_url
433
482
  detail.version = registry_info.annotations.get("org.opencontainers.image.version")
434
483
  detail.revision = registry_info.annotations.get("org.opencontainers.image.revision")
435
- detail.source_url = registry_info.annotations.get("org.opencontainers.image.source") or source_repo_url
484
+ # explicit source_repo_url overrides container, e.g. where container source is only the docker wrapper
485
+ detail.source_url = source_repo_url or registry_info.annotations.get("org.opencontainers.image.source")
486
+
487
+ if detail.source_url is None and registry_info is not None and registry_info.index_name is not None:
488
+ registry_config: tuple[str | None, str, str, str | None, str | None] | None = REGISTRIES.get(
489
+ registry_info.index_name
490
+ )
491
+ repo_template: str | None = registry_config[4] if registry_config else None
492
+ if repo_template:
493
+ source_url = repo_template.format(image_name=registry_info.name)
494
+ if validate_url(source_url, cache_ttl=86400):
495
+ detail.source_url = source_url
496
+ self.log.info("Implied source from registry: %s", detail.source_url)
497
+
498
+ if detail.source_url is None and detail.notes_url is None and detail.revision is None and detail.version is None:
499
+ return None
436
500
 
437
501
  if detail.source_url and "#" in detail.source_url:
438
502
  detail.source_repo_url = detail.source_url.split("#", 1)[0]
@@ -452,30 +516,77 @@ class SourceReleaseEnricher:
452
516
  "source": detail.source_url or MISSING_VAL,
453
517
  }
454
518
 
455
- diff_url: str | None = DIFF_URL_TEMPLATES[detail.source_platform].format(**template_vars)
456
- if diff_url and MISSING_VAL not in diff_url and validate_url(diff_url):
519
+ diff_url_template: str | None = DIFF_URL_TEMPLATES.get(detail.source_platform)
520
+ diff_url: str | None = diff_url_template.format(**template_vars) if diff_url_template else None
521
+ if diff_url and MISSING_VAL not in diff_url and validate_url(diff_url, cache_ttl=3600):
457
522
  detail.diff_url = diff_url
458
523
  else:
459
524
  diff_url = None
460
525
 
461
- if detail.notes_url is None:
462
- detail.notes_url = RELEASE_URL_TEMPLATES[detail.source_platform].format(**template_vars)
463
-
464
- if MISSING_VAL in detail.notes_url or not validate_url(detail.notes_url):
465
- detail.notes_url = UNKNOWN_RELEASE_URL_TEMPLATES[detail.source_platform].format(**template_vars)
466
- if MISSING_VAL in detail.notes_url or not validate_url(detail.notes_url):
467
- detail.notes_url = None
468
-
469
- if detail.source_platform == SOURCE_PLATFORM_GITHUB and detail.source_repo_url:
526
+ if detail.notes_url is None and detail.source_platform in RELEASE_URL_TEMPLATES:
527
+ platform_notes_url: str | None = RELEASE_URL_TEMPLATES[detail.source_platform].format(**template_vars)
528
+ if (
529
+ platform_notes_url
530
+ and MISSING_VAL not in platform_notes_url
531
+ and validate_url(platform_notes_url, cache_ttl=86400)
532
+ ):
533
+ self.log.debug("Setting default known release notes url: %s", platform_notes_url)
534
+ detail.notes_url = platform_notes_url
535
+
536
+ if detail.notes_url is None and detail.source_platform in UNKNOWN_RELEASE_URL_TEMPLATES:
537
+ platform_notes_url = UNKNOWN_RELEASE_URL_TEMPLATES[detail.source_platform].format(**template_vars)
538
+ if (
539
+ platform_notes_url
540
+ and MISSING_VAL not in platform_notes_url
541
+ and validate_url(platform_notes_url, cache_ttl=86400)
542
+ ):
543
+ self.log.debug("Setting default unknown release notes url: %s", platform_notes_url)
544
+ detail.notes_url = platform_notes_url
545
+
546
+ if detail.source_platform == SOURCE_PLATFORM_GITHUB and detail.source_repo_url and detail.version is not None:
547
+ access_token: str | None = self.gh_cfg.access_token if self.gh_cfg else None
548
+ if access_token:
549
+ self.log.debug("Using configured bearer token (%s chars) for GitHub API", len(access_token))
470
550
  base_api = detail.source_repo_url.replace("https://github.com", "https://api.github.com/repos")
471
551
 
472
- api_response: Response | None = fetch_url(f"{base_api}/releases/tags/{detail.version}")
552
+ api_response: Response | None = fetch_url(
553
+ f"{base_api}/releases/tags/{detail.version}", bearer_token=access_token, allow_stale=True
554
+ )
555
+ if api_response and api_response.status_code == 404:
556
+ # possible that source version doesn't match release gag
557
+ alt_api_response: Response | None = fetch_url(f"{base_api}/releases/latest", bearer_token=access_token)
558
+ if alt_api_response and alt_api_response.is_success:
559
+ alt_api_results = httpx_json_content(alt_api_response, {})
560
+ if alt_api_results and re.fullmatch(f"(V|v|r|R)?{detail.version}", alt_api_results.get("tag_name")):
561
+ self.log.info(
562
+ f"Matched {registry_info.name} {detail.version} to latest release {alt_api_results['tag_name']}"
563
+ )
564
+ api_response = alt_api_response
565
+ elif alt_api_results:
566
+ self.log.debug(
567
+ "Failed to match %s release %s on GitHub, found tag %s for name %s published at %s",
568
+ registry_info.name,
569
+ detail.version,
570
+ alt_api_results.get("tag_name"),
571
+ alt_api_results.get("name"),
572
+ alt_api_results.get("published_at"),
573
+ )
574
+
473
575
  if api_response and api_response.is_success:
474
576
  api_results: Any = httpx_json_content(api_response, {})
475
577
  detail.summary = api_results.get("body") # ty:ignore[possibly-missing-attribute]
476
578
  reactions = api_results.get("reactions") # ty:ignore[possibly-missing-attribute]
477
579
  if reactions:
478
580
  detail.net_score = reactions.get("+1", 0) - reactions.get("-1", 0)
581
+ elif api_response:
582
+ api_results = httpx_json_content(api_response, default={})
583
+ self.log.debug(
584
+ "Failed to find %s release %s on GitHub, status %s, errors; %s",
585
+ registry_info.name,
586
+ detail.version,
587
+ api_response.status_code,
588
+ api_results.get("errors"),
589
+ )
479
590
  else:
480
591
  self.log.debug(
481
592
  "Failed to fetch GitHub release info",
@@ -523,14 +634,18 @@ class ContainerDistributionAPIVersionLookup(VersionLookup):
523
634
  self.api_stats = APIStatsCounter()
524
635
 
525
636
  def fetch_token(self, registry: str, image_name: str) -> str | None:
526
- default_host: tuple[str, str, str, str] = (registry, registry, registry, TOKEN_URL_TEMPLATE)
637
+ default_host: tuple[str, str, str, str, None] = (registry, registry, registry, TOKEN_URL_TEMPLATE, None)
527
638
  auth_host: str | None = REGISTRIES.get(registry, default_host)[0]
528
639
  if auth_host is None:
529
640
  return None
530
641
 
531
642
  service: str = REGISTRIES.get(registry, default_host)[2]
532
- url_template: str = REGISTRIES.get(registry, default_host)[3]
533
- auth_url: str = url_template.format(auth_host=auth_host, image_name=image_name, service=service)
643
+ url_template: str | None = REGISTRIES.get(registry, default_host)[3]
644
+ auth_url: str | None = (
645
+ url_template.format(auth_host=auth_host, image_name=image_name, service=service) if url_template else None
646
+ )
647
+ if auth_url is None:
648
+ return None
534
649
  response: Response | None = fetch_url(
535
650
  auth_url, cache_ttl=self.cfg.token_cache_ttl, follow_redirects=True, api_stats_counter=self.api_stats
536
651
  )
@@ -737,7 +852,7 @@ class ContainerDistributionAPIVersionLookup(VersionLookup):
737
852
  if index_digest:
738
853
  result.image_digest = index_digest
739
854
  result.short_digest = result.condense_digest(index_digest)
740
- log.debug("Setting %s image digest %s", result.name, result.short_digest)
855
+ self.log.debug("Setting %s image digest %s", result.name, result.short_digest)
741
856
 
742
857
  digest: str | None = m.get("digest")
743
858
  media_type = m.get("mediaType")
@@ -757,7 +872,7 @@ class ContainerDistributionAPIVersionLookup(VersionLookup):
757
872
  self.log.warning("Empty digest for %s %s %s", api_host, digest, media_type)
758
873
  else:
759
874
  result.repo_digest = result.condense_digest(digest, short=False)
760
- log.debug("Setting %s repo digest: %s", result.name, result.repo_digest)
875
+ self.log.debug("Setting %s repo digest: %s", result.name, result.repo_digest)
761
876
 
762
877
  if manifest.get("annotations"):
763
878
  result.annotations.update(manifest.get("annotations", {}))
@@ -780,6 +895,7 @@ class ContainerDistributionAPIVersionLookup(VersionLookup):
780
895
  if config and "Labels" in config:
781
896
  result.annotations.update(config.get("Labels") or {})
782
897
  result.annotations.update(img_config.get("annotations") or {})
898
+ result.created = config.get("created") or config.get("Created")
783
899
  else:
784
900
  self.log.debug("No config found: %s", manifest)
785
901
  except Exception as e:
@@ -872,5 +988,6 @@ class DockerClientVersionLookup(VersionLookup):
872
988
  labels: dict[str, str | float | int | bool | None] = cherrypick_annotations(local_image_info, result)
873
989
  result.custom = labels or {}
874
990
  result.version = cast("str|None", labels.get("image_version"))
991
+ result.created = cast("str|None", labels.get("image_created"))
875
992
  result.origin = "DOCKER_CLIENT"
876
993
  return result
updates2mqtt/model.py CHANGED
@@ -1,3 +1,4 @@
1
+ import datetime as dt
1
2
  import json
2
3
  import time
3
4
  from abc import abstractmethod
@@ -6,27 +7,40 @@ from threading import Event
6
7
  from typing import Any
7
8
 
8
9
  import structlog
10
+ from tzlocal import get_localzone
9
11
 
10
12
  from updates2mqtt.config import NodeConfig, PublishPolicy, UpdatePolicy, VersionPolicy
11
- from updates2mqtt.helpers import timestamp
13
+ from updates2mqtt.helpers import sanitize_name, timestamp
12
14
 
13
15
 
14
- class DiscoveryArtefactDetail:
15
- """Provider specific detail"""
16
+ class DiscoveryDetail:
17
+ def __init__(self) -> None:
18
+ self.captured: dt.datetime = dt.datetime.now(tz=get_localzone())
16
19
 
20
+ @abstractmethod
17
21
  def as_dict(self) -> dict[str, str | list | dict | bool | int | None]:
18
22
  return {}
19
23
 
24
+ def __str__(self) -> str:
25
+ """Log friendly"""
26
+ return ",".join(f"{k}:{v}" for k, v in self.as_dict().items())
27
+
20
28
 
21
- class DiscoveryInstallationDetail:
29
+ class DiscoveryArtefactDetail(DiscoveryDetail):
22
30
  """Provider specific detail"""
23
31
 
24
- @abstractmethod
25
- def as_dict(self) -> dict[str, str | list | dict | bool | int | None]:
26
- return {}
32
+ def __init__(self) -> None:
33
+ super().__init__()
34
+
35
+
36
+ class DiscoveryInstallationDetail(DiscoveryDetail):
37
+ """Provider specific detail"""
38
+
39
+ def __init__(self) -> None:
40
+ super().__init__()
27
41
 
28
42
 
29
- class ReleaseDetail:
43
+ class ReleaseDetail(DiscoveryDetail):
30
44
  """The artefact source details
31
45
 
32
46
  Note this may be an actual software package, or the source details of the wrapping of it
@@ -34,6 +48,7 @@ class ReleaseDetail:
34
48
  """
35
49
 
36
50
  def __init__(self, notes_url: str | None = None, summary: str | None = None) -> None:
51
+ super().__init__()
37
52
  self.source_platform: str | None = None
38
53
  self.source_repo_url: str | None = None
39
54
  self.source_url: str | None = None
@@ -45,8 +60,9 @@ class ReleaseDetail:
45
60
  self.summary: str | None = summary
46
61
  self.net_score: int | None = None
47
62
 
48
- def as_dict(self) -> dict[str, str | None]:
63
+ def as_dict(self) -> dict[str, str | list | dict | bool | int | None]:
49
64
  return {
65
+ "captured": self.captured.isoformat(),
50
66
  "title": self.title,
51
67
  "version": self.version,
52
68
  "source_platform": self.source_platform,
@@ -94,7 +110,7 @@ class Discovery:
94
110
  self.provider: ReleaseProvider = provider
95
111
  self.source_type: str = provider.source_type
96
112
  self.session: str = session
97
- self.name: str = name
113
+ self.name: str = sanitize_name(name)
98
114
  self.node: str = node
99
115
  self.entity_picture_url: str | None = entity_picture_url
100
116
  self.current_version: str | None = current_version