diff --git a/src/borg/archive.py b/src/borg/archive.py index 020ba5ac9..bed8b7012 100644 --- a/src/borg/archive.py +++ b/src/borg/archive.py @@ -644,8 +644,8 @@ Duration: {0.duration} "cmdline": sys.argv, "hostname": hostname, "username": getuser(), - "time": start.strftime(ISO_FORMAT), - "time_end": end.strftime(ISO_FORMAT), + "time": start.isoformat(timespec="microseconds"), + "time_end": end.isoformat(timespec="microseconds"), "chunker_params": self.chunker_params, } if stats is not None: diff --git a/src/borg/constants.py b/src/borg/constants.py index c8643aa78..4d487c01d 100644 --- a/src/borg/constants.py +++ b/src/borg/constants.py @@ -102,9 +102,6 @@ EXIT_WARNING = 1 # reached normal end of operation, but there were issues EXIT_ERROR = 2 # terminated abruptly, did not reach end of operation EXIT_SIGNAL_BASE = 128 # terminated due to signal, rc = 128 + sig_no -# never use datetime.isoformat(), it is evil. always use one of these: -# datetime.strftime(ISO_FORMAT) # output always includes .microseconds -# datetime.strftime(ISO_FORMAT_NO_USECS) # output never includes microseconds ISO_FORMAT_NO_USECS = "%Y-%m-%dT%H:%M:%S" ISO_FORMAT = ISO_FORMAT_NO_USECS + ".%f" diff --git a/src/borg/helpers/manifest.py b/src/borg/helpers/manifest.py index d81b0a738..c28c1ee73 100644 --- a/src/borg/helpers/manifest.py +++ b/src/borg/helpers/manifest.py @@ -65,7 +65,7 @@ class Archives(abc.MutableMapping): id, ts = info assert isinstance(id, bytes) if isinstance(ts, datetime): - ts = ts.replace(tzinfo=None).strftime(ISO_FORMAT) + ts = ts.replace(tzinfo=None).isoformat(timespec="microseconds") assert isinstance(ts, str) self._archives[name] = {"id": id, "time": ts} @@ -254,11 +254,11 @@ class Manifest: self.config["tam_required"] = True # self.timestamp needs to be strictly monotonically increasing. Clocks often are not set correctly if self.timestamp is None: - self.timestamp = datetime.utcnow().strftime(ISO_FORMAT) + self.timestamp = datetime.utcnow().isoformat(timespec="microseconds") else: prev_ts = self.last_timestamp - incremented = (prev_ts + timedelta(microseconds=1)).strftime(ISO_FORMAT) - self.timestamp = max(incremented, datetime.utcnow().strftime(ISO_FORMAT)) + incremented = (prev_ts + timedelta(microseconds=1)).isoformat(timespec="microseconds") + self.timestamp = max(incremented, datetime.utcnow().isoformat(timespec="microseconds")) # include checks for limits as enforced by limited unpacker (used by load()) assert len(self.archives) <= MAX_ARCHIVES assert all(len(name) <= 255 for name in self.archives) diff --git a/src/borg/helpers/time.py b/src/borg/helpers/time.py index a7170764d..0b18de14a 100644 --- a/src/borg/helpers/time.py +++ b/src/borg/helpers/time.py @@ -2,8 +2,6 @@ import os import time from datetime import datetime, timezone -from ..constants import ISO_FORMAT, ISO_FORMAT_NO_USECS - def to_localtime(ts): """Convert datetime object from UTC to local time zone""" @@ -12,8 +10,7 @@ def to_localtime(ts): def parse_timestamp(timestamp, tzinfo=timezone.utc): """Parse a ISO 8601 timestamp string""" - fmt = ISO_FORMAT if "." in timestamp else ISO_FORMAT_NO_USECS - dt = datetime.strptime(timestamp, fmt) + dt = datetime.fromisoformat(timestamp) if tzinfo is not None: dt = dt.replace(tzinfo=tzinfo) return dt @@ -26,22 +23,11 @@ def timestamp(s): ts = safe_s(os.stat(s).st_mtime) return datetime.fromtimestamp(ts, tz=timezone.utc) except OSError: - # didn't work, try parsing as timestamp. UTC, no TZ, no microsecs support. - for format in ( - "%Y-%m-%dT%H:%M:%SZ", - "%Y-%m-%dT%H:%M:%S+00:00", - "%Y-%m-%dT%H:%M:%S", - "%Y-%m-%d %H:%M:%S", - "%Y-%m-%dT%H:%M", - "%Y-%m-%d %H:%M", - "%Y-%m-%d", - "%Y-%j", - ): - try: - return datetime.strptime(s, format).replace(tzinfo=timezone.utc) - except ValueError: - continue - raise ValueError + # didn't work, try parsing as a ISO timestamp. if no TZ is given, we assume UTC. + dt = datetime.fromisoformat(s) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt # Not too rarely, we get crappy timestamps from the fs, that overflow some computations. @@ -106,7 +92,7 @@ def isoformat_time(ts: datetime): Format *ts* according to ISO 8601. """ # note: first make all datetime objects tz aware before adding %z here. - return ts.strftime(ISO_FORMAT) + return ts.isoformat(timespec="microseconds") def format_timedelta(td): diff --git a/src/borg/repository.py b/src/borg/repository.py index 93eedb3ea..d55e2d9e6 100644 --- a/src/borg/repository.py +++ b/src/borg/repository.py @@ -656,7 +656,9 @@ class Repository: if self.append_only: with open(os.path.join(self.path, "transactions"), "a") as log: print( - "transaction %d, UTC time %s" % (transaction_id, datetime.utcnow().strftime(ISO_FORMAT)), file=log + "transaction %d, UTC time %s" + % (transaction_id, datetime.utcnow().isoformat(timespec="microseconds")), + file=log, ) # Write hints file diff --git a/src/borg/testsuite/archiver.py b/src/borg/testsuite/archiver.py index 846c9ef8a..33eb48122 100644 --- a/src/borg/testsuite/archiver.py +++ b/src/borg/testsuite/archiver.py @@ -249,6 +249,11 @@ def test_disk_full(cmd): assert rc == EXIT_SUCCESS +def checkts(ts): + # check if the timestamp is in the expected format + assert datetime.strptime(ts, ISO_FORMAT) # must not raise + + class ArchiverTestCaseBase(BaseTestCase): EXE: str = None # python source based FORK_DEFAULT = False @@ -1682,7 +1687,7 @@ class ArchiverTestCase(ArchiverTestCaseBase): repository = info_repo["repository"] assert len(repository["id"]) == 64 assert "last_modified" in repository - assert datetime.strptime(repository["last_modified"], ISO_FORMAT) # must not raise + checkts(repository["last_modified"]) assert info_repo["encryption"]["mode"] == RK_ENCRYPTION[13:] assert "keyfile" not in info_repo["encryption"] cache = info_repo["cache"] @@ -1701,8 +1706,8 @@ class ArchiverTestCase(ArchiverTestCaseBase): assert isinstance(archive["duration"], float) assert len(archive["id"]) == 64 assert "stats" in archive - assert datetime.strptime(archive["start"], ISO_FORMAT) - assert datetime.strptime(archive["end"], ISO_FORMAT) + checkts(archive["start"]) + checkts(archive["end"]) def test_info_json_of_empty_archive(self): """See https://github.com/borgbackup/borg/issues/6120""" @@ -2579,11 +2584,11 @@ class ArchiverTestCase(ArchiverTestCaseBase): list_repo = json.loads(self.cmd(f"--repo={self.repository_location}", "rlist", "--json")) repository = list_repo["repository"] assert len(repository["id"]) == 64 - assert datetime.strptime(repository["last_modified"], ISO_FORMAT) # must not raise + checkts(repository["last_modified"]) assert list_repo["encryption"]["mode"] == RK_ENCRYPTION[13:] assert "keyfile" not in list_repo["encryption"] archive0 = list_repo["archives"][0] - assert datetime.strptime(archive0["time"], ISO_FORMAT) # must not raise + checkts(archive0["time"]) list_archive = self.cmd(f"--repo={self.repository_location}", "list", "test", "--json-lines") items = [json.loads(s) for s in list_archive.splitlines()] @@ -2591,7 +2596,7 @@ class ArchiverTestCase(ArchiverTestCaseBase): file1 = items[1] assert file1["path"] == "input/file1" assert file1["size"] == 81920 - assert datetime.strptime(file1["mtime"], ISO_FORMAT) # must not raise + checkts(file1["mtime"]) list_archive = self.cmd( f"--repo={self.repository_location}", "list", "test", "--json-lines", "--format={sha256}" @@ -4058,7 +4063,7 @@ class ManifestAuthenticationTest(ArchiverTestCaseBase): "version": 1, "archives": {}, "config": {}, - "timestamp": (datetime.utcnow() + timedelta(days=1)).strftime(ISO_FORMAT), + "timestamp": (datetime.utcnow() + timedelta(days=1)).isoformat(timespec="microseconds"), } ), ), @@ -4078,7 +4083,7 @@ class ManifestAuthenticationTest(ArchiverTestCaseBase): { "version": 1, "archives": {}, - "timestamp": (datetime.utcnow() + timedelta(days=1)).strftime(ISO_FORMAT), + "timestamp": (datetime.utcnow() + timedelta(days=1)).isoformat(timespec="microseconds"), } ), ),