1 | """ |
---|
2 | Time formatting utilities. |
---|
3 | |
---|
4 | ISO-8601: |
---|
5 | http://www.cl.cam.ac.uk/~mgk25/iso-time.html |
---|
6 | """ |
---|
7 | |
---|
8 | import calendar, datetime, re, time |
---|
9 | from typing import Optional |
---|
10 | from enum import Enum |
---|
11 | |
---|
12 | |
---|
13 | class ParseDurationUnitFormat(str, Enum): |
---|
14 | SECONDS0 = "s" |
---|
15 | SECONDS1 = "second" |
---|
16 | SECONDS2 = "seconds" |
---|
17 | DAYS0 = "day" |
---|
18 | DAYS1 = "days" |
---|
19 | MONTHS0 = "mo" |
---|
20 | MONTHS1 = "month" |
---|
21 | MONTHS2 = "months" |
---|
22 | YEARS0 = "year" |
---|
23 | YEARS1 = "years" |
---|
24 | |
---|
25 | @classmethod |
---|
26 | def list_values(cls): |
---|
27 | return list(map(lambda c: c.value, cls)) |
---|
28 | |
---|
29 | |
---|
30 | def format_time(t): |
---|
31 | return time.strftime("%Y-%m-%d %H:%M:%S", t) |
---|
32 | |
---|
33 | def iso_utc_date( |
---|
34 | now: Optional[float] = None, |
---|
35 | t=time.time |
---|
36 | ) -> str: |
---|
37 | if now is None: |
---|
38 | now = t() |
---|
39 | return datetime.datetime.utcfromtimestamp(now).isoformat()[:10] |
---|
40 | |
---|
41 | def iso_utc( |
---|
42 | now: Optional[float] = None, |
---|
43 | sep: str = '_', |
---|
44 | t=time.time |
---|
45 | ) -> str: |
---|
46 | if now is None: |
---|
47 | now = t() |
---|
48 | sep = str(sep) # should already be a str |
---|
49 | return datetime.datetime.utcfromtimestamp(now).isoformat(sep) |
---|
50 | |
---|
51 | def iso_utc_time_to_seconds(isotime, _conversion_re=re.compile(r"(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})[T_ ](?P<hour>\d{2}):(?P<minute>\d{2}):(?P<second>\d{2})(?P<subsecond>\.\d+)?")): |
---|
52 | """ |
---|
53 | The inverse of iso_utc(). |
---|
54 | |
---|
55 | Real ISO-8601 is "2003-01-08T06:30:59". We also accept the widely |
---|
56 | used variants "2003-01-08_06:30:59" and "2003-01-08 06:30:59". |
---|
57 | """ |
---|
58 | m = _conversion_re.match(isotime) |
---|
59 | if not m: |
---|
60 | raise ValueError(isotime, "not a complete ISO8601 timestamp") |
---|
61 | year, month, day = int(m.group('year')), int(m.group('month')), int(m.group('day')) |
---|
62 | hour, minute, second = int(m.group('hour')), int(m.group('minute')), int(m.group('second')) |
---|
63 | subsecstr = m.group('subsecond') |
---|
64 | if subsecstr: |
---|
65 | subsecfloat = float(subsecstr) |
---|
66 | else: |
---|
67 | subsecfloat = 0 |
---|
68 | |
---|
69 | return calendar.timegm( (year, month, day, hour, minute, second, 0, 1, 0) ) + subsecfloat |
---|
70 | |
---|
71 | |
---|
72 | def parse_duration(s): |
---|
73 | """ |
---|
74 | Parses a duration string and converts it to seconds. The unit format is case insensitive |
---|
75 | |
---|
76 | Args: |
---|
77 | s (str): The duration string to parse. Expected format: `<number><unit>` |
---|
78 | where `unit` can be one of the values defined in `ParseDurationUnitFormat`. |
---|
79 | |
---|
80 | Returns: |
---|
81 | int: The duration in seconds. |
---|
82 | |
---|
83 | Raises: |
---|
84 | ValueError: If the input string does not match the expected format or contains invalid units. |
---|
85 | """ |
---|
86 | SECOND = 1 |
---|
87 | DAY = 24*60*60 |
---|
88 | MONTH = 31*DAY |
---|
89 | YEAR = 365*DAY |
---|
90 | time_map = { |
---|
91 | ParseDurationUnitFormat.SECONDS0: SECOND, |
---|
92 | ParseDurationUnitFormat.SECONDS1: SECOND, |
---|
93 | ParseDurationUnitFormat.SECONDS2: SECOND, |
---|
94 | ParseDurationUnitFormat.DAYS0: DAY, |
---|
95 | ParseDurationUnitFormat.DAYS1: DAY, |
---|
96 | ParseDurationUnitFormat.MONTHS0: MONTH, |
---|
97 | ParseDurationUnitFormat.MONTHS1: MONTH, |
---|
98 | ParseDurationUnitFormat.MONTHS2: MONTH, |
---|
99 | ParseDurationUnitFormat.YEARS0: YEAR, |
---|
100 | ParseDurationUnitFormat.YEARS1: YEAR, |
---|
101 | } |
---|
102 | |
---|
103 | # Build a regex pattern dynamically from the list of valid values |
---|
104 | unit_pattern = "|".join(re.escape(unit) for unit in ParseDurationUnitFormat.list_values()) |
---|
105 | pattern = rf"^\s*(\d+)\s*({unit_pattern})\s*$" |
---|
106 | |
---|
107 | # case-insensitive regex matching |
---|
108 | match = re.match(pattern, s, re.IGNORECASE) |
---|
109 | if not match: |
---|
110 | # Generate dynamic error message |
---|
111 | valid_units = ", ".join(f"'{value}'" for value in ParseDurationUnitFormat.list_values()) |
---|
112 | raise ValueError(f"No valid unit in '{s}'. Expected one of: ({valid_units})") |
---|
113 | |
---|
114 | number = int(match.group(1)) # Extract the numeric value |
---|
115 | unit = match.group(2).lower() # Extract the unit & normalize the unit to lowercase |
---|
116 | |
---|
117 | return number * time_map[unit] |
---|
118 | |
---|
119 | def parse_date(s): |
---|
120 | # return seconds-since-epoch for the UTC midnight that starts the given |
---|
121 | # day |
---|
122 | return int(iso_utc_time_to_seconds(s + "T00:00:00")) |
---|
123 | |
---|
124 | def format_delta(time_1, time_2): |
---|
125 | if time_1 is None: |
---|
126 | return "N/A" |
---|
127 | if time_1 > time_2: |
---|
128 | return '-' |
---|
129 | delta = int(time_2 - time_1) |
---|
130 | seconds = delta % 60 |
---|
131 | delta -= seconds |
---|
132 | minutes = (delta // 60) % 60 |
---|
133 | delta -= minutes * 60 |
---|
134 | hours = delta // (60*60) % 24 |
---|
135 | delta -= hours * 24 |
---|
136 | days = delta // (24*60*60) |
---|
137 | if not days: |
---|
138 | if not hours: |
---|
139 | if not minutes: |
---|
140 | return "%ss" % (seconds) |
---|
141 | else: |
---|
142 | return "%sm %ss" % (minutes, seconds) |
---|
143 | else: |
---|
144 | return "%sh %sm %ss" % (hours, minutes, seconds) |
---|
145 | else: |
---|
146 | return "%sd %sh %sm %ss" % (days, hours, minutes, seconds) |
---|
147 | |
---|