From 5f53bcbe45676d3fe20d5fb245571c61be12d77f Mon Sep 17 00:00:00 2001 From: "John T. Wodder II" Date: Tue, 9 Jul 2024 08:21:21 -0400 Subject: [PATCH 1/6] [skip ci] Traverse assets via Archive API --- code/setup.cfg | 1 + code/src/healthstatus/__main__.py | 13 ++-- code/src/healthstatus/adandi.py | 119 +++++++++++++++++++++++++++++ code/src/healthstatus/aioutil.py | 71 ++++++++++++++++- code/src/healthstatus/checker.py | 123 ++++++++++++++---------------- code/src/healthstatus/config.py | 2 + 6 files changed, 254 insertions(+), 75 deletions(-) create mode 100644 code/src/healthstatus/adandi.py diff --git a/code/setup.cfg b/code/setup.cfg index 7b2bf08bc..12049cb9d 100644 --- a/code/setup.cfg +++ b/code/setup.cfg @@ -23,6 +23,7 @@ install_requires = async_generator ~= 1.10; python_version < '3.10' click >= 8.0 ghreq ~= 0.1 + httpx ~= 0.22 hdmf packaging pydantic ~= 2.0 diff --git a/code/src/healthstatus/__main__.py b/code/src/healthstatus/__main__.py index a74f0d7b8..5935827f1 100644 --- a/code/src/healthstatus/__main__.py +++ b/code/src/healthstatus/__main__.py @@ -103,7 +103,7 @@ def check( for t in TESTS: pkg_versions.update(t.prepare()) hs = HealthStatus( - backup_root=mount_point, + mount_point=mount_point, reports_root=Path.cwd(), dandisets=dandisets, dandiset_jobs=dandiset_jobs, @@ -211,11 +211,11 @@ def test_files(testname: str, files: tuple[Path, ...], save_results: bool) -> No pkg_versions.update(t.prepare(minimal=t.NAME != testname)) testfunc = TESTS.get(testname) ok = True - dandiset_cache: dict[Path, tuple[Dandiset, set[AssetPath]]] = {} + dandiset_cache: dict[Path, Dandiset] = {} for f in files: if save_results and (path := find_dandiset(Path(f))) is not None: try: - dandiset, asset_paths = dandiset_cache[path] + dandiset = dandiset_cache[path] except KeyError: dandiset = Dandiset( identifier=path.name, @@ -223,13 +223,11 @@ def test_files(testname: str, files: tuple[Path, ...], save_results: bool) -> No reports_root=Path.cwd(), versions=pkg_versions, ) - asset_paths = anyio.run(dandiset.get_asset_paths) - dandiset_cache[path] = (dandiset, asset_paths) + dandiset_cache[path] = dandiset report = AssetReport(dandiset=dandiset) ap = AssetPath(Path(f).relative_to(path).as_posix()) else: report = None - asset_paths = None ap = None log.info("Testing %s ...", f) r = anyio.run(testfunc.run, f) @@ -240,7 +238,6 @@ def test_files(testname: str, files: tuple[Path, ...], save_results: bool) -> No ok = False if save_results: assert report is not None - assert asset_paths is not None assert ap is not None atr = AssetTestResult( testname=testname, @@ -248,7 +245,7 @@ def test_files(testname: str, files: tuple[Path, ...], save_results: bool) -> No result=r, ) report.register_test_result(atr) - report.dump(asset_paths) + report.dump() sys.exit(0 if ok else 1) diff --git a/code/src/healthstatus/adandi.py b/code/src/healthstatus/adandi.py new file mode 100644 index 000000000..1e3be6748 --- /dev/null +++ b/code/src/healthstatus/adandi.py @@ -0,0 +1,119 @@ +from __future__ import annotations +from collections.abc import AsyncGenerator +from dataclasses import InitVar, dataclass, field +from datetime import datetime +import platform +import sys +from typing import Any +from anyio.abc import AsyncResource +import httpx +from pydantic import BaseModel +from .aioutil import arequest + +if sys.version_info[:2] >= (3, 10): + from contextlib import aclosing +else: + from async_generator import aclosing + +USER_AGENT = "dandisets-healthstatus ({}) httpx/{} {}/{}".format( + "https://github.com/dandi/dandisets-healthstatus", + httpx.__version__, + platform.python_implementation(), + platform.python_version(), +) + + +@dataclass +class AsyncDandiClient(AsyncResource): + api_url: str + token: InitVar[str | None] = None + session: httpx.AsyncClient = field(init=False) + + def __post_init__(self, token: str | None) -> None: + headers = {"User-Agent": USER_AGENT} + if token is not None: + headers["Authorization"] = f"token {token}" + self.session = httpx.AsyncClient( + base_url=self.api_url, + headers=headers, + follow_redirects=True, + ) + + async def aclose(self) -> None: + await self.session.aclose() + + def get_url(self, path: str) -> str: + if path.lower().startswith(("http://", "https://")): + return path + else: + return self.api_url.rstrip("/") + "/" + path.lstrip("/") + + async def get(self, path: str, **kwargs: Any) -> Any: + return (await arequest(self.session, "GET", path, **kwargs)).json() + + async def paginate( + self, + path: str, + page_size: int | None = None, + params: dict | None = None, + **kwargs: Any, + ) -> AsyncGenerator: + """ + Paginate through the resources at the given path: GET the path, yield + the values in the ``"results"`` key, and repeat with the URL in the + ``"next"`` key until it is ``null``. + """ + if page_size is not None: + if params is None: + params = {} + params["page_size"] = page_size + r = await self.get(path, params=params, **kwargs) + while True: + for item in r["results"]: + yield item + if r.get("next"): + r = await self.get(r["next"], **kwargs) + else: + break + + async def get_dandiset(self, dandiset_id: str) -> DandisetInfo: + return DandisetInfo.from_raw_response( + await self.get(f"/dandisets/{dandiset_id}/") + ) + + async def get_dandisets(self) -> AsyncGenerator[DandisetInfo, None]: + async with aclosing(self.paginate("/dandisets/")) as ait: + async for data in ait: + yield DandisetInfo.from_raw_response(data) + + async def get_asset_paths(self, dandiset_id: str) -> AsyncGenerator[str, None]: + async with aclosing( + self.paginate( + f"/dandisets/{dandiset_id}/versions/draft/assets/", + params={"order": "created", "page_size": "1000"}, + ) + ) as ait: + async for item in ait: + yield item["path"] + + +@dataclass +class DandisetInfo: + identifier: str + draft_modified: datetime + + @classmethod + def from_raw_response(cls, data: dict[str, Any]) -> DandisetInfo: + resp = DandisetResponse.model_validate(data) + return cls( + identifier=resp.identifier, draft_modified=resp.draft_version.modified + ) + + +class VersionInfo(BaseModel): + modified: datetime + + +class DandisetResponse(BaseModel): + identifier: str + draft_version: VersionInfo diff --git a/code/src/healthstatus/aioutil.py b/code/src/healthstatus/aioutil.py index a7af37990..b6968794d 100644 --- a/code/src/healthstatus/aioutil.py +++ b/code/src/healthstatus/aioutil.py @@ -1,10 +1,14 @@ from __future__ import annotations -from collections.abc import AsyncGenerator, Callable +from collections.abc import AsyncGenerator, Callable, Container, Iterator import math +import random +import ssl import sys -from typing import Awaitable, TypeVar +from typing import Any, Awaitable, TypeVar import anyio from anyio.streams.memory import MemoryObjectReceiveStream +import httpx +from .core import log if sys.version_info[:2] >= (3, 10): from contextlib import aclosing @@ -32,3 +36,66 @@ async def dowork(rec: MemoryObjectReceiveStream[T]) -> None: async with sender, aclosing(inputs): async for item in inputs: await sender.send(item) + + +async def arequest( + client: httpx.AsyncClient, + method: str, + url: str, + retry_on: Container[int] = (), + **kwargs: Any, +) -> httpx.Response: + waits = exp_wait(attempts=15, base=2) + kwargs.setdefault("timeout", 60) + while True: + try: + r = await client.request(method, url, follow_redirects=True, **kwargs) + r.raise_for_status() + except (httpx.HTTPError, ssl.SSLError) as e: + if isinstance(e, (httpx.RequestError, ssl.SSLError)) or ( + isinstance(e, httpx.HTTPStatusError) + and ( + e.response.status_code >= 500 or e.response.status_code in retry_on + ) + ): + try: + delay = next(waits) + except StopIteration: + raise e + log.warning( + "Retrying %s request to %s in %f seconds as it raised %s: %s", + method.upper(), + url, + delay, + type(e).__name__, + str(e), + ) + await anyio.sleep(delay) + continue + else: + raise + return r + + +def exp_wait( + base: float = 1.25, + multiplier: float = 1, + attempts: int | None = None, + jitter: float = 0.1, +) -> Iterator[float]: + """ + Returns a generator of values usable as `sleep()` times when retrying + something with exponential backoff. + + :param float base: + :param float multiplier: value to multiply values by after exponentiation + :param Optional[int] attempts: how many values to yield; set to `None` to + yield forever + :param Optional[float] jitter: add +1 of that jitter ratio for the time + randomly so that wait track is unique. + :rtype: Iterator[float] + """ + n = 0 + while attempts is None or n < attempts: + yield (base**n * multiplier) * (1 + (random.random() - 0.5) * jitter) + n += 1 diff --git a/code/src/healthstatus/checker.py b/code/src/healthstatus/checker.py index de166c0e8..ca62817a1 100644 --- a/code/src/healthstatus/checker.py +++ b/code/src/healthstatus/checker.py @@ -1,17 +1,18 @@ from __future__ import annotations -from collections import defaultdict, deque -from collections.abc import AsyncGenerator, AsyncIterator +from collections import defaultdict +from collections.abc import AsyncGenerator from dataclasses import dataclass, field -from datetime import datetime, timezone +from datetime import datetime from os.path import getsize from pathlib import Path from random import choice -import re +import sys import textwrap from typing import Optional import anyio +from .adandi import AsyncDandiClient, DandisetInfo from .aioutil import pool_tasks -from .config import WORKERS_PER_DANDISET +from .config import DANDI_API_URL, WORKERS_PER_DANDISET from .core import ( Asset, AssetPath, @@ -24,6 +25,11 @@ ) from .tests import TESTS, Test +if sys.version_info[:2] >= (3, 10): + from contextlib import aclosing +else: + from async_generator import aclosing + @dataclass class TestCase: @@ -77,12 +83,16 @@ async def run(self) -> UntestedAsset: @dataclass class HealthStatus: - backup_root: Path + client: AsyncDandiClient = field(init=False) + mount_point: Path reports_root: Path dandisets: tuple[str, ...] dandiset_jobs: int versions: dict[str, str] + def __post_init__(self) -> None: + self.client = AsyncDandiClient(api_url=DANDI_API_URL) + async def run_all(self) -> None: async def dowork(dandiset: Dandiset) -> None: (await dandiset.test_all_assets()).dump() @@ -100,27 +110,26 @@ async def run_random_assets(self, mode: str) -> None: async def dowork(dandiset: Dandiset) -> None: report = await tester(dandiset) if report is not None: - report.dump(await dandiset.get_asset_paths()) + report.dump() await pool_tasks(dowork, self.aiterdandisets(), self.dandiset_jobs) async def aiterdandisets(self) -> AsyncGenerator[Dandiset, None]: if self.dandisets: for did in self.dandisets: - yield await self.get_dandiset( - did, self.backup_root / "dandisets" / did / "draft" - ) + yield self.make_dandiset(await self.client.get_dandiset(did)) else: - async for p in anyio.Path(self.backup_root / "dandisets").iterdir(): - if re.fullmatch(r"\d{6,}", p.name) and await p.is_dir(): - log.info("Found Dandiset %s", p.name) - yield await self.get_dandiset(p.name, Path(p, "draft")) + async with aclosing(self.client.get_dandisets()) as ait: + async for info in ait: + log.info("Found Dandiset %s", info.identifier) + yield self.make_dandiset(info) - async def get_dandiset(self, identifier: str, path: Path) -> Dandiset: + def make_dandiset(self, info: DandisetInfo) -> Dandiset: return Dandiset( - identifier=identifier, - path=path, - reports_root=self.reports_root, + identifier=info.identifier, + draft_modified=info.draft_modified, + path=self.mount_point / "dandisets" / info.identifier / "draft", + reportdir=self.reports_root / "results" / info.identifier, versions=self.versions, ) @@ -128,19 +137,11 @@ async def get_dandiset(self, identifier: str, path: Path) -> Dandiset: @dataclass class Dandiset: identifier: str + draft_modified: datetime path: Path - draft_mtime: datetime = field(init=False) - reports_root: Path + reportdir: Path versions: dict[str, str] - def __post_init__(self) -> None: - mtime = self.path.stat().st_mtime - self.draft_mtime = datetime.fromtimestamp(mtime, timezone.utc) - - @property - def reportdir(self) -> Path: - return self.reports_root / "results" / self.identifier - @property def statusfile(self) -> Path: return self.reportdir / "status.yaml" @@ -184,9 +185,12 @@ async def aiterassets() -> AsyncGenerator[TestCase | Untested, None]: async def test_random_asset(self) -> Optional[AssetReport]: log.info("Scanning Dandiset %s", self.identifier) - all_nwbs = [asset async for asset in self.aiterassets() if asset.is_nwb()] + all_assets = [asset async for asset in self.aiterassets()] + all_nwbs = [asset for asset in all_assets if asset.is_nwb()] if all_nwbs: - return await self.test_one_asset(choice(all_nwbs)) + report = await self.test_one_asset(choice(all_nwbs)) + report.set_asset_paths({asset.asset_path for asset in all_assets}) + return report else: log.info("Dandiset %s: no NWB assets", self.identifier) return None @@ -203,11 +207,15 @@ async def test_random_outdated_asset_first(self) -> Optional[AssetReport]: if asset_paths: p = choice(list(asset_paths)) asset = Asset(filepath=self.path / p, asset_path=p) - return await self.test_one_asset(asset) + report = await self.test_one_asset(asset) + report.set_asset_paths( + {asset.asset_path async for asset in self.aiterassets()} + ) + return report else: log.info( "Dandiset %s: no outdated assets in status.yaml; selecting from" - " all assets on disk", + " all assets in Archive", self.identifier, ) return await self.test_random_asset() @@ -225,35 +233,15 @@ async def aiterjobs() -> AsyncGenerator[TestCase, None]: await pool_tasks(dowork, aiterjobs(), WORKERS_PER_DANDISET) return report - async def aiterassets(self) -> AsyncIterator[Asset]: - def mkasset(filepath: anyio.Path) -> Asset: - return Asset( - filepath=Path(filepath), - asset_path=AssetPath(filepath.relative_to(self.path).as_posix()), - ) - - dirs = deque([anyio.Path(self.path)]) - while dirs: - async for p in dirs.popleft().iterdir(): - if p.name in ( - ".dandi", - ".datalad", - ".git", - ".gitattributes", - ".gitmodules", - ): - continue - if await p.is_dir(): - if p.suffix in (".zarr", ".ngff"): - yield mkasset(p) - else: - dirs.append(p) - elif p.name != "dandiset.yaml": - yield mkasset(p) - - async def get_asset_paths(self) -> set[AssetPath]: - log.info("Scanning Dandiset %s", self.identifier) - return {asset.asset_path async for asset in self.aiterassets()} + async def aiterassets(self) -> AsyncGenerator[Asset, None]: + async with aclosing( + self.healthstatus.client.get_asset_paths(self.identifier) + ) as ait: + async for path in ait: + yield Asset( + filepath=self.path / path, + asset_path=AssetPath(path), + ) @dataclass @@ -280,7 +268,7 @@ def as_status(self) -> DandisetStatus: assert self.ended is not None return DandisetStatus( dandiset=self.dandiset.identifier, - draft_modified=self.dandiset.draft_mtime, + draft_modified=self.dandiset.draft_modified, last_run=self.started, last_run_ended=self.ended, last_run_duration=(self.ended - self.started).total_seconds(), @@ -342,23 +330,28 @@ class AssetReport: dandiset: Dandiset results: list[AssetTestResult] = field(default_factory=list) started: datetime = field(default_factory=lambda: datetime.now().astimezone()) + asset_paths: set[AssetPath] | None = None def register_test_result(self, r: AssetTestResult) -> None: self.results.append(r) - def dump(self, asset_paths: set[AssetPath]) -> None: + def set_asset_paths(self, paths: set[AssetPath]) -> None: + self.asset_paths = paths + + def dump(self) -> None: try: status = self.dandiset.load_status() except FileNotFoundError: status = DandisetStatus( dandiset=self.dandiset.identifier, - draft_modified=self.dandiset.draft_mtime, + draft_modified=self.dandiset.draft_modified, tests=[TestStatus(name=testname) for testname in TESTS.keys()], versions=self.dandiset.versions, ) for r in self.results: status.update_asset(r, self.dandiset.versions) - status.retain(asset_paths, self.dandiset.versions) + if self.asset_paths is not None: + status.retain(self.asset_paths, self.dandiset.versions) self.dandiset.dump_status(status) for r in self.results: if r.outcome is Outcome.FAIL: diff --git a/code/src/healthstatus/config.py b/code/src/healthstatus/config.py index 06cd0da7e..2e586a086 100644 --- a/code/src/healthstatus/config.py +++ b/code/src/healthstatus/config.py @@ -1,5 +1,7 @@ from pathlib import Path +DANDI_API_URL = "https://api.dandiarchive.org/api" + MATNWB_INSTALL_DIR = Path("matnwb") # in current working directory PACKAGES_TO_VERSION = ["pynwb", "hdmf"] From 8e6b6683112665ef235cef045415bc64432f5370 Mon Sep 17 00:00:00 2001 From: "John T. Wodder II" Date: Tue, 9 Jul 2024 09:54:50 -0400 Subject: [PATCH 2/6] Rearchitecture --- code/src/healthstatus/__main__.py | 22 +- code/src/healthstatus/checker.py | 367 ++++++++++++++++-------------- code/src/healthstatus/core.py | 4 +- code/src/healthstatus/util.py | 5 + 4 files changed, 218 insertions(+), 180 deletions(-) diff --git a/code/src/healthstatus/__main__.py b/code/src/healthstatus/__main__.py index 5935827f1..00d204807 100644 --- a/code/src/healthstatus/__main__.py +++ b/code/src/healthstatus/__main__.py @@ -12,7 +12,7 @@ import anyio import click from packaging.version import Version -from .checker import AssetReport, Dandiset, HealthStatus +from .checker import DandisetReporter, HealthStatus from .core import AssetPath, AssetTestResult, DandisetStatus, Outcome, TestSummary, log from .mounts import ( AssetInDandiset, @@ -211,23 +211,21 @@ def test_files(testname: str, files: tuple[Path, ...], save_results: bool) -> No pkg_versions.update(t.prepare(minimal=t.NAME != testname)) testfunc = TESTS.get(testname) ok = True - dandiset_cache: dict[Path, Dandiset] = {} + dandiset_cache: dict[Path, DandisetReporter] = {} for f in files: if save_results and (path := find_dandiset(Path(f))) is not None: try: - dandiset = dandiset_cache[path] + reporter = dandiset_cache[path] except KeyError: - dandiset = Dandiset( + reporter = DandisetReporter( identifier=path.name, - path=path, - reports_root=Path.cwd(), + reportdir=Path("results", path.name), versions=pkg_versions, ) - dandiset_cache[path] = dandiset - report = AssetReport(dandiset=dandiset) + dandiset_cache[path] = reporter ap = AssetPath(Path(f).relative_to(path).as_posix()) else: - report = None + reporter = None ap = None log.info("Testing %s ...", f) r = anyio.run(testfunc.run, f) @@ -237,15 +235,15 @@ def test_files(testname: str, files: tuple[Path, ...], save_results: bool) -> No if r.outcome is not Outcome.PASS: ok = False if save_results: - assert report is not None + assert reporter is not None assert ap is not None atr = AssetTestResult( testname=testname, asset_path=AssetPath(ap), result=r, ) - report.register_test_result(atr) - report.dump() + reporter.register_test_result(atr) + reporter.dump() sys.exit(0 if ok else 1) diff --git a/code/src/healthstatus/checker.py b/code/src/healthstatus/checker.py index ca62817a1..0de7c6295 100644 --- a/code/src/healthstatus/checker.py +++ b/code/src/healthstatus/checker.py @@ -8,6 +8,7 @@ from random import choice import sys import textwrap +from types import TracebackType from typing import Optional import anyio from .adandi import AsyncDandiClient, DandisetInfo @@ -24,6 +25,7 @@ log, ) from .tests import TESTS, Test +from .util import nowstamp if sys.version_info[:2] >= (3, 10): from contextlib import aclosing @@ -94,124 +96,113 @@ def __post_init__(self) -> None: self.client = AsyncDandiClient(api_url=DANDI_API_URL) async def run_all(self) -> None: - async def dowork(dandiset: Dandiset) -> None: - (await dandiset.test_all_assets()).dump() + async def dowork(dst: DandisetTester) -> None: + await dst.test_all_assets() + dst.reporter.dump() await pool_tasks(dowork, self.aiterdandisets(), self.dandiset_jobs) async def run_random_assets(self, mode: str) -> None: if mode == "random-asset": - tester = Dandiset.test_random_asset + tester = DandisetTester.test_random_asset elif mode == "random-outdated-asset-first": - tester = Dandiset.test_random_outdated_asset_first + tester = DandisetTester.test_random_outdated_asset_first else: raise ValueError(f"Invalid random asset mode: {mode!r}") - async def dowork(dandiset: Dandiset) -> None: - report = await tester(dandiset) - if report is not None: - report.dump() + async def dowork(dst: DandisetTester) -> None: + if await tester(dst): + dst.reporter.dump() await pool_tasks(dowork, self.aiterdandisets(), self.dandiset_jobs) - async def aiterdandisets(self) -> AsyncGenerator[Dandiset, None]: - if self.dandisets: - for did in self.dandisets: - yield self.make_dandiset(await self.client.get_dandiset(did)) - else: - async with aclosing(self.client.get_dandisets()) as ait: - async for info in ait: - log.info("Found Dandiset %s", info.identifier) - yield self.make_dandiset(info) - - def make_dandiset(self, info: DandisetInfo) -> Dandiset: - return Dandiset( - identifier=info.identifier, - draft_modified=info.draft_modified, - path=self.mount_point / "dandisets" / info.identifier / "draft", - reportdir=self.reports_root / "results" / info.identifier, - versions=self.versions, - ) + async def aiterdandisets(self) -> AsyncGenerator[DandisetTester, None]: + async def inner() -> AsyncGenerator[DandisetInfo, None]: + if self.dandisets: + for did in self.dandisets: + yield await self.client.get_dandiset(did) + else: + async with aclosing(self.client.get_dandisets()) as ait: + async for info in ait: + log.info("Found Dandiset %s", info.identifier) + yield info + + async with aclosing(inner()) as ait: + async for info in ait: + yield DandisetTester( + reporter=DandisetReporter( + identifier=info.identifier, + draft_modified=info.draft_modified, + reportdir=self.reports_root / "results" / info.identifier, + versions=self.versions, + ), + mount_path=( + self.mount_point / "dandisets" / info.identifier / "draft" + ), + client=self.client, + ) @dataclass -class Dandiset: - identifier: str - draft_modified: datetime - path: Path - reportdir: Path - versions: dict[str, str] +class DandisetTester: + reporter: DandisetReporter + mount_path: Path + client: AsyncDandiClient @property - def statusfile(self) -> Path: - return self.reportdir / "status.yaml" - - def load_status(self) -> DandisetStatus: - return DandisetStatus.from_file(self.identifier, self.statusfile) + def identifier(self) -> str: + return self.reporter.identifier - def dump_status(self, status: DandisetStatus) -> None: - self.statusfile.parent.mkdir(parents=True, exist_ok=True) - status.to_file(self.statusfile) - - async def test_all_assets(self) -> DandisetReport: + async def test_all_assets(self) -> None: log.info("Processing Dandiset %s", self.identifier) - report = DandisetReport(dandiset=self) + with self.reporter.session() as report: - async def dowork(job: TestCase | Untested) -> None: - res = await job.run() - if isinstance(res, AssetTestResult): - report.register_test_result(res) - else: - assert isinstance(res, UntestedAsset) - report.register_untested(res) - - async def aiterassets() -> AsyncGenerator[TestCase | Untested, None]: - async for asset in self.aiterassets(): - log.info( - "Dandiset %s: found asset %s", self.identifier, asset.asset_path - ) - report.nassets += 1 - if asset.is_nwb(): - for t in TESTS: - yield TestCase( - asset=asset, testfunc=t, dandiset_id=self.identifier - ) + async def dowork(job: TestCase | Untested) -> None: + res = await job.run() + if isinstance(res, AssetTestResult): + report.register_test_result(res) else: - yield Untested(asset=asset, dandiset_id=self.identifier) + assert isinstance(res, UntestedAsset) + report.register_untested(res) - await pool_tasks(dowork, aiterassets(), WORKERS_PER_DANDISET) - report.finished() - return report - - async def test_random_asset(self) -> Optional[AssetReport]: + async def aitercases() -> AsyncGenerator[TestCase | Untested, None]: + async for asset in self.aiterassets(): + log.info( + "Dandiset %s: found asset %s", self.identifier, asset.asset_path + ) + if asset.is_nwb(): + for t in TESTS: + yield TestCase( + asset=asset, testfunc=t, dandiset_id=self.identifier + ) + else: + yield Untested(asset=asset, dandiset_id=self.identifier) + + await pool_tasks(dowork, aitercases(), WORKERS_PER_DANDISET) + + async def test_random_asset(self) -> bool: + # Returns True if anything tested log.info("Scanning Dandiset %s", self.identifier) all_assets = [asset async for asset in self.aiterassets()] all_nwbs = [asset for asset in all_assets if asset.is_nwb()] if all_nwbs: - report = await self.test_one_asset(choice(all_nwbs)) - report.set_asset_paths({asset.asset_path for asset in all_assets}) - return report + await self.test_one_asset(choice(all_nwbs)) + self.reporter.set_asset_paths({asset.asset_path for asset in all_assets}) + return True else: log.info("Dandiset %s: no NWB assets", self.identifier) - return None - - async def test_random_outdated_asset_first(self) -> Optional[AssetReport]: - try: - status = self.load_status() - except FileNotFoundError: - asset_paths = set() - else: - asset_paths = { - path for t in status.tests for path in t.outdated_assets(self.versions) - } - if asset_paths: - p = choice(list(asset_paths)) - asset = Asset(filepath=self.path / p, asset_path=p) - report = await self.test_one_asset(asset) - report.set_asset_paths( + return False + + async def test_random_outdated_asset_first(self) -> bool: + # Returns True if anything tested + if outdated := self.reporter.outdated_assets(): + p = choice(list(outdated)) + asset = Asset(filepath=self.mount_path / p, asset_path=p) + await self.test_one_asset(asset) + self.reporter.set_asset_paths( {asset.asset_path async for asset in self.aiterassets()} ) - return report + return True else: log.info( "Dandiset %s: no outdated assets in status.yaml; selecting from" @@ -220,60 +211,147 @@ async def test_random_outdated_asset_first(self) -> Optional[AssetReport]: ) return await self.test_random_asset() - async def test_one_asset(self, asset: Asset) -> AssetReport: - report = AssetReport(dandiset=self) - + async def test_one_asset(self, asset: Asset) -> None: async def dowork(job: TestCase) -> None: - report.register_test_result(await job.run()) + self.reporter.register_test_result(await job.run()) async def aiterjobs() -> AsyncGenerator[TestCase, None]: for t in TESTS: yield TestCase(asset=asset, testfunc=t, dandiset_id=self.identifier) await pool_tasks(dowork, aiterjobs(), WORKERS_PER_DANDISET) - return report async def aiterassets(self) -> AsyncGenerator[Asset, None]: - async with aclosing( - self.healthstatus.client.get_asset_paths(self.identifier) - ) as ait: + async with aclosing(self.client.get_asset_paths(self.identifier)) as ait: async for path in ait: yield Asset( - filepath=self.path / path, + filepath=self.mount_path / path, asset_path=AssetPath(path), ) @dataclass -class DandisetReport: - dandiset: Dandiset - nassets: int = 0 +class DandisetReporter: + identifier: str + draft_modified: datetime + reportdir: Path + versions: dict[str, str] + status: DandisetStatus = field(init=False) + errors: list[TestError] = field(init=False, default_factory=list) + started: datetime = field(init=False, default_factory=nowstamp) + + def __post_init__(self) -> None: + try: + self.status = DandisetStatus.from_file(self.identifier, self.statusfile) + except FileNotFoundError: + self.status = DandisetStatus( + dandiset=self.identifier, + draft_modified=self.draft_modified, + tests=[TestStatus(name=testname) for testname in TESTS.keys()], + versions=self.versions, + ) + + @property + def statusfile(self) -> Path: + return self.reportdir / "status.yaml" + + def dump(self) -> None: + self.status.to_file(self.statusfile) + errors_by_test = defaultdict(list) + for e in self.errors: + errors_by_test[e.testname].append(e) + for testname, errors in errors_by_test.items(): + with ( + self.reportdir + / f"{self.started:%Y.%m.%d.%H.%M.%S}_{testname}_errors.log" + ).open("w", encoding="utf-8", errors="surrogateescape") as fp: + for e in errors: + print( + f"Asset: {e.asset}\nOutput:\n" + + textwrap.indent(e.output, " " * 4), + file=fp, + ) + + def outdated_assets(self) -> set[AssetPath]: + return { + path for t in self.status.tests for path in t.outdated_assets(self.versions) + } + + def session(self) -> DandisetSession: + return DandisetSession(self) + + def register_test_result(self, r: AssetTestResult) -> None: + self.status.update_asset(r, self.versions) + if r.outcome is Outcome.FAIL: + assert r.output is not None + self.errors.append( + TestError( + testname=r.testname, + asset=r.asset_path, + output=r.output, + ) + ) + + def set_asset_paths(self, paths: set[AssetPath]) -> None: + self.status.retain(paths, self.versions) + + +@dataclass +class DandisetSession: + reporter: DandisetReporter tests: dict[str, TestReport] = field( - default_factory=lambda: defaultdict(TestReport) + init=False, default_factory=lambda: defaultdict(TestReport) ) - untested: list[UntestedAsset] = field(default_factory=list) - started: datetime = field(default_factory=lambda: datetime.now().astimezone()) - ended: Optional[datetime] = None + untested: list[UntestedAsset] = field(init=False, default_factory=list) + errors: list[TestError] = field(init=False, default_factory=list) + ended: Optional[datetime] = field(init=False, default=None) + + def __enter__(self) -> DandisetSession: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + _exc_val: BaseException | None, + _exc_tb: TracebackType | None, + ) -> None: + if exc_type is None: + self.ended = datetime.now().astimezone() + self.reporter.status = self.as_status() + self.reporter.errors = self.errors def register_test_result(self, r: AssetTestResult) -> None: - self.tests[r.testname].by_outcome[r.outcome].append(r) + self.tests[r.testname].register(r) + if r.outcome is Outcome.FAIL: + assert r.output is not None + self.errors.append( + TestError( + testname=r.testname, + asset=r.asset_path, + output=r.output, + ) + ) def register_untested(self, d: UntestedAsset) -> None: self.untested.append(d) - def finished(self) -> None: - self.ended = datetime.now().astimezone() + @property + def nassets(self) -> int: + return len( + {p for report in self.tests.values() for p in report.asset_paths()} + | {ua.asset for ua in self.untested} + ) def as_status(self) -> DandisetStatus: assert self.ended is not None return DandisetStatus( - dandiset=self.dandiset.identifier, - draft_modified=self.dandiset.draft_modified, - last_run=self.started, + dandiset=self.reporter.identifier, + draft_modified=self.reporter.draft_modified, + last_run=self.reporter.started, last_run_ended=self.ended, - last_run_duration=(self.ended - self.started).total_seconds(), + last_run_duration=(self.ended - self.reporter.started).total_seconds(), nassets=self.nassets, - versions=self.dandiset.versions, + versions=self.reporter.versions, tests=[ TestStatus( name=name, @@ -289,22 +367,6 @@ def as_status(self) -> DandisetStatus: untested=self.untested, ) - def dump(self) -> None: - self.dandiset.dump_status(self.as_status()) - for testname, report in self.tests.items(): - if report.failed: - with ( - self.dandiset.reportdir - / f"{self.started:%Y.%m.%d.%H.%M.%S}_{testname}_errors.log" - ).open("w", encoding="utf-8", errors="surrogateescape") as fp: - for r in report.failed: - assert r.output is not None - print( - f"Asset: {r.asset_path}\nOutput:\n" - + textwrap.indent(r.output, " " * 4), - file=fp, - ) - @dataclass class TestReport: @@ -312,6 +374,9 @@ class TestReport: init=False, default_factory=lambda: defaultdict(list) ) + def register(self, r: AssetTestResult) -> None: + self.by_outcome[r.outcome].append(r) + @property def passed(self) -> list[AssetTestResult]: return self.by_outcome[Outcome.PASS] @@ -324,44 +389,12 @@ def failed(self) -> list[AssetTestResult]: def timedout(self) -> list[AssetTestResult]: return self.by_outcome[Outcome.TIMEOUT] + def asset_paths(self) -> set[AssetPath]: + return {atr.asset_path for lst in self.by_outcome.values() for atr in lst} -@dataclass -class AssetReport: - dandiset: Dandiset - results: list[AssetTestResult] = field(default_factory=list) - started: datetime = field(default_factory=lambda: datetime.now().astimezone()) - asset_paths: set[AssetPath] | None = None - - def register_test_result(self, r: AssetTestResult) -> None: - self.results.append(r) - - def set_asset_paths(self, paths: set[AssetPath]) -> None: - self.asset_paths = paths - def dump(self) -> None: - try: - status = self.dandiset.load_status() - except FileNotFoundError: - status = DandisetStatus( - dandiset=self.dandiset.identifier, - draft_modified=self.dandiset.draft_modified, - tests=[TestStatus(name=testname) for testname in TESTS.keys()], - versions=self.dandiset.versions, - ) - for r in self.results: - status.update_asset(r, self.dandiset.versions) - if self.asset_paths is not None: - status.retain(self.asset_paths, self.dandiset.versions) - self.dandiset.dump_status(status) - for r in self.results: - if r.outcome is Outcome.FAIL: - with ( - self.dandiset.reportdir - / f"{self.started:%Y.%m.%d.%H.%M.%S}_{r.testname}_errors.log" - ).open("a", encoding="utf-8", errors="surrogateescape") as fp: - assert r.output is not None - print( - f"Asset: {r.asset_path}\nOutput:\n" - + textwrap.indent(r.output, " " * 4), - file=fp, - ) +@dataclass +class TestError: + testname: str + asset: AssetPath + output: str diff --git a/code/src/healthstatus/core.py b/code/src/healthstatus/core.py index cb4f22707..be892398b 100644 --- a/code/src/healthstatus/core.py +++ b/code/src/healthstatus/core.py @@ -144,7 +144,7 @@ def summary(self, pagelink: str) -> str: class UntestedAsset(BaseModel): - asset: str + asset: AssetPath size: int file_type: str mime_type: str @@ -180,6 +180,7 @@ def from_file(cls, dandiset: str, yamlfile: Path) -> DandisetStatus: return cls.model_validate({"dandiset": dandiset, **load_yaml_lineno(fp)}) def to_file(self, path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) jsonable = self.model_dump(mode="json") path.write_text(yaml.dump(jsonable)) @@ -206,6 +207,7 @@ def retain( t.assets_timeout = [ a for a in t.assets_timeout if getpath(a) in asset_paths ] + self.nassets = len(asset_paths) self.prune_versions(current_versions) def prune_versions(self, current_versions: dict[str, str]) -> None: diff --git a/code/src/healthstatus/util.py b/code/src/healthstatus/util.py index 6bfa9fe92..386d1b4ce 100644 --- a/code/src/healthstatus/util.py +++ b/code/src/healthstatus/util.py @@ -1,5 +1,6 @@ from __future__ import annotations from dataclasses import dataclass +from datetime import datetime from pathlib import Path import subprocess import requests @@ -71,3 +72,7 @@ def get_version(self) -> str: text=True, check=True, ).stdout.strip() + + +def nowstamp() -> datetime: + return datetime.now().astimezone() From 3381952ad45c63f65ac0a3f159276e144e1c0060 Mon Sep 17 00:00:00 2001 From: "John T. Wodder II" Date: Tue, 9 Jul 2024 10:06:06 -0400 Subject: [PATCH 3/6] Don't try to test outdated assets that no longer exist --- code/src/healthstatus/checker.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/code/src/healthstatus/checker.py b/code/src/healthstatus/checker.py index 0de7c6295..fc024e3b0 100644 --- a/code/src/healthstatus/checker.py +++ b/code/src/healthstatus/checker.py @@ -106,7 +106,7 @@ async def run_random_assets(self, mode: str) -> None: if mode == "random-asset": tester = DandisetTester.test_random_asset elif mode == "random-outdated-asset-first": - tester = DandisetTester.test_random_outdated_asset_first + tester = DandisetTester.test_random_outdated_asset_first # type: ignore[assignment] else: raise ValueError(f"Invalid random asset mode: {mode!r}") @@ -180,10 +180,11 @@ async def aitercases() -> AsyncGenerator[TestCase | Untested, None]: await pool_tasks(dowork, aitercases(), WORKERS_PER_DANDISET) - async def test_random_asset(self) -> bool: + async def test_random_asset(self, all_assets: list[Asset] | None = None) -> bool: # Returns True if anything tested log.info("Scanning Dandiset %s", self.identifier) - all_assets = [asset async for asset in self.aiterassets()] + if all_assets is None: + all_assets = [asset async for asset in self.aiterassets()] all_nwbs = [asset for asset in all_assets if asset.is_nwb()] if all_nwbs: await self.test_one_asset(choice(all_nwbs)) @@ -195,13 +196,13 @@ async def test_random_asset(self) -> bool: async def test_random_outdated_asset_first(self) -> bool: # Returns True if anything tested - if outdated := self.reporter.outdated_assets(): + all_assets = [asset async for asset in self.aiterassets()] + all_asset_paths = {asset.asset_path for asset in all_assets} + if outdated := (self.reporter.outdated_assets() & all_asset_paths): p = choice(list(outdated)) asset = Asset(filepath=self.mount_path / p, asset_path=p) await self.test_one_asset(asset) - self.reporter.set_asset_paths( - {asset.asset_path async for asset in self.aiterassets()} - ) + self.reporter.set_asset_paths(all_asset_paths) return True else: log.info( @@ -209,7 +210,7 @@ async def test_random_outdated_asset_first(self) -> bool: " all assets in Archive", self.identifier, ) - return await self.test_random_asset() + return await self.test_random_asset(all_assets) async def test_one_asset(self, asset: Asset) -> None: async def dowork(job: TestCase) -> None: From a0e27d311b504e5156c451232eb836ae9cb5fa59 Mon Sep 17 00:00:00 2001 From: "John T. Wodder II" Date: Tue, 9 Jul 2024 13:55:39 -0400 Subject: [PATCH 4/6] Don't bother with `draft_modified` for `test-files` --- code/src/healthstatus/__main__.py | 1 + code/src/healthstatus/checker.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/code/src/healthstatus/__main__.py b/code/src/healthstatus/__main__.py index 00d204807..36165e6da 100644 --- a/code/src/healthstatus/__main__.py +++ b/code/src/healthstatus/__main__.py @@ -219,6 +219,7 @@ def test_files(testname: str, files: tuple[Path, ...], save_results: bool) -> No except KeyError: reporter = DandisetReporter( identifier=path.name, + draft_modified=None, reportdir=Path("results", path.name), versions=pkg_versions, ) diff --git a/code/src/healthstatus/checker.py b/code/src/healthstatus/checker.py index fc024e3b0..64f39415c 100644 --- a/code/src/healthstatus/checker.py +++ b/code/src/healthstatus/checker.py @@ -234,7 +234,7 @@ async def aiterassets(self) -> AsyncGenerator[Asset, None]: @dataclass class DandisetReporter: identifier: str - draft_modified: datetime + draft_modified: datetime | None reportdir: Path versions: dict[str, str] status: DandisetStatus = field(init=False) From 445712864cc75fb299177ad9202eab029cb7910b Mon Sep 17 00:00:00 2001 From: "John T. Wodder II" Date: Tue, 9 Jul 2024 14:01:20 -0400 Subject: [PATCH 5/6] Require all `status.yaml` files to contain "dandiset" key The key was added quite a while ago, and all files in `results/` have it now, so we don't need to keep supporting files that don't have it. --- code/src/healthstatus/__main__.py | 2 +- code/src/healthstatus/checker.py | 2 +- code/src/healthstatus/core.py | 4 ++-- code/test/data/report-input/000001/status.yaml | 1 + code/test/data/report-input/000002/status.yaml | 1 + code/test/data/report-input/000003/status.yaml | 1 + code/test/data/report-input/000004/status.yaml | 1 + code/test/data/report-input/000005/status.yaml | 1 + code/test/data/report-input/000006/status.yaml | 1 + code/test/data/report-output.md | 10 +++++----- 10 files changed, 15 insertions(+), 9 deletions(-) diff --git a/code/src/healthstatus/__main__.py b/code/src/healthstatus/__main__.py index 36165e6da..314232f60 100644 --- a/code/src/healthstatus/__main__.py +++ b/code/src/healthstatus/__main__.py @@ -134,7 +134,7 @@ def report() -> None: assets_seen = 0 for p in Path("results").iterdir(): if re.fullmatch(r"\d{6,}", p.name) and p.is_dir(): - status = DandisetStatus.from_file(p.name, p / "status.yaml") + status = DandisetStatus.from_file(p / "status.yaml") passed, failed, timedout = status.combined_counts() asset_qtys[Outcome.PASS] += passed asset_qtys[Outcome.FAIL] += failed diff --git a/code/src/healthstatus/checker.py b/code/src/healthstatus/checker.py index 64f39415c..833e95cd2 100644 --- a/code/src/healthstatus/checker.py +++ b/code/src/healthstatus/checker.py @@ -243,7 +243,7 @@ class DandisetReporter: def __post_init__(self) -> None: try: - self.status = DandisetStatus.from_file(self.identifier, self.statusfile) + self.status = DandisetStatus.from_file(self.statusfile) except FileNotFoundError: self.status = DandisetStatus( dandiset=self.identifier, diff --git a/code/src/healthstatus/core.py b/code/src/healthstatus/core.py index be892398b..39bfa6e1f 100644 --- a/code/src/healthstatus/core.py +++ b/code/src/healthstatus/core.py @@ -175,9 +175,9 @@ def _rmlinenos(cls, value: Any) -> Any: return value @classmethod - def from_file(cls, dandiset: str, yamlfile: Path) -> DandisetStatus: + def from_file(cls, yamlfile: Path) -> DandisetStatus: with yamlfile.open() as fp: - return cls.model_validate({"dandiset": dandiset, **load_yaml_lineno(fp)}) + return cls.model_validate(load_yaml_lineno(fp)) def to_file(self, path: Path) -> None: path.parent.mkdir(parents=True, exist_ok=True) diff --git a/code/test/data/report-input/000001/status.yaml b/code/test/data/report-input/000001/status.yaml index fec0eb0d0..13dd02aba 100644 --- a/code/test/data/report-input/000001/status.yaml +++ b/code/test/data/report-input/000001/status.yaml @@ -1,3 +1,4 @@ +dandiset: '000001' dandiset_version: 9c6ab8750946cdac16c62057ec1f76f9ce954fe1 last_run: 2022-12-15 10:59:47.526056-05:00 nassets: 5 diff --git a/code/test/data/report-input/000002/status.yaml b/code/test/data/report-input/000002/status.yaml index 06f3c155e..83908c514 100644 --- a/code/test/data/report-input/000002/status.yaml +++ b/code/test/data/report-input/000002/status.yaml @@ -1,3 +1,4 @@ +dandiset: '000002' dandiset_version: 9c6ab8750946cdac16c62057ec1f76f9ce954fe1 last_run: 2022-12-15 10:59:47.526056-05:00 nassets: 3 diff --git a/code/test/data/report-input/000003/status.yaml b/code/test/data/report-input/000003/status.yaml index ad6a28027..687425c7f 100644 --- a/code/test/data/report-input/000003/status.yaml +++ b/code/test/data/report-input/000003/status.yaml @@ -1,3 +1,4 @@ +dandiset: '000003' dandiset_version: 9c6ab8750946cdac16c62057ec1f76f9ce954fe1 last_run: 2022-12-15 10:59:47.526056-05:00 nassets: 3 diff --git a/code/test/data/report-input/000004/status.yaml b/code/test/data/report-input/000004/status.yaml index c80fe8cbb..155605fd0 100644 --- a/code/test/data/report-input/000004/status.yaml +++ b/code/test/data/report-input/000004/status.yaml @@ -1,3 +1,4 @@ +dandiset: '000004' dandiset_version: 9c6ab8750946cdac16c62057ec1f76f9ce954fe1 last_run: 2022-12-15 10:59:47.526056-05:00 nassets: 3 diff --git a/code/test/data/report-input/000005/status.yaml b/code/test/data/report-input/000005/status.yaml index 098f2aac1..2354e6980 100644 --- a/code/test/data/report-input/000005/status.yaml +++ b/code/test/data/report-input/000005/status.yaml @@ -1,3 +1,4 @@ +dandiset: '000005' dandiset_version: 9c6ab8750946cdac16c62057ec1f76f9ce954fe1 last_run: 2022-12-15 10:59:47.526056-05:00 nassets: 3 diff --git a/code/test/data/report-input/000006/status.yaml b/code/test/data/report-input/000006/status.yaml index a25b00950..b59d9176e 100644 --- a/code/test/data/report-input/000006/status.yaml +++ b/code/test/data/report-input/000006/status.yaml @@ -1,3 +1,4 @@ +dandiset: '000006' dandiset_version: 9c6ab8750946cdac16c62057ec1f76f9ce954fe1 last_run: 2022-12-15 10:59:47.526056-05:00 nassets: 0 diff --git a/code/test/data/report-output.md b/code/test/data/report-output.md index 4afc570ad..e73f2925b 100644 --- a/code/test/data/report-output.md +++ b/code/test/data/report-output.md @@ -12,9 +12,9 @@ # By Dandiset | Dandiset | pynwb_open_load_ns | matnwb_nwbRead | Untested | | --- | --- | --- | --- | -| [000001](results/000001/status.yaml) | [3 passed](results/000001/status.yaml#L6), 0 failed, 0 timed out | [3 passed](results/000001/status.yaml#L13), 0 failed, 0 timed out | [2](results/000001/status.yaml#L19) | -| [000002](results/000002/status.yaml) | 0 passed, [3 failed](results/000002/status.yaml#L5), 0 timed out | 0 passed, [3 failed](results/000002/status.yaml#L12), 0 timed out | — | -| [000003](results/000003/status.yaml) | 0 passed, 0 failed, [3 timed out](results/000003/status.yaml#L7) | 0 passed, 0 failed, [3 timed out](results/000003/status.yaml#L14) | — | -| [000004](results/000004/status.yaml) | [3 passed](results/000004/status.yaml#L6), 0 failed, 0 timed out | 0 passed, [3 failed](results/000004/status.yaml#L12), 0 timed out | — | -| [000005](results/000005/status.yaml) | [3 passed](results/000005/status.yaml#L6), 0 failed, 0 timed out | [1 passed](results/000005/status.yaml#L14), [1 failed](results/000005/status.yaml#L12), [1 timed out](results/000005/status.yaml#L16) | — | +| [000001](results/000001/status.yaml) | [3 passed](results/000001/status.yaml#L7), 0 failed, 0 timed out | [3 passed](results/000001/status.yaml#L14), 0 failed, 0 timed out | [2](results/000001/status.yaml#L20) | +| [000002](results/000002/status.yaml) | 0 passed, [3 failed](results/000002/status.yaml#L6), 0 timed out | 0 passed, [3 failed](results/000002/status.yaml#L13), 0 timed out | — | +| [000003](results/000003/status.yaml) | 0 passed, 0 failed, [3 timed out](results/000003/status.yaml#L8) | 0 passed, 0 failed, [3 timed out](results/000003/status.yaml#L15) | — | +| [000004](results/000004/status.yaml) | [3 passed](results/000004/status.yaml#L7), 0 failed, 0 timed out | 0 passed, [3 failed](results/000004/status.yaml#L13), 0 timed out | — | +| [000005](results/000005/status.yaml) | [3 passed](results/000005/status.yaml#L7), 0 failed, 0 timed out | [1 passed](results/000005/status.yaml#L15), [1 failed](results/000005/status.yaml#L13), [1 timed out](results/000005/status.yaml#L17) | — | | [000006](results/000006/status.yaml) | — | — | — | From af6090f82add95da1ea3a32b1905fe025a3cbc11 Mon Sep 17 00:00:00 2001 From: "John T. Wodder II" Date: Mon, 15 Jul 2024 07:43:59 -0400 Subject: [PATCH 6/6] Always set `draft_modified` when rewriting a `status.yaml` --- code/src/healthstatus/checker.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/code/src/healthstatus/checker.py b/code/src/healthstatus/checker.py index 833e95cd2..51c73c8df 100644 --- a/code/src/healthstatus/checker.py +++ b/code/src/healthstatus/checker.py @@ -251,6 +251,8 @@ def __post_init__(self) -> None: tests=[TestStatus(name=testname) for testname in TESTS.keys()], versions=self.versions, ) + else: + self.status.draft_modified = self.draft_modified @property def statusfile(self) -> Path: