From 90012d0112f6f7e36f46848714a0ca2cad1623ee Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 13:14:38 +0000 Subject: [PATCH 1/3] Decide the refresh mtime match from the stored value, not a fixed window A flat MTIME_TOLERANCE of 2 s absorbs the coarsest filesystem granularity, but it applies that window everywhere -- including on filesystems that store the mtime exactly. There an asset genuinely replaced less than two seconds later at an identical size compares "same" and is silently not refreshed: the inverse of #1907, and one nobody would ever notice, since the symptom is a stale file rather than a slow download. The gap between the record and the local mtime can only have been introduced by the destination quantizing the value we set with os.utime(), so require it to be a gap the *observed* value can actually account for: the stored mtime must be a multiple of some known granularity that is itself wider than the gap. A filesystem truncating to whole seconds cannot have produced a stored ...21.651, so against that value even a millisecond of drift is a real change; a stored ...20.000 is consistent with truncation, and a gap up to two seconds says nothing either way. This needs no probing of the destination and no state -- the evidence is the value already being compared. Add it as is_same_mtime() beside is_same_time() in utils, since it replaces a use of the latter and answers the same kind of question. Compare in integer nanoseconds via st_mtime_ns so the multiple-of test is exact. os.utime() takes the time as a C double, so even an exact filesystem round-trips it a few tens of nanoseconds off (22 ns measured on tmpfs); that, rather than any filesystem property, is what is_same_time()'s 1 us default was really absorbing, and it is now named as MTIME_ROUNDTRIP_SLACK_NS. Tests: unit-test the predicate over the unchanged/changed matrix beside the is_same_time() tests; cover a sub-second change on a precise filesystem, which the fixed window skipped; add exFAT's 10 ms to the simulated granularities and quantize the fixture in integer nanoseconds so that granularity truncates cleanly. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01AZfG9qzAUVagkBoBxmbfCD --- dandi/consts.py | 20 ++++++----- dandi/download.py | 18 +++------- dandi/tests/test_download.py | 70 +++++++++++++++++++++++++++++++----- dandi/tests/test_utils.py | 53 +++++++++++++++++++++++++++ dandi/utils.py | 53 ++++++++++++++++++++++++++- 5 files changed, 183 insertions(+), 31 deletions(-) diff --git a/dandi/consts.py b/dandi/consts.py index baca5465c..557360283 100644 --- a/dandi/consts.py +++ b/dandi/consts.py @@ -258,11 +258,15 @@ def urls(self) -> Iterator[str]: #: Suffix used for temporary download directories DOWNLOAD_SUFFIX = ".dandidownload" -#: Tolerance (in seconds) when comparing an asset's recorded mtime against the -#: mtime read back from the downloaded file under ``-e refresh``. That local -#: mtime is one we set ourselves with ``os.utime()``, so the comparison is -#: really a filesystem round trip, and not every filesystem stores mtimes at -#: the resolution ``os.stat()`` reports them at: mounted Windows volumes, -#: exFAT and some network filesystems truncate, and FAT rounds to a multiple -#: of two seconds. See https://github.com/dandi/dandi-cli/issues/1907 -MTIME_TOLERANCE = 2.0 +#: Granularities (in nanoseconds) with which filesystems are known to store +#: mtimes: FAT rounds to a multiple of two seconds, ext3/HFS+/ISO9660 truncate +#: to whole seconds, and exFAT to 10 ms. Used under ``-e refresh`` to decide +#: whether the mtime read back from a downloaded file is a plausible +#: quantization of the one we set on it -- see `is_same_mtime()`. +MTIME_GRANULARITIES_NS = (2_000_000_000, 1_000_000_000, 10_000_000) + +#: Slack (in nanoseconds) allowed for the float round trip itself: +#: ``os.utime()`` takes the time as seconds in a C double, so even a filesystem +#: storing mtimes at full nanosecond resolution reads back a few tens of +#: nanoseconds away from what we asked for. +MTIME_ROUNDTRIP_SLACK_NS = 1_000 diff --git a/dandi/download.py b/dandi/download.py index e92aa0f7f..dc8eeb350 100644 --- a/dandi/download.py +++ b/dandi/download.py @@ -40,7 +40,7 @@ from . import get_logger from .consts import ( DOWNLOAD_SUFFIX, - MTIME_TOLERANCE, + MTIME_GRANULARITIES_NS, RETRY_STATUSES, SyncMode, dandiset_metadata_file, @@ -66,7 +66,7 @@ exclude_from_zarr, flattened, get_retry_after, - is_same_time, + is_same_mtime, path_is_subpath, pluralize, yaml_load, @@ -708,13 +708,7 @@ def _download_file( else: stat = os.stat(op.realpath(path)) same = [] - # The mtime compared against here is the one we set ourselves - # with os.utime() after the previous download, so this is - # really a filesystem round trip; tolerate the coarsest - # granularity filesystems are known to store mtimes with - # instead of assuming the value round-trips exactly. See - # https://github.com/dandi/dandi-cli/issues/1907 - if is_same_time(stat.st_mtime, mtime, tolerance=MTIME_TOLERANCE): + if is_same_mtime(stat.st_mtime_ns, mtime): same.append("mtime") if size is not None and stat.st_size == size: same.append("size") @@ -724,18 +718,16 @@ def _download_file( # TODO: add recording and handling of .nwb object_id yield _skip_file("same time and size", size=size) return - # Both timestamps are reported in UTC, which is what - # is_same_time() normalizes to before comparing them lgr.debug( "%r - same attributes: %s. Redownloading. " "local mtime: %s, record mtime: %s, delta: %f s, " - "tolerance: %g s, local size: %s, record size: %s", + "granularities: %s ns, local size: %s, record size: %s", str(path), same, ensure_datetime(stat.st_mtime, tz=timezone.utc), ensure_datetime(mtime, tz=timezone.utc), abs(stat.st_mtime - mtime.timestamp()), - MTIME_TOLERANCE, + MTIME_GRANULARITIES_NS, stat.st_size, size, ) diff --git a/dandi/tests/test_download.py b/dandi/tests/test_download.py index 3485d8031..4245afc34 100644 --- a/dandi/tests/test_download.py +++ b/dandi/tests/test_download.py @@ -31,7 +31,12 @@ from .fixtures import SampleDandiset, SampleDandisetFactory from .skip import mark from .test_helpers import TWO_ARRAY_ZARR_LAYOUT, assert_dirtrees_eq, zarr_format_of -from ..consts import DRAFT, MTIME_TOLERANCE, SyncMode, dandiset_metadata_file +from ..consts import ( + DRAFT, + MTIME_GRANULARITIES_NS, + SyncMode, + dandiset_metadata_file, +) from ..dandiarchive import DandisetURL from ..download import ( DownloadDirectory, @@ -194,14 +199,16 @@ def quantizing_utime( path: Any, times: Any = None, *, ns: Any = None, **kwargs: Any ) -> None: # `os.utime()` accepts the times either as seconds (`times`) or as - # nanoseconds (`ns`, used by e.g. `shutil.copystat()`), never both, so - # quantize whichever was given and forward it the same way + # nanoseconds (`ns`, used by e.g. `shutil.copystat()`), never both. + # Quantize in integer nanoseconds -- as a filesystem does, and so that + # granularities not exactly representable as a float (10 ms) truncate + # cleanly -- then forward whichever form was given. if granularity: + g_ns = round(granularity * 10**9) if times is not None: - times = tuple(t // granularity * granularity for t in times) - if ns is not None: - ns_granularity = int(granularity * 1_000_000_000) - ns = tuple(n // ns_granularity * ns_granularity for n in ns) + ns, times = tuple(round(t * 10**9) for t in times), None + assert ns is not None + ns = tuple(n // g_ns * g_ns for n in ns) if ns is not None: real_utime(path, ns=ns, **kwargs) else: @@ -223,7 +230,7 @@ def set_granularity(value: float) -> None: @pytest.mark.ai_generated -@pytest.mark.parametrize("granularity", [0.0, 1.0, 2.0]) +@pytest.mark.parametrize("granularity", [0.0, 0.01, 1.0, 2.0]) def test_download_file_refresh_coarse_mtime_fs( tmp_path: Path, coarse_mtime_fs: Callable[[float], None], granularity: float ) -> None: @@ -310,13 +317,58 @@ def downloader(start_at: int = 0) -> Iterator[bytes]: r.getMessage() for r in caplog.records if "Redownloading" in r.getMessage() ] assert "same attributes: ['size']" in msg - assert f"tolerance: {MTIME_TOLERANCE:g} s" in msg + assert f"granularities: {MTIME_GRANULARITIES_NS} ns" in msg # The record's mtime is reported in full, so that someone reading the log # can see what it was compared against. The local one is not asserted on: # it is whatever the filesystem stored, which is the point of the test. assert f"record mtime: {COARSE_MTIME_RECORD}" in msg +#: Same size as `COARSE_MTIME_CONTENT`, different bytes -- an asset replaced +#: in place, which only the mtime can distinguish from the original +COARSE_MTIME_CONTENT_2 = b"This is other text\n" + + +@pytest.mark.ai_generated +def test_download_file_refresh_detects_subsecond_change(tmp_path: Path) -> None: + """A sub-second change on a filesystem that stores mtimes exactly must + still be redownloaded. + + The counterpart to `test_download_file_refresh_coarse_mtime_fs`: tolerating + the coarsest known granularity unconditionally would skip this, since the + size is unchanged and the mtime moved well under two seconds. Here the + filesystem stored the previous mtime at full precision, so the discrepancy + is not something its quantization could have produced. + """ + assert len(COARSE_MTIME_CONTENT_2) == len(COARSE_MTIME_CONTENT) + path = tmp_path / "file.txt" + + def download_it(content: bytes, mtime: datetime) -> list[dict]: + def downloader(start_at: int = 0) -> Iterator[bytes]: + yield content[start_at:] + + return list( + _download_file( + downloader, + path, + tmp_path, + Lock(), + size=len(content), + mtime=mtime, + existing=DownloadExisting.REFRESH, + ) + ) + + download_it(COARSE_MTIME_CONTENT, COARSE_MTIME_RECORD) + assert path.read_bytes() == COARSE_MTIME_CONTENT + # The asset was replaced 0.4 s later with different bytes of the same size + statuses = download_it( + COARSE_MTIME_CONTENT_2, COARSE_MTIME_RECORD + timedelta(seconds=0.4) + ) + assert {"status": "downloading"} in statuses + assert path.read_bytes() == COARSE_MTIME_CONTENT_2 + + def test_download_newest_version(text_dandiset: SampleDandiset, tmp_path: Path) -> None: dandiset = text_dandiset.dandiset dandiset_id = text_dandiset.dandiset_id diff --git a/dandi/tests/test_utils.py b/dandi/tests/test_utils.py index 3a15043c5..b8d788060 100644 --- a/dandi/tests/test_utils.py +++ b/dandi/tests/test_utils.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import Iterable +import datetime import inspect import logging import os.path as op @@ -28,6 +29,7 @@ get_module_version, get_utcnow_datetime, is_page2_url, + is_same_mtime, is_same_time, is_url, on_windows, @@ -158,6 +160,57 @@ def test_time_samples(t: str) -> None: ) # exactly the same +#: An mtime with a non-zero sub-second component, i.e. one that does not +#: survive a round trip through a filesystem storing whole seconds only +MTIME_RECORD = datetime.datetime( + 2026, 8, 22, 15, 21, 21, 651000, tzinfo=datetime.timezone.utc +) + + +def _ns(t: datetime.datetime) -> int: + return int(t.timestamp() * 10**9) + + +def _truncated(t: datetime.datetime, granularity_ns: int) -> int: + """`t` as a filesystem with the given mtime granularity would store it""" + return _ns(t) // granularity_ns * granularity_ns + + +@pytest.mark.ai_generated +@pytest.mark.parametrize( + "local_ns,record,same", + [ + # An unchanged file, however the filesystem stored the mtime we set: + # exactly, or truncated to exFAT's 10 ms, a whole second, or FAT's 2 s + (_ns(MTIME_RECORD), MTIME_RECORD, True), + (_truncated(MTIME_RECORD, 10**7), MTIME_RECORD, True), + (_truncated(MTIME_RECORD, 10**9), MTIME_RECORD, True), + (_truncated(MTIME_RECORD, 2 * 10**9), MTIME_RECORD, True), + # A changed file, against a stored value too precise to explain the gap + (_ns(MTIME_RECORD), MTIME_RECORD + datetime.timedelta(seconds=0.4), False), + (_ns(MTIME_RECORD), MTIME_RECORD + datetime.timedelta(seconds=1.9), False), + ( + _truncated(MTIME_RECORD, 10**7), + MTIME_RECORD + datetime.timedelta(seconds=0.4), + False, + ), + # A gap wider than any known granularity, whatever the stored value + ( + _truncated(MTIME_RECORD, 2 * 10**9), + MTIME_RECORD + datetime.timedelta(seconds=1.5), + False, + ), + ( + _ns(MTIME_RECORD - datetime.timedelta(hours=1)), + MTIME_RECORD, + False, + ), + ], +) +def test_is_same_mtime(local_ns: int, record: datetime.datetime, same: bool) -> None: + assert is_same_mtime(local_ns, record) is same + + def test_flatten() -> None: assert inspect.isgenerator(flatten([1])) # flattened is just a list() around flatten diff --git a/dandi/utils.py b/dandi/utils.py index cd6ea7afd..82791cc1d 100644 --- a/dandi/utils.py +++ b/dandi/utils.py @@ -35,7 +35,13 @@ from yarl import URL from . import __version__, get_logger -from .consts import DandiInstance, known_instances, known_instances_rev +from .consts import ( + MTIME_GRANULARITIES_NS, + MTIME_ROUNDTRIP_SLACK_NS, + DandiInstance, + known_instances, + known_instances_rev, +) from .exceptions import BadCliVersionError, CliVersionTooOldError AnyPath = Union[str, Path] @@ -157,6 +163,51 @@ def is_same_time( ) +_EPOCH = datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc) + + +def _datetime_to_ns(dt: datetime.datetime) -> int: + """Epoch nanoseconds for `dt`, without a float round trip""" + delta = dt - _EPOCH + return (delta.days * 86400 + delta.seconds) * 10**9 + delta.microseconds * 1000 + + +def is_same_mtime(local_ns: int, record: datetime.datetime) -> bool: + """Is `local_ns` the mtime `record`, as a filesystem could have stored it? + + For an mtime we set ourselves with `os.utime()` and are now reading back, + where the only discrepancy an otherwise-unchanged file can show is the + filesystem's own quantization of the value. Where `is_same_time()` + tolerates a fixed window -- which on a filesystem that stores the value + exactly would mask a real change -- this requires the discrepancy to be one + the *observed* value can account for: `local_ns` must be a multiple of some + granularity in `MTIME_GRANULARITIES_NS` that is itself wider than the gap. + A filesystem truncating to whole seconds cannot have produced a stored + value of ``...21.651``, so against such a value even a millisecond of drift + means the file really did change; a stored ``...20.000`` is consistent with + truncation, and a gap of up to two seconds says nothing either way. + + Parameters + ---------- + local_ns: int + The mtime as read back from the filesystem, in epoch nanoseconds, i.e. + `os.stat()`'s `st_mtime_ns`. Nanoseconds rather than `st_mtime` so that + the multiple-of test is exact. + record: datetime.datetime + The mtime we asked for. + + See https://github.com/dandi/dandi-cli/issues/1907 + """ + delta = abs(_datetime_to_ns(record) - local_ns) + if delta <= MTIME_ROUNDTRIP_SLACK_NS: + return True + # TODO: this accounts for quantization only. A destination that also + # *shifts* mtimes -- notably FAT, which stores local time, so every mtime + # moves by an hour across a DST transition -- will still compare unequal + # once after such a shift. Niche enough to leave for now. + return any(g > delta and local_ns % g == 0 for g in MTIME_GRANULARITIES_NS) + + def ensure_strtime( t: str | int | float | datetime.datetime, isoformat: bool = True ) -> str: From 696987ac649d02dc17351412be83f7cdf26745f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 13:43:18 +0000 Subject: [PATCH 2/3] Apply the same mtime reasoning to dandiset.yaml _populate_dandiset_yaml() compared the local dandiset.yaml's mtime against the record with a bare `>=`. That mtime is another one we set ourselves with os.utime() twelve lines below, so a destination that quantizes mtimes reads it back below the value written and the comparison takes it for an older file. The symptom differs from the asset path and is milder: the content-equality check above short-circuits whenever the metadata matches, so there is no re-transfer churn. What is lost is the guard the `>=` exists for -- a locally-edited dandiset.yaml whose mtime lands inside the quantization window looks stale and gets overwritten by ds.update_metadata(). Note this is not a straight substitution of is_same_mtime() for the operator. The comparison is one-sided on purpose: a genuinely newer local copy is current too, and an equality test alone would call it "not the record" and clobber exactly the edits the check protects. So keep the `>=` arm and add is_same_mtime() as the second, for the value we wrote ourselves. The test pins all three branches, and fails against either mistake: the bare `>=` on every coarse granularity, and an is_same_mtime()-only check on all four, including the exact-filesystem case. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01AZfG9qzAUVagkBoBxmbfCD --- dandi/download.py | 16 +++++++++++++++- dandi/tests/test_download.py | 33 ++++++++++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/dandi/download.py b/dandi/download.py index dc8eeb350..3ec19e080 100644 --- a/dandi/download.py +++ b/dandi/download.py @@ -564,6 +564,20 @@ def _skip_file(msg: Any, **kwargs: Any) -> dict: return {"status": "skipped", "message": str(msg), **kwargs} +def _local_yaml_is_current(path: str, record: datetime) -> bool: + """Is the local dandiset.yaml at least as new as the record? + + Its mtime is another one we set ourselves with `os.utime()`, so a + destination that quantizes mtimes reads it back below the value written. + A bare ``>=`` takes that for an older file and overwrites a copy the user + may have edited; `is_same_mtime()` recognizes it as the value we wrote. + Kept one-sided: a genuinely newer local copy is current too, which an + equality test alone would reject. + """ + st = os.lstat(path) + return st.st_mtime >= record.timestamp() or is_same_mtime(st.st_mtime_ns, record) + + def _populate_dandiset_yaml( dandiset_path: str | Path, dandiset: RemoteDandiset, existing: DownloadExisting ) -> Iterator[dict]: @@ -591,7 +605,7 @@ def _populate_dandiset_yaml( raise RuntimeError("Not refreshing path in git annex repository") elif existing is DownloadExisting.SKIP or ( existing is DownloadExisting.REFRESH - and os.lstat(dandiset_yaml).st_mtime >= mtime.timestamp() + and _local_yaml_is_current(dandiset_yaml, mtime) ): yield _skip_file("already exists") return diff --git a/dandi/tests/test_download.py b/dandi/tests/test_download.py index 4245afc34..2a8d22161 100644 --- a/dandi/tests/test_download.py +++ b/dandi/tests/test_download.py @@ -48,11 +48,12 @@ PYOUTHelper, _check_attempts_and_sleep, _download_file, + _local_yaml_is_current, download, ) from ..exceptions import NotFoundError from ..support.digests import Digester -from ..utils import list_paths, yaml_load +from ..utils import is_same_mtime, list_paths, yaml_load # both urls point to 000027 (lean test dataset), and both draft and "released" @@ -369,6 +370,36 @@ def downloader(start_at: int = 0) -> Iterator[bytes]: assert path.read_bytes() == COARSE_MTIME_CONTENT_2 +@pytest.mark.ai_generated +@pytest.mark.parametrize("granularity", [0.0, 0.01, 1.0, 2.0]) +def test_local_yaml_is_current_coarse_mtime_fs( + tmp_path: Path, coarse_mtime_fs: Callable[[float], None], granularity: float +) -> None: + """`dandiset.yaml` we just wrote counts as current however coarsely the + filesystem stored the mtime, while genuinely older/newer copies still + compare as themselves. + """ + coarse_mtime_fs(granularity) + path = tmp_path / dandiset_metadata_file + path.write_text("id: DANDI:000000\n") + os.utime(path, (time.time(), COARSE_MTIME_RECORD.timestamp())) + + # The mtime we just set, read back through the filesystem + assert _local_yaml_is_current(str(path), COARSE_MTIME_RECORD) + # The record moved on by more than any granularity -> stale, rewrite it + assert not _local_yaml_is_current( + str(path), COARSE_MTIME_RECORD + timedelta(hours=1) + ) + # A locally edited copy is newer than the record and must be left alone. + # This is why the check stays one-sided: `is_same_mtime()` on its own would + # call an hour-newer file "not the record" and clobber the user's edit. + os.utime( + path, (time.time(), (COARSE_MTIME_RECORD + timedelta(hours=1)).timestamp()) + ) + assert _local_yaml_is_current(str(path), COARSE_MTIME_RECORD) + assert not is_same_mtime(path.stat().st_mtime_ns, COARSE_MTIME_RECORD) + + def test_download_newest_version(text_dandiset: SampleDandiset, tmp_path: Path) -> None: dandiset = text_dandiset.dandiset dandiset_id = text_dandiset.dandiset_id From 1a29756d5012e42c8532e4af84f88669ae33041d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 13:52:09 +0000 Subject: [PATCH 3/3] Rename _local_yaml_is_current to _is_local_file_current Nothing in it is specific to dandiset.yaml -- it asks whether a local file whose mtime we set ourselves is at least as new as the record, which is true of any such file. Generalize the docstring to match and rename the test with it. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01AZfG9qzAUVagkBoBxmbfCD --- dandi/download.py | 14 +++++++------- dandi/tests/test_download.py | 19 +++++++++---------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/dandi/download.py b/dandi/download.py index 3ec19e080..841743cfa 100644 --- a/dandi/download.py +++ b/dandi/download.py @@ -564,13 +564,13 @@ def _skip_file(msg: Any, **kwargs: Any) -> dict: return {"status": "skipped", "message": str(msg), **kwargs} -def _local_yaml_is_current(path: str, record: datetime) -> bool: - """Is the local dandiset.yaml at least as new as the record? +def _is_local_file_current(path: str, record: datetime) -> bool: + """Is the local file at least as new as the record? - Its mtime is another one we set ourselves with `os.utime()`, so a - destination that quantizes mtimes reads it back below the value written. - A bare ``>=`` takes that for an older file and overwrites a copy the user - may have edited; `is_same_mtime()` recognizes it as the value we wrote. + For a file whose mtime we set ourselves with `os.utime()`: a destination + that quantizes mtimes reads it back below the value written, and a bare + ``>=`` takes that for an older file and overwrites a copy the user may + have edited; `is_same_mtime()` recognizes it as the value we wrote. Kept one-sided: a genuinely newer local copy is current too, which an equality test alone would reject. """ @@ -605,7 +605,7 @@ def _populate_dandiset_yaml( raise RuntimeError("Not refreshing path in git annex repository") elif existing is DownloadExisting.SKIP or ( existing is DownloadExisting.REFRESH - and _local_yaml_is_current(dandiset_yaml, mtime) + and _is_local_file_current(dandiset_yaml, mtime) ): yield _skip_file("already exists") return diff --git a/dandi/tests/test_download.py b/dandi/tests/test_download.py index 2a8d22161..92949b42b 100644 --- a/dandi/tests/test_download.py +++ b/dandi/tests/test_download.py @@ -48,7 +48,7 @@ PYOUTHelper, _check_attempts_and_sleep, _download_file, - _local_yaml_is_current, + _is_local_file_current, download, ) from ..exceptions import NotFoundError @@ -372,22 +372,21 @@ def downloader(start_at: int = 0) -> Iterator[bytes]: @pytest.mark.ai_generated @pytest.mark.parametrize("granularity", [0.0, 0.01, 1.0, 2.0]) -def test_local_yaml_is_current_coarse_mtime_fs( +def test_is_local_file_current_coarse_mtime_fs( tmp_path: Path, coarse_mtime_fs: Callable[[float], None], granularity: float ) -> None: - """`dandiset.yaml` we just wrote counts as current however coarsely the - filesystem stored the mtime, while genuinely older/newer copies still - compare as themselves. + """A file we just wrote counts as current however coarsely the filesystem + stored the mtime, while genuinely older/newer copies compare as themselves. """ coarse_mtime_fs(granularity) - path = tmp_path / dandiset_metadata_file - path.write_text("id: DANDI:000000\n") + path = tmp_path / "file.txt" + path.write_text("some content\n") os.utime(path, (time.time(), COARSE_MTIME_RECORD.timestamp())) # The mtime we just set, read back through the filesystem - assert _local_yaml_is_current(str(path), COARSE_MTIME_RECORD) + assert _is_local_file_current(str(path), COARSE_MTIME_RECORD) # The record moved on by more than any granularity -> stale, rewrite it - assert not _local_yaml_is_current( + assert not _is_local_file_current( str(path), COARSE_MTIME_RECORD + timedelta(hours=1) ) # A locally edited copy is newer than the record and must be left alone. @@ -396,7 +395,7 @@ def test_local_yaml_is_current_coarse_mtime_fs( os.utime( path, (time.time(), (COARSE_MTIME_RECORD + timedelta(hours=1)).timestamp()) ) - assert _local_yaml_is_current(str(path), COARSE_MTIME_RECORD) + assert _is_local_file_current(str(path), COARSE_MTIME_RECORD) assert not is_same_mtime(path.stat().st_mtime_ns, COARSE_MTIME_RECORD)