Coverage for custom_components/supernotify/archive.py: 96%

335 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-11-08 17:42 +0000

1from __future__ import annotations 

2 

3import datetime as dt 

4import json 

5import logging 

6import math 

7import os 

8from abc import abstractmethod 

9from typing import TYPE_CHECKING, Any 

10 

11import aiofiles.os 

12import anyio 

13import homeassistant.util.dt as dt_util 

14from anyio import Path 

15from homeassistant.const import ( 

16 CONF_ENABLED, 

17) 

18 

19from .const import ( 

20 CONF_ARCHIVE_DAYS, 

21 CONF_ARCHIVE_DIAGNOSTICS, 

22 CONF_ARCHIVE_EVENT_NAME, 

23 CONF_ARCHIVE_EVENT_SELECTION, 

24 CONF_ARCHIVE_MQTT_QOS, 

25 CONF_ARCHIVE_MQTT_RETAIN, 

26 CONF_ARCHIVE_MQTT_TOPIC, 

27 CONF_ARCHIVE_PATH, 

28) 

29from .schema import DeliveryOutcome, OutcomeSelection 

30 

31if TYPE_CHECKING: 

32 from homeassistant.core import Context as HAContext 

33 from homeassistant.helpers.typing import ConfigType 

34 

35 from custom_components.supernotify.hass_api import HomeAssistantAPI 

36 

37 from .engine import SupernotifyEngine 

38 from .people import Recipient 

39 

40_LOGGER = logging.getLogger(__name__) 

41 

42ARCHIVE_DEFAULT_DAYS = 1 

43WRITE_TEST = ".startup" 

44ARCHIVE_PATH_ISSUE = "archive_path" 

45 

46 

47def _filename_period(filename: str) -> tuple[float, float] | None: 

48 """The span of time an archive file could have been created in, going by the local time 

49 to the minute at the start of its name, or ``None`` if it isn't named that way. 

50 

51 Both sides of a daylight saving change are allowed for, since the name has no UTC offset. 

52 """ 

53 time_zone = dt_util.get_default_time_zone() 

54 try: 

55 named: dt.datetime = dt.datetime.strptime(filename[:16], "%Y-%m-%dT%H-%M").replace(tzinfo=time_zone) 

56 except ValueError: 

57 return None 

58 stamps: list[float] = [named.replace(fold=fold).timestamp() for fold in (0, 1)] 

59 return min(stamps), max(stamps) + 60 

60 

61 

62def _list_entries_sync( 

63 archive_path: str, 

64 limit: int, 

65 after: dt.datetime | None, 

66 before: dt.datetime | None, 

67 outcome: str | None, 

68 priority: list[str] | None = None, 

69) -> list[dict[str, Any]]: 

70 """The body of ``ArchiveDirectory.list_entries``, run in the executor as one job.""" 

71 entries: list[dict[str, Any]] = [] 

72 priorities: set[str] | None = {str(p).lower() for p in priority} if priority else None 

73 try: 

74 with os.scandir(archive_path) as scan: 

75 files = [e for e in scan if e.name.endswith(".json") and e.name != WRITE_TEST] 

76 files.sort(key=lambda e: e.stat().st_ctime, reverse=True) 

77 earliest: float = after.timestamp() if after else -math.inf 

78 latest: float = before.timestamp() if before else math.inf 

79 for entry in files: 

80 if len(entries) >= limit: 

81 break 

82 if (after or before) and (named := _filename_period(entry.name)) and (named[1] < earliest or named[0] > latest): 

83 continue 

84 try: 

85 with open(entry.path, encoding="utf-8") as fh: 

86 data: dict[str, Any] = json.load(fh) 

87 except Exception as exc: 

88 _LOGGER.debug("SUPERNOTIFY Skipping unreadable archive file %s: %s", entry.name, exc) 

89 continue 

90 created: dt.datetime | None = dt_util.parse_datetime(data.get("created") or "") 

91 if after is not None and created is not None and created < after: 

92 continue 

93 if before is not None and created is not None and created > before: 

94 continue 

95 if outcome is not None and str(data.get("outcome", "")).lower() != outcome.lower(): 

96 continue 

97 if priorities is not None and str(data.get("priority", "")).lower() not in priorities: 

98 continue 

99 entries.append(data) 

100 except Exception as exc: 

101 _LOGGER.warning("SUPERNOTIFY Unable to list archive entries: %s", exc) 

102 return entries 

103 

104 

105class ArchivableObject: 

106 ha_context: HAContext | None = None 

107 

108 @abstractmethod 

109 def base_filename(self) -> str: 

110 pass 

111 

112 @abstractmethod 

113 def contents(self, diagnostics: bool = False, **_kwargs: Any) -> dict[str, Any]: 

114 pass 

115 

116 def outcome(self) -> DeliveryOutcome: 

117 return DeliveryOutcome.NO_DELIVERY 

118 

119 def diagnostics_selected(self, outcome_policy: OutcomeSelection) -> bool: 

120 """Whether the archived copy should carry the full diagnostic content""" 

121 return self.selected(outcome_policy) 

122 

123 def selected(self, outcome_policy: OutcomeSelection) -> bool: 

124 return bool( 

125 outcome_policy & OutcomeSelection.ALL 

126 or (outcome_policy & OutcomeSelection.SUCCESS and self.outcome() == DeliveryOutcome.SUCCESS) 

127 or (outcome_policy & OutcomeSelection.NO_DELIVERY and self.outcome() == DeliveryOutcome.NO_DELIVERY) 

128 or (outcome_policy & OutcomeSelection.PARTIAL_DELIVERY and self.outcome() == DeliveryOutcome.PARTIAL_DELIVERY) 

129 or (outcome_policy & OutcomeSelection.DUPE and self.outcome() == DeliveryOutcome.DUPE) 

130 or (outcome_policy & OutcomeSelection.FALLBACK_DELIVERY and self.outcome() == DeliveryOutcome.FALLBACK_DELIVERY) 

131 or (outcome_policy & OutcomeSelection.ERROR and self.outcome() == DeliveryOutcome.ERROR) 

132 ) 

133 

134 

135class ArchiveDestination: 

136 @abstractmethod 

137 async def archive(self, archive_object: ArchivableObject) -> bool: 

138 pass 

139 

140 

141class EventArchiver(ArchiveDestination): 

142 def __init__( 

143 self, 

144 hass_api: HomeAssistantAPI, 

145 event_name: str, 

146 selection: OutcomeSelection = OutcomeSelection.NONE, 

147 diagnostics: OutcomeSelection = OutcomeSelection.ERROR, 

148 ) -> None: 

149 self.hass_api = hass_api 

150 self.event_name = event_name 

151 self.selection = selection 

152 self.diagnostics = diagnostics 

153 if selection: 

154 _LOGGER.info("SUPERNOTIFY Archiving %s notifications as %s events", selection.name, event_name) 

155 

156 async def archive(self, archive_object: ArchivableObject) -> bool: 

157 if not archive_object.selected(self.selection): 

158 return False 

159 try: 

160 payload = archive_object.contents(diagnostics=archive_object.diagnostics_selected(self.diagnostics)) 

161 self.hass_api.fire_event(self.event_name, payload, context=archive_object.ha_context) 

162 return True 

163 except Exception: 

164 _LOGGER.warning("SUPERNOTIFY Failed to archive to event %s", self.event_name) 

165 return False 

166 

167 

168class ArchiveTopic(ArchiveDestination): 

169 def __init__( 

170 self, 

171 hass_api: HomeAssistantAPI, 

172 topic: str, 

173 qos: int = 0, 

174 retain: bool = False, 

175 diagnostics: OutcomeSelection = OutcomeSelection.ERROR, 

176 ) -> None: 

177 self.hass_api: HomeAssistantAPI = hass_api 

178 self.topic: str = topic 

179 self.qos: int = qos 

180 self.retain: bool = retain 

181 self.diagnostics: OutcomeSelection = diagnostics 

182 self.enabled: bool = False 

183 

184 async def initialize(self) -> None: 

185 if self.topic: 

186 if await self.hass_api.mqtt_available(raise_on_error=False): 

187 _LOGGER.info(f"SUPERNOTIFY Archiving to MQTT topic {self.topic}, qos {self.qos}, retain {self.retain}") 

188 self.enabled = True 

189 else: 

190 _LOGGER.warning( 

191 f"SUPERNOTIFY Archiving configured for topic {self.topic} but MQTT not available at startup, disabled" 

192 ) 

193 

194 async def archive(self, archive_object: ArchivableObject) -> bool: 

195 if not self.enabled: 

196 return False 

197 payload = archive_object.contents(diagnostics=archive_object.diagnostics_selected(self.diagnostics)) 

198 topic = f"{self.topic}/{archive_object.base_filename()}" 

199 _LOGGER.debug(f"SUPERNOTIFY Publishing notification to {topic}") 

200 try: 

201 await self.hass_api.mqtt_publish( 

202 topic=topic, 

203 payload=payload, 

204 qos=self.qos, 

205 retain=self.retain, 

206 ) 

207 return True 

208 except Exception: 

209 _LOGGER.warning(f"SUPERNOTIFY Failed to archive to topic {self.topic}") 

210 return False 

211 

212 

213class ArchiveDirectory(ArchiveDestination): 

214 def __init__( 

215 self, 

216 path: str, 

217 diagnostics: OutcomeSelection = OutcomeSelection.ERROR, 

218 hass_api: HomeAssistantAPI | None = None, 

219 ) -> None: 

220 self.hass_api: HomeAssistantAPI | None = hass_api 

221 self.configured_path: str = path 

222 self.archive_path: anyio.Path | None = None 

223 self.enabled: bool = False 

224 # why the path can't be used, if it can't 

225 self.error: str | None = None 

226 self.diagnostics: OutcomeSelection = diagnostics 

227 

228 async def initialize(self) -> None: 

229 verify_archive_path: Path = Path(self.configured_path) 

230 if not await verify_archive_path.exists(): 

231 _LOGGER.info("SUPERNOTIFY Archive path not found at %s", verify_archive_path) 

232 try: 

233 await verify_archive_path.mkdir(parents=True, exist_ok=True) 

234 except Exception as e: 

235 _LOGGER.warning("SUPERNOTIFY Archive path %s cannot be created: %s", verify_archive_path, e) 

236 self.error = str(e) 

237 return 

238 if not await verify_archive_path.is_dir(): 

239 _LOGGER.warning("SUPERNOTIFY Archive path %s is not a directory", verify_archive_path) 

240 self.error = "not a directory" 

241 return 

242 try: 

243 await verify_archive_path.joinpath(WRITE_TEST).touch(exist_ok=True) 

244 self.archive_path = verify_archive_path 

245 _LOGGER.info("SUPERNOTIFY Archiving notifications to file system at %s", verify_archive_path) 

246 self.enabled = True 

247 except Exception as e: 

248 _LOGGER.warning("SUPERNOTIFY Archive path %s cannot be written: %s", verify_archive_path, e) 

249 self.error = str(e) 

250 

251 async def archive(self, archive_object: ArchivableObject) -> bool: 

252 archived: bool = False 

253 

254 if self.enabled and self.archive_path: # archive_path to assuage mypy 

255 archive_filepath: Path | None = None 

256 diagnostics: bool = archive_object.diagnostics_selected(self.diagnostics) 

257 try: 

258 filename = f"{archive_object.base_filename()}.json" 

259 archive_filepath = self.archive_path.joinpath(filename) 

260 serialized: str = json.dumps(archive_object.contents(diagnostics=diagnostics), indent=2) 

261 async with aiofiles.open(archive_filepath, mode="w") as file: 

262 await file.write(serialized) 

263 _LOGGER.debug("SUPERNOTIFY Archived notification %s", await archive_filepath.absolute()) 

264 archived = True 

265 except Exception as e: 

266 _LOGGER.warning("SUPERNOTIFY Unable to archive notification: %s", e) 

267 if diagnostics and archive_filepath: 

268 try: 

269 serialized = json.dumps(archive_object.contents(diagnostics=False), indent=2) 

270 async with aiofiles.open(archive_filepath, mode="w") as file: 

271 await file.write(serialized) 

272 _LOGGER.warning("SUPERNOTIFY Archived minimal notification %s", await archive_filepath.absolute()) 

273 archived = True 

274 except Exception: 

275 _LOGGER.exception("SUPERNOTIFY Unable to archive minimal notification") 

276 return archived 

277 

278 async def list_entries( 

279 self, 

280 limit: int = 20, 

281 after: dt.datetime | None = None, 

282 before: dt.datetime | None = None, 

283 outcome: str | None = None, 

284 priority: list[str] | None = None, 

285 ) -> list[dict[str, Any]]: 

286 """Return up to *limit* archived notifications, newest first. 

287 

288 Optional *after* and *before* filter by the ``created`` timestamp stored in 

289 each archive file. Optional *outcome* keeps only notifications whose top-level 

290 ``outcome`` field matches, ignoring case: the archive stores the ``DeliveryOutcome`` 

291 value (``"success"``) and the action's selector offers ``"SUCCESS"``. Optional 

292 *priority* keeps only notifications with one of these priorities, also ignoring case. 

293 Filters apply before *limit*, so the newest *limit* matching notifications are returned. 

294 

295 Files are named for the minute they were created, so any that fall outside 

296 *after* and *before* are passed over without being opened. 

297 """ 

298 if not self.archive_path or not await self.archive_path.exists(): 

299 return [] 

300 # one executor job for the whole scan: a file at a time through aiofiles is a thread hop per 

301 # open and per read, seconds for a month of archive 

302 if self.hass_api is None: # only a directory made outside Home Assistant, as in some tests 

303 return _list_entries_sync(str(self.archive_path), limit, after, before, outcome, priority) 

304 entries: list[dict[str, Any]] = await self.hass_api.create_job( 

305 _list_entries_sync, str(self.archive_path), limit, after, before, outcome, priority 

306 ) 

307 return entries 

308 

309 async def read_entry(self, notification_id: str) -> dict[str, Any] | None: 

310 """Return the full JSON of one archived notification by its id, or ``None``.""" 

311 if not self.archive_path or not await self.archive_path.exists(): 

312 return None 

313 try: 

314 raw_scan = await aiofiles.os.scandir(self.archive_path) 

315 for entry in raw_scan: 

316 if entry.name.endswith(".json") and notification_id in entry.name: 

317 async with aiofiles.open(entry.path, mode="r") as fh: 

318 return json.loads(await fh.read()) 

319 except Exception as exc: 

320 _LOGGER.warning("SUPERNOTIFY Unable to read archive entry %s: %s", notification_id, exc) 

321 return None 

322 

323 async def size(self) -> int: 

324 path = self.archive_path 

325 if path and await path.exists(): 

326 return sum(1 for p in await aiofiles.os.listdir(path) if p != WRITE_TEST) 

327 return 0 

328 

329 async def cleanup(self, days: int) -> int: 

330 cutoff = dt.datetime.now(dt.UTC) - dt.timedelta(days=days) 

331 cutoff = cutoff.astimezone(dt.UTC) 

332 purged = 0 

333 if self.archive_path and await self.archive_path.exists(): 

334 try: 

335 archive = await aiofiles.os.scandir(self.archive_path) 

336 for entry in archive: 

337 if entry.name == WRITE_TEST: 

338 continue 

339 # st_ctime is used deliberately here (not st_birthtime): archive files are 

340 # written once and never modified afterwards, so ctime reflects creation time 

341 # on the platforms this integration targets; st_birthtime is not guaranteed to 

342 # be available on all Linux filesystems. 

343 if dt_util.utc_from_timestamp(entry.stat().st_ctime) <= cutoff: # ty: ignore[deprecated,unused-ignore-comment] 

344 _LOGGER.debug("SUPERNOTIFY Purging %s", entry.path) 

345 await aiofiles.os.unlink(entry.path) 

346 purged += 1 

347 except Exception as e: 

348 _LOGGER.warning("SUPERNOTIFY Unable to clean up archive at %s: %s", self.archive_path, e, exc_info=True) 

349 _LOGGER.info("SUPERNOTIFY Purged %s archived notifications for cutoff %s", purged, cutoff) 

350 else: 

351 _LOGGER.debug("SUPERNOTIFY Skipping archive purge for unknown path %s", self.archive_path) 

352 return purged 

353 

354 

355class NotificationArchive: 

356 def __init__( 

357 self, 

358 config: ConfigType, 

359 hass_api: HomeAssistantAPI, 

360 ) -> None: 

361 self.hass_api = hass_api 

362 self.enabled = bool(config.get(CONF_ENABLED, False)) 

363 self.archive_directory: ArchiveDirectory | None = None 

364 self.archive_topic: ArchiveTopic | None = None 

365 self.event_archiver: EventArchiver | None = None 

366 self.event_selection: OutcomeSelection = config.get(CONF_ARCHIVE_EVENT_SELECTION, OutcomeSelection.NONE) 

367 self.diagnostics: OutcomeSelection = config.get(CONF_ARCHIVE_DIAGNOSTICS, OutcomeSelection.ERROR) 

368 self.archive_event_name: str = config.get(CONF_ARCHIVE_EVENT_NAME, "supernotification") 

369 self.configured_archive_path: str | None = config.get(CONF_ARCHIVE_PATH) 

370 self.archive_days = int(config.get(CONF_ARCHIVE_DAYS, ARCHIVE_DEFAULT_DAYS)) 

371 self.mqtt_topic: str | None = config.get(CONF_ARCHIVE_MQTT_TOPIC) 

372 self.mqtt_qos: int = int(config.get(CONF_ARCHIVE_MQTT_QOS, 0)) 

373 self.mqtt_retain: bool = bool(config.get(CONF_ARCHIVE_MQTT_RETAIN, False)) 

374 

375 async def initialize(self) -> None: 

376 if not self.enabled: 

377 _LOGGER.info("SUPERNOTIFY Archive disabled") 

378 return 

379 if not self.configured_archive_path: 

380 _LOGGER.warning("SUPERNOTIFY Archive path not configured") 

381 else: 

382 self.archive_directory = ArchiveDirectory( 

383 self.hass_api.config_path(self.configured_archive_path), 

384 diagnostics=self.diagnostics, 

385 hass_api=self.hass_api, 

386 ) 

387 await self.archive_directory.initialize() 

388 if self.archive_directory.error is None: 

389 self.hass_api.delete_issue(ARCHIVE_PATH_ISSUE) 

390 else: 

391 self.hass_api.raise_issue( 

392 ARCHIVE_PATH_ISSUE, 

393 ARCHIVE_PATH_ISSUE, 

394 {"path": self.archive_directory.configured_path, "error": self.archive_directory.error}, 

395 learn_more_url="https://supernotify.rhizomatics.org.uk/latest/configuration/archiving/", 

396 ) 

397 

398 if self.mqtt_topic: 

399 self.archive_topic = ArchiveTopic(self.hass_api, self.mqtt_topic, self.mqtt_qos, self.mqtt_retain, self.diagnostics) 

400 await self.archive_topic.initialize() 

401 

402 self.event_archiver = EventArchiver(self.hass_api, self.archive_event_name, self.event_selection, self.diagnostics) 

403 

404 async def size(self) -> int: 

405 return await self.archive_directory.size() if self.archive_directory else 0 

406 

407 async def cleanup(self, days: int | None = None) -> int: 

408 days = self.archive_days if days is None else days 

409 return await self.archive_directory.cleanup(days) if self.archive_directory else 0 

410 

411 async def archive(self, archive_object: ArchivableObject) -> bool: 

412 archived: bool = False 

413 if self.archive_topic and await self.archive_topic.archive(archive_object): 

414 archived = True 

415 if self.archive_directory and await self.archive_directory.archive(archive_object): 

416 archived = True 

417 if self.event_archiver: 

418 await self.event_archiver.archive(archive_object) 

419 

420 return archived 

421 

422 

423def _names_for(engine: SupernotifyEngine, person_ids: list[str]) -> list[str]: 

424 def _display_name(recipient: Recipient) -> str: 

425 return recipient.alias or recipient.name 

426 

427 people = engine.context.people_registry.people 

428 return sorted(_display_name(people[p]) if p in people else p for p in person_ids) 

429 

430 

431def _scenario_names(scenarios: Any) -> list[str]: # noqa: ANN401 - as stored, which has varied 

432 """The scenario names of an archived notification: a list, or in older archive files a mapping by 

433 name, or a single name.""" 

434 if not scenarios: 

435 return [] 

436 if isinstance(scenarios, list | tuple | dict): 

437 return [str(name) for name in scenarios] 

438 return [str(scenarios)] 

439 

440 

441def _bump(counts: dict[str, int], key: str) -> None: 

442 counts[key] = counts.get(key, 0) + 1 

443 

444 

445def summarize_by_day(entries: list[dict[str, Any]]) -> dict[str, Any]: 

446 """Count archived notifications per local day, oldest first: how many, by outcome, priority, 

447 local hour and applied scenario, and for each delivery in how many it sent or failed. 

448 

449 Older archive files keep `enabled_scenarios` as a mapping by name rather than a list - either 

450 is counted by name. A notification without a readable `created` is left out.""" 

451 days: dict[str, dict[str, Any]] = {} 

452 for contents in entries: 

453 created: dt.datetime | None = dt_util.parse_datetime(str(contents.get("created") or "")) 

454 if created is None: 

455 continue 

456 local: dt.datetime = dt_util.as_local(created) 

457 key: str = local.date().isoformat() 

458 day: dict[str, Any] = days.setdefault( 

459 key, {"date": key, "count": 0, "outcome": {}, "priority": {}, "hour": [0] * 24, "deliveries": {}, "scenarios": {}} 

460 ) 

461 day["count"] += 1 

462 day["hour"][local.hour] += 1 

463 _bump(day["outcome"], str(contents.get("outcome") or "unknown").lower()) 

464 _bump(day["priority"], str(contents.get("priority") or "unknown").lower()) 

465 for name, outcomes in (contents.get("deliveries") or {}).items(): 

466 if not isinstance(outcomes, dict): 

467 continue 

468 sent, failed = bool(outcomes.get("success")), bool(outcomes.get("error")) 

469 if sent or failed: 

470 counts: dict[str, int] = day["deliveries"].setdefault(name, {"success": 0, "failed": 0}) 

471 counts["success"] += sent 

472 counts["failed"] += failed 

473 for scenario in _scenario_names(contents.get("enabled_scenarios")): 

474 _bump(day["scenarios"], scenario) 

475 ordered: list[dict[str, Any]] = [days[key] for key in sorted(days)] 

476 return {"days": ordered, "count": sum(day["count"] for day in ordered)} 

477 

478 

479def summarize_notification( 

480 engine: SupernotifyEngine, contents: dict[str, Any], include_provenance: bool = True 

481) -> dict[str, Any]: 

482 """Cut an archived or live notification down to what explains what happened to it. 

483 

484 `missed` counts the deliveries that were asked for but could not go out - the reason 

485 a notification is a `partial_delivery` when nothing failed. `delivery_provenance`, why 

486 each delivery was enabled or disabled, is about half of a summary: `enquire_archive` 

487 leaves it out of its `summary` verbosity, the assistant tools keep it.""" 

488 deliveries: dict[str, dict[str, Any]] = {} 

489 for name, outcomes in (contents.get("deliveries") or {}).items(): 

490 if skipped := outcomes.get("skipped"): 

491 deliveries[name] = {"skipped": skipped.get("suppression_reason")} 

492 continue 

493 summary: dict[str, Any] = {} 

494 recipients: set[str] = set() 

495 for outcome in ("success", "suppressed", "error"): 

496 envelopes: list[dict[str, Any]] = outcomes.get(outcome) or [] 

497 if not envelopes: 

498 continue 

499 summary[outcome] = len(envelopes) 

500 for envelope in envelopes: 

501 recipients.update(((envelope.get("target") or {}).get("person_id")) or []) 

502 if outcome == "suppressed" and envelope.get("skip_reason"): 

503 summary.setdefault("reasons", []).append(envelope["skip_reason"]) 

504 if outcome == "error": 

505 summary.setdefault("errors", []).extend( 

506 call.get("exception") for call in envelope.get("failed_calls") or [] if call.get("exception") 

507 ) 

508 if recipients: 

509 summary["recipients"] = _names_for(engine, sorted(recipients)) 

510 deliveries[name] = summary 

511 condition_variables: dict[str, Any] = contents.get("condition_variables") or {} 

512 result: dict[str, Any] = { 

513 "id": contents.get("id"), 

514 "created": contents.get("created"), 

515 "outcome": contents.get("outcome"), 

516 "missed": contents.get("missed") or 0, 

517 "message": contents.get("message"), 

518 "title": condition_variables.get("notification_title"), 

519 "priority": contents.get("priority"), 

520 "scenarios": _scenario_names(contents.get("enabled_scenarios")), 

521 "occupancy": { 

522 state: _names_for(engine, [p.get("person") for p in people if p.get("person")]) 

523 for state, people in (contents.get("occupancy") or {}).items() 

524 }, 

525 "deliveries": deliveries, 

526 } 

527 if include_provenance: 

528 result["delivery_provenance"] = contents.get("delivery_provenance") or {} 

529 if contents.get("unknown_names"): 

530 result["unknown_names"] = contents["unknown_names"] 

531 if contents.get("_suppression_reason"): 

532 result["suppressed"] = contents["_suppression_reason"] 

533 if requester := (contents.get("original_context") or {}).get("user"): 

534 result["requested_by"] = requester 

535 return result