mirror of https://github.com/borgbackup/borg.git
178 lines
6.0 KiB
Python
178 lines
6.0 KiB
Python
import os
|
|
import re
|
|
from datetime import datetime, timezone, timedelta
|
|
|
|
|
|
def parse_timestamp(timestamp, tzinfo=timezone.utc):
|
|
"""Parse a ISO 8601 timestamp string.
|
|
|
|
For naive/unaware dt, assume it is in tzinfo timezone (default: UTC).
|
|
"""
|
|
dt = datetime.fromisoformat(timestamp)
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=tzinfo)
|
|
return dt
|
|
|
|
|
|
def parse_local_timestamp(timestamp, tzinfo=None):
|
|
"""Parse a ISO 8601 timestamp string.
|
|
|
|
For naive/unaware dt, assume it is in local timezone.
|
|
Convert to tzinfo timezone (the default None means: local timezone).
|
|
"""
|
|
dt = datetime.fromisoformat(timestamp)
|
|
if dt.tzinfo is None:
|
|
dt = dt.astimezone(tz=tzinfo)
|
|
return dt
|
|
|
|
|
|
def timestamp(s):
|
|
"""Convert a --timestamp=s argument to a datetime object"""
|
|
try:
|
|
# is it pointing to a file / directory?
|
|
ts = safe_s(os.stat(s).st_mtime)
|
|
return datetime.fromtimestamp(ts, tz=timezone.utc)
|
|
except OSError:
|
|
# didn't work, try parsing as a ISO timestamp. if no TZ is given, we assume local timezone.
|
|
return parse_local_timestamp(s)
|
|
|
|
|
|
# Not too rarely, we get crappy timestamps from the fs, that overflow some computations.
|
|
# As they are crap anyway (valid filesystem timestamps always refer to the past up to
|
|
# the present, but never to the future), nothing is lost if we just clamp them to the
|
|
# maximum value we can support.
|
|
# As long as people are using borg on 32bit platforms to access borg archives, we must
|
|
# keep this value True. But we can expect that we can stop supporting 32bit platforms
|
|
# well before coming close to the year 2038, so this will never be a practical problem.
|
|
SUPPORT_32BIT_PLATFORMS = True # set this to False before y2038.
|
|
|
|
if SUPPORT_32BIT_PLATFORMS:
|
|
# second timestamps will fit into a signed int32 (platform time_t limit).
|
|
# nanosecond timestamps thus will naturally fit into a signed int64.
|
|
# subtract last 48h to avoid any issues that could be caused by tz calculations.
|
|
# this is in the year 2038, so it is also less than y9999 (which is a datetime internal limit).
|
|
# msgpack can pack up to uint64.
|
|
MAX_S = 2**31 - 1 - 48 * 3600
|
|
MAX_NS = MAX_S * 1000000000
|
|
else:
|
|
# nanosecond timestamps will fit into a signed int64.
|
|
# subtract last 48h to avoid any issues that could be caused by tz calculations.
|
|
# this is in the year 2262, so it is also less than y9999 (which is a datetime internal limit).
|
|
# round down to 1e9 multiple, so MAX_NS corresponds precisely to a integer MAX_S.
|
|
# msgpack can pack up to uint64.
|
|
MAX_NS = (2**63 - 1 - 48 * 3600 * 1000000000) // 1000000000 * 1000000000
|
|
MAX_S = MAX_NS // 1000000000
|
|
|
|
|
|
def safe_s(ts):
|
|
if 0 <= ts <= MAX_S:
|
|
return ts
|
|
elif ts < 0:
|
|
return 0
|
|
else:
|
|
return MAX_S
|
|
|
|
|
|
def safe_ns(ts):
|
|
if 0 <= ts <= MAX_NS:
|
|
return ts
|
|
elif ts < 0:
|
|
return 0
|
|
else:
|
|
return MAX_NS
|
|
|
|
|
|
def safe_timestamp(item_timestamp_ns):
|
|
t_ns = safe_ns(item_timestamp_ns)
|
|
return datetime.fromtimestamp(t_ns / 1e9, timezone.utc) # return tz-aware utc datetime obj
|
|
|
|
|
|
def format_time(ts: datetime, format_spec=""):
|
|
"""
|
|
Convert *ts* to a human-friendly format with textual weekday.
|
|
"""
|
|
return ts.strftime("%a, %Y-%m-%d %H:%M:%S %z" if format_spec == "" else format_spec)
|
|
|
|
|
|
def format_timedelta(td):
|
|
"""Format timedelta in a human friendly format"""
|
|
ts = td.total_seconds()
|
|
s = ts % 60
|
|
m = int(ts / 60) % 60
|
|
h = int(ts / 3600) % 24
|
|
txt = "%.3f seconds" % s
|
|
if m:
|
|
txt = "%d minutes %s" % (m, txt)
|
|
if h:
|
|
txt = "%d hours %s" % (h, txt)
|
|
if td.days:
|
|
txt = "%d days %s" % (td.days, txt)
|
|
return txt
|
|
|
|
|
|
def calculate_relative_offset(format_string, from_ts, earlier=False):
|
|
"""
|
|
Calculates offset based on a relative marker. 7d (7 days), 8m (8 months)
|
|
earlier: whether offset should be calculated to an earlier time.
|
|
"""
|
|
if from_ts is None:
|
|
from_ts = archive_ts_now()
|
|
|
|
if format_string is not None:
|
|
offset_regex = re.compile(r"(?P<offset>\d+)(?P<unit>[md])")
|
|
match = offset_regex.search(format_string)
|
|
|
|
if match:
|
|
unit = match.group("unit")
|
|
offset = int(match.group("offset"))
|
|
offset *= -1 if earlier else 1
|
|
|
|
if unit == "d":
|
|
return from_ts + timedelta(days=offset)
|
|
elif unit == "m":
|
|
return offset_n_months(from_ts, offset)
|
|
|
|
raise ValueError(f"Invalid relative ts offset format: {format_string}")
|
|
|
|
|
|
def offset_n_months(from_ts, n_months):
|
|
def get_month_and_year_from_total(total_completed_months):
|
|
month = (total_completed_months % 12) + 1
|
|
year = total_completed_months // 12
|
|
return month, year
|
|
|
|
# Calculate target month and year by getting completed total_months until target_month
|
|
total_months = (from_ts.year * 12) + from_ts.month + n_months - 1
|
|
target_month, target_year = get_month_and_year_from_total(total_months)
|
|
|
|
# calculate the max days of the target month by subtracting a day from the next month
|
|
following_month, year_of_following_month = get_month_and_year_from_total(total_months + 1)
|
|
max_days_in_month = (datetime(year_of_following_month, following_month, 1) - timedelta(1)).day
|
|
|
|
return datetime(day=min(from_ts.day, max_days_in_month), month=target_month, year=target_year).replace(
|
|
tzinfo=from_ts.tzinfo
|
|
)
|
|
|
|
|
|
class OutputTimestamp:
|
|
def __init__(self, ts: datetime):
|
|
self.ts = ts
|
|
|
|
def __format__(self, format_spec):
|
|
# we want to output a timestamp in the user's local timezone
|
|
return format_time(self.ts.astimezone(), format_spec=format_spec)
|
|
|
|
def __str__(self):
|
|
return f"{self}"
|
|
|
|
def isoformat(self):
|
|
# we want to output a timestamp in the user's local timezone
|
|
return self.ts.astimezone().isoformat(timespec="microseconds")
|
|
|
|
to_json = isoformat
|
|
|
|
|
|
def archive_ts_now():
|
|
"""return tz-aware datetime obj for current time for usage as archive timestamp"""
|
|
return datetime.now(timezone.utc) # utc time / utc timezone
|