batutils 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- batutils/__init__.py +5 -0
- batutils/_common/__init__.py +6 -0
- batutils/_common/_decode.py +40 -0
- batutils/dtu/__init__.py +19 -0
- batutils/dtu/casting/__init__.py +5 -0
- batutils/dtu/casting/_any.py +136 -0
- batutils/dtu/casting/_filetime.py +36 -0
- batutils/dtu/casting/_instant.py +31 -0
- batutils/dtu/casting/_string.py +81 -0
- batutils/dtu/casting/_timestamp.py +50 -0
- batutils/dtu/formatting/__init__.py +2 -0
- batutils/dtu/formatting/_delta.py +62 -0
- batutils/dtu/formatting/_serialize.py +26 -0
- batutils/dtu/parsers/__init__.py +2 -0
- batutils/dtu/parsers/_string.py +44 -0
- batutils/dtu/parsers/_timedelta.py +161 -0
- batutils/dtu/shortcuts/__init__.py +3 -0
- batutils/dtu/shortcuts/_aliases.py +29 -0
- batutils/dtu/shortcuts/_checkers.py +21 -0
- batutils/dtu/shortcuts/_finders.py +80 -0
- batutils/dtu/structs/__init__.py +5 -0
- batutils/dtu/structs/_base.py +5 -0
- batutils/dtu/structs/_deltasplit.py +278 -0
- batutils/dtu/structs/_types.py +24 -0
- batutils/dtu/structs/aliases.py +21 -0
- batutils/dtu/structs/consts.py +35 -0
- batutils/structs/__init__.py +7 -0
- batutils/structs/_base.py +21 -0
- batutils/tpu/__init__.py +0 -0
- batutils/tpu/casting/__init__.py +9 -0
- batutils/tpu/casting/_currency.py +52 -0
- batutils/tpu/casting/_evals.py +247 -0
- batutils/tpu/casting/_ip.py +58 -0
- batutils/tpu/casting/_mask.py +34 -0
- batutils/tpu/casting/_params.py +108 -0
- batutils/tpu/casting/_serialize.py +20 -0
- batutils/tpu/comparison/__init__.py +1 -0
- batutils/tpu/comparison/_mixin.py +30 -0
- batutils/tpu/insp/__init__.py +2 -0
- batutils/tpu/insp/_insp_old.py +347 -0
- batutils/tpu/insp/_typecheck.py +89 -0
- batutils/tpu/iters/__init__.py +4 -0
- batutils/tpu/iters/_attrs.py +84 -0
- batutils/tpu/iters/_dicts.py +130 -0
- batutils/tpu/iters/_group.py +50 -0
- batutils/tpu/iters/_lists.py +178 -0
- batutils/tpu/size/__init__.py +1 -0
- batutils/tpu/size/_total.py +52 -0
- batutils/tpu/strimp/__init__.py +1 -0
- batutils/tpu/strimp/_getters.py +285 -0
- batutils/tpu/string/__init__.py +1 -0
- batutils/tpu/string/_string.py +160 -0
- batutils/tpu/structs/__init__.py +1 -0
- batutils/tpu/structs/_base.py +3 -0
- batutils/tpu/structs/empty/__init__.py +1 -0
- batutils/tpu/structs/empty/_empty.py +387 -0
- batutils/tpu/structs/enumex/__init__.py +1 -0
- batutils/tpu/structs/enumex/_enumextra.py +25 -0
- batutils/tpu/structs/fluid/__init__.py +1 -0
- batutils/tpu/structs/fluid/_attrfluid.py +77 -0
- batutils/tpu/structs/sentience/__init__.py +1 -0
- batutils/tpu/structs/sentience/_builder.py +87 -0
- batutils/tpu/structs/serializers/__init__.py +3 -0
- batutils/tpu/structs/serializers/_json.py +12 -0
- batutils/tpu/structs/serializers/_jsonsafe.py +69 -0
- batutils/tpu/structs/serializers/_universal.py +318 -0
- batutils/tpu/structs/singleton/__init__.py +1 -0
- batutils/tpu/structs/singleton/_singleton.py +35 -0
- batutils-2.0.0.dist-info/LICENSE +22 -0
- batutils-2.0.0.dist-info/METADATA +125 -0
- batutils-2.0.0.dist-info/RECORD +73 -0
- batutils-2.0.0.dist-info/WHEEL +5 -0
- batutils-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
__all__ = [
|
|
2
|
+
'str_to_delta',
|
|
3
|
+
]
|
|
4
|
+
from batutils.dtu.structs import *
|
|
5
|
+
|
|
6
|
+
_PERIOD_PART = re.compile(r"([+-]?(?:\d+(?:\.(?:\d+)?)?|\.\d+))\s*([a-z]+)\s*", re.IGNORECASE)
|
|
7
|
+
|
|
8
|
+
_KEYWORDS = {
|
|
9
|
+
'd': 'days',
|
|
10
|
+
'day': 'days',
|
|
11
|
+
'days': 'days',
|
|
12
|
+
|
|
13
|
+
'h': 'hours',
|
|
14
|
+
'hr': 'hours',
|
|
15
|
+
'hrs': 'hours',
|
|
16
|
+
'hour': 'hours',
|
|
17
|
+
'hours': 'hours',
|
|
18
|
+
|
|
19
|
+
'm': 'minutes',
|
|
20
|
+
'i': 'minutes',
|
|
21
|
+
'min': 'minutes',
|
|
22
|
+
'mins': 'minutes',
|
|
23
|
+
'minute': 'minutes',
|
|
24
|
+
'minutes': 'minutes',
|
|
25
|
+
|
|
26
|
+
's': 'seconds',
|
|
27
|
+
'sec': 'seconds',
|
|
28
|
+
'secs': 'seconds',
|
|
29
|
+
'second': 'seconds',
|
|
30
|
+
'seconds': 'seconds',
|
|
31
|
+
|
|
32
|
+
'w': 'weeks',
|
|
33
|
+
'wk': 'weeks',
|
|
34
|
+
'wks': 'weeks',
|
|
35
|
+
'week': 'weeks',
|
|
36
|
+
'weeks': 'weeks',
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _parse_split(string: str) -> Optional[List[Tuple[str, str]]]:
|
|
41
|
+
"""Splits a string presumed to containing a written out time period into a
|
|
42
|
+
list of 2-tuples, each containing a numerical value first in string format
|
|
43
|
+
and then a unit keyword.
|
|
44
|
+
|
|
45
|
+
Strictly speaking this just searches for any number in a text (integer or
|
|
46
|
+
decimal, with an optional leading + or -) and the first word following that
|
|
47
|
+
number and pairs them up, but that basically does the job shockingly well.
|
|
48
|
+
|
|
49
|
+
Examples:
|
|
50
|
+
>>> _parse_split('2 days 7 hours 10 minutes')
|
|
51
|
+
[('2', 'days'), ('7', 'hours'), ('10', 'minutes')]
|
|
52
|
+
|
|
53
|
+
>>> _parse_split('2 days, 7.3 hours and 10 minutes')
|
|
54
|
+
[('2', 'days'), ('7.3', 'hours'), ('10', 'minutes')]
|
|
55
|
+
|
|
56
|
+
>>> _parse_split('2 days, 7.3 hrs and 10 mins')
|
|
57
|
+
[('2', 'days'), ('7.3', 'hrs'), ('10', 'mins')]
|
|
58
|
+
|
|
59
|
+
>>> _parse_split('2 days, -7.3 hrs and 10 floops')
|
|
60
|
+
[('2', 'days'), ('-7.3', 'hrs'), ('10', 'floops')]
|
|
61
|
+
|
|
62
|
+
>>> _parse_split('2d-7.3h10m')
|
|
63
|
+
[('2', 'd'), ('-7.3', 'h'), ('10', 'm')]
|
|
64
|
+
|
|
65
|
+
>>> _parse_split('1st of all, the 3 bears were really just 2 and-a-half bears')
|
|
66
|
+
[('1', 'st'), ('3', 'bears'), ('2', 'and')]
|
|
67
|
+
"""
|
|
68
|
+
if string and isinstance(string, str):
|
|
69
|
+
buf = []
|
|
70
|
+
string = string.strip().lower()
|
|
71
|
+
for match in _PERIOD_PART.finditer(string):
|
|
72
|
+
buf.append(match.groups())
|
|
73
|
+
return buf or None
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _timedelta_keywords(list_of_lists: List[Tuple[str, str]]) -> Dict:
|
|
78
|
+
"""Given a list of 2-tuples, containing the numerical value first in string
|
|
79
|
+
format and then a time unit keyword, parses the numerical value to a Python
|
|
80
|
+
float and changes any time unit format alias or shorthand into its
|
|
81
|
+
equivalent keyword that's a valid argument for initialising a timedelta,
|
|
82
|
+
and then pairs up each of those into a dict that can then be fed into
|
|
83
|
+
timedelta's __init__ as kwargs via the ** operator.
|
|
84
|
+
|
|
85
|
+
:raises KeyError: If a time unit keyword was not recognised
|
|
86
|
+
"""
|
|
87
|
+
kw_map = {}
|
|
88
|
+
for num, kw in list_of_lists:
|
|
89
|
+
kw_map[_KEYWORDS[kw]] = float(num)
|
|
90
|
+
return kw_map
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def split_delta(delta, include_weeks=True, include_months=True, include_years=True):
|
|
94
|
+
"""Splits a timedelta into the time periods it approximately
|
|
95
|
+
contains. Mean length of years and months are used and fractional
|
|
96
|
+
days/seconds might get shaved of but this is useful to evaluate individual
|
|
97
|
+
period lengths of displaying the maximum chunk of period a timedelta
|
|
98
|
+
contains.
|
|
99
|
+
|
|
100
|
+
This class includes an `is_past` property which is True if the timedelta
|
|
101
|
+
contained a negative value but all the values in the split are nevertheless
|
|
102
|
+
kept positive.
|
|
103
|
+
|
|
104
|
+
It also includes a few `largest` and `second_largest` properties that
|
|
105
|
+
contain the first and second period units that contain values greater than 0
|
|
106
|
+
in order of increasing accuracy (i.e. from year to second).
|
|
107
|
+
|
|
108
|
+
Example:
|
|
109
|
+
|
|
110
|
+
>>> total = datetime.timedelta(days=2002, seconds=20000)
|
|
111
|
+
>>> parts = split_delta(total)
|
|
112
|
+
>>> parts
|
|
113
|
+
{'seconds': 20, 'months': 5, 'days': 2, '2nd': 'months', 'hours': 5, '1st': 'years', 'is_past': False, 'weeks': 3, 'years': 5, 'minutes': 33}
|
|
114
|
+
|
|
115
|
+
:param delta: Time period to evaluate
|
|
116
|
+
:type delta: datetime.timedelta
|
|
117
|
+
:param include_weeks: Include weeks in the division (and reduce the days accordingly)?
|
|
118
|
+
:type include_weeks: bool
|
|
119
|
+
:rtype: dict
|
|
120
|
+
"""
|
|
121
|
+
ds = DeltaSplit(time_delta=delta, include_weeks=include_weeks, include_months=include_months, include_years=include_years)
|
|
122
|
+
parts = ds.to_dict()
|
|
123
|
+
parts['1st'] = parts.pop('largest')
|
|
124
|
+
parts['2nd'] = parts.pop('second_largest')
|
|
125
|
+
return parts
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def str_to_delta(string: str, default: Any = 0) -> Union[TimeDelta, Any]:
|
|
130
|
+
"""Converts a simple string with time duration keywords into a
|
|
131
|
+
`datetime.timedelta` object, akin to the keyword arguments in
|
|
132
|
+
`datetime.timedelta`.
|
|
133
|
+
|
|
134
|
+
Examples:
|
|
135
|
+
>>> str_to_delta('12days') == datetime.timedelta(days=12)
|
|
136
|
+
True
|
|
137
|
+
>>> str_to_delta('2h34m') == datetime.timedelta(hours=2, minutes=34)
|
|
138
|
+
True
|
|
139
|
+
>>> str_to_delta('-1.5 hours') == datetime.timedelta(hours=-1.5)
|
|
140
|
+
True
|
|
141
|
+
|
|
142
|
+
Valid keywords (and aliases):
|
|
143
|
+
- days (d, day)
|
|
144
|
+
- hours (h, hr, hrs, hour)
|
|
145
|
+
- minutes (m, i, min, mins, minute)
|
|
146
|
+
- seconds (s, sec, secs, second)
|
|
147
|
+
- weeks (w, wk, wks, week)
|
|
148
|
+
|
|
149
|
+
Numeric values can be integers or floats (with or without a plus or minus
|
|
150
|
+
sign) and all whitespaces are valid but ignored.
|
|
151
|
+
"""
|
|
152
|
+
if default == 0:
|
|
153
|
+
default = TimeDelta(seconds=0)
|
|
154
|
+
res = _parse_split(string)
|
|
155
|
+
if res:
|
|
156
|
+
try:
|
|
157
|
+
return TimeDelta(**_timedelta_keywords(res))
|
|
158
|
+
except (ValueError, KeyError, IndexError):
|
|
159
|
+
return default
|
|
160
|
+
else:
|
|
161
|
+
return default
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
__all__ = [
|
|
2
|
+
'now',
|
|
3
|
+
'now_time',
|
|
4
|
+
'today',
|
|
5
|
+
'ticks',
|
|
6
|
+
]
|
|
7
|
+
|
|
8
|
+
from batutils.dtu.structs import *
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def now() -> Datetime:
|
|
13
|
+
"""Shortcut alias to `datetime.datetime.now()`"""
|
|
14
|
+
return Datetime.now()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def now_time() -> Time:
|
|
18
|
+
"""Shortcut alias to `datetime.datetime.now().time()`"""
|
|
19
|
+
return Datetime.now().time()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def today() -> Date:
|
|
23
|
+
"""Shortcut alias to `datetime.date.today()`"""
|
|
24
|
+
return Date.today()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def ticks() -> float:
|
|
28
|
+
"""Shortcut alias to `time.time()`"""
|
|
29
|
+
return time.time()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
__all__ = [
|
|
2
|
+
'is_past',
|
|
3
|
+
'is_future',
|
|
4
|
+
]
|
|
5
|
+
|
|
6
|
+
from batutils.dtu.structs import *
|
|
7
|
+
from batutils.dtu.casting import *
|
|
8
|
+
from ._aliases import *
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def is_past(temporal_object: T_TEMPORAL_VALUE) -> bool:
|
|
12
|
+
"""Returns true if the given temporal value is in the past.
|
|
13
|
+
"""
|
|
14
|
+
return any_to_datetime(temporal_object) < now()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def is_future(temporal_object: T_TEMPORAL_VALUE) -> bool:
|
|
18
|
+
"""Returns true if the given temporal value is in the future.
|
|
19
|
+
"""
|
|
20
|
+
return any_to_datetime(temporal_object) > now()
|
|
21
|
+
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
__all__ = [
|
|
2
|
+
'next_time',
|
|
3
|
+
'next_weekday',
|
|
4
|
+
'set_midnight',
|
|
5
|
+
'from_now',
|
|
6
|
+
'ago',
|
|
7
|
+
]
|
|
8
|
+
|
|
9
|
+
from batutils.dtu.structs import *
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def next_time(find_time: Time, start_dt: Optional[Datetime] = None) -> Datetime:
|
|
13
|
+
"""Find the earlier datetime when the given `find_time` occurs, after an
|
|
14
|
+
optional given `start_dt`, which is the current datetime by default.
|
|
15
|
+
|
|
16
|
+
Useful if you need something like "When does the next happy hour start?" if
|
|
17
|
+
"Happy-Hour" starts at 17:00 every day.
|
|
18
|
+
|
|
19
|
+
So if it's 2023-09-02 and 13:23:57...
|
|
20
|
+
>>> next_time(Time(17, 0))
|
|
21
|
+
datetime.datetime(2023, 9, 2, 17, 0, 0, 0)
|
|
22
|
+
|
|
23
|
+
But if its past 17, or 2023-09-02 and 19:23:57, we get tomorrow...
|
|
24
|
+
>>> next_time(Time(17, 0))
|
|
25
|
+
datetime.datetime(2023, 9, 3, 17, 0, 0, 0)
|
|
26
|
+
"""
|
|
27
|
+
start_dt = start_dt or Datetime.now()
|
|
28
|
+
dt_with_time_replaced = Datetime.combine(start_dt.date(), find_time)
|
|
29
|
+
if dt_with_time_replaced <= start_dt:
|
|
30
|
+
# New time is BEFORE the original time, so move it forward by one day
|
|
31
|
+
dt_with_time_replaced += TimeDelta(days=1)
|
|
32
|
+
return dt_with_time_replaced
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def next_weekday(day_of_week: Union[Weekday, int],
|
|
36
|
+
find_time: Optional[Time] = None,
|
|
37
|
+
start_dt: Optional[Datetime] = None) -> Datetime:
|
|
38
|
+
"""Find the earlier datetime occurrence of the given `day_of_week`
|
|
39
|
+
(where 0 = Monday) after an optional given `start_dt`, which is the current
|
|
40
|
+
datetime by default and using the optional given `find_time`, which is
|
|
41
|
+
midnight by default.
|
|
42
|
+
"""
|
|
43
|
+
start_dt = start_dt or Datetime.now() # Today
|
|
44
|
+
find_time = find_time or Time() # Midnight
|
|
45
|
+
dt_with_time_replaced = datetime.datetime.combine(start_dt.date(), find_time)
|
|
46
|
+
if dt_with_time_replaced <= start_dt:
|
|
47
|
+
# New time is BEFORE the original time, so move it forward by one day
|
|
48
|
+
dt_with_time_replaced += datetime.timedelta(days=1)
|
|
49
|
+
|
|
50
|
+
# Now we need to advance enough days to land on the correct week day
|
|
51
|
+
days_to_advance = (day_of_week - dt_with_time_replaced.weekday()) % 7
|
|
52
|
+
return dt_with_time_replaced + datetime.timedelta(days=days_to_advance)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def set_midnight(dt: T_DATE_VALUE) -> Datetime:
|
|
56
|
+
"""Rounds off any hours, minutes, seconds, etc. of a datetime object and
|
|
57
|
+
sets the time to midnight that day or combines a date object with a time
|
|
58
|
+
object set to midnight.
|
|
59
|
+
"""
|
|
60
|
+
if isinstance(dt, Datetime):
|
|
61
|
+
dt = dt.date()
|
|
62
|
+
if isinstance(dt, Date):
|
|
63
|
+
return datetime.datetime.combine(dt, Time())
|
|
64
|
+
return dt
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def from_now(days: T_NUMBER = 0, hours: T_NUMBER = 0, minutes: T_NUMBER = 0,
|
|
68
|
+
seconds: T_NUMBER = 0, weeks: T_NUMBER = 0) -> Datetime:
|
|
69
|
+
"""Adds the given number of weeks, days, hours, minutes and/or seconds
|
|
70
|
+
to the current datetime and returns it.
|
|
71
|
+
"""
|
|
72
|
+
return Datetime.now() + TimeDelta(days=days, hours=hours, minutes=minutes, seconds=seconds, weeks=weeks)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def ago(days: T_NUMBER = 0, hours: T_NUMBER = 0, minutes: T_NUMBER = 0,
|
|
76
|
+
seconds: T_NUMBER = 0, weeks: T_NUMBER = 0) -> Datetime:
|
|
77
|
+
"""Subtracts the given number of weeks, days, hours, minutes and/or seconds
|
|
78
|
+
from the current datetime and returns it.
|
|
79
|
+
"""
|
|
80
|
+
return Datetime.now() - TimeDelta(days=days, hours=hours, minutes=minutes, seconds=seconds, weeks=weeks)
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
__all__ = [
|
|
2
|
+
'DeltaSplit',
|
|
3
|
+
]
|
|
4
|
+
|
|
5
|
+
from ._base import *
|
|
6
|
+
from .aliases import *
|
|
7
|
+
from .consts import *
|
|
8
|
+
from ._types import *
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
_field_size_map: Dict[str, T_NUMBER] = {
|
|
12
|
+
'years': DAYS_IN_MEAN_YEAR, # Days
|
|
13
|
+
'months': DAYS_IN_MEAN_MONTH, # Days
|
|
14
|
+
'weeks': 7, # Days
|
|
15
|
+
|
|
16
|
+
'hours': SECONDS_IN_ONE_HOUR,
|
|
17
|
+
'minutes': SECONDS_IN_ONE_MINUTE,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
_DEFAULT_DIRECTIONALS = ('in {}', '{} ago')
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclasses.dataclass(frozen=True)
|
|
24
|
+
class DeltaSplit:
|
|
25
|
+
"""Splits a timedelta into the time periods it approximately
|
|
26
|
+
contains. Mean length of years and months are used and fractional
|
|
27
|
+
days/seconds might get shaved of but this is useful to evaluate individual
|
|
28
|
+
period lengths of displaying the maximum chunk of period a timedelta
|
|
29
|
+
contains.
|
|
30
|
+
|
|
31
|
+
This class includes an `is_past` property which is True if the timedelta
|
|
32
|
+
contained a negative value but all the values in the split are nevertheless
|
|
33
|
+
kept positive.
|
|
34
|
+
|
|
35
|
+
It also includes a few `largest` and `second_largest` properties that
|
|
36
|
+
contain the first and second period units that contain values greater than 0
|
|
37
|
+
in order of increasing accuracy (i.e. from year to second).
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
# Names of unit fields with non-zero values in order of unit size
|
|
41
|
+
field_order: Tuple[str] = dataclasses.field(default_factory=tuple, init=False)
|
|
42
|
+
|
|
43
|
+
is_past: bool = dataclasses.field(default=False, init=False) # Is this counting backwards in time?
|
|
44
|
+
|
|
45
|
+
years: Optional[int] = dataclasses.field(default=None, init=False) # Optional
|
|
46
|
+
months: Optional[int] = dataclasses.field(default=None, init=False) # Optional
|
|
47
|
+
weeks: Optional[int] = dataclasses.field(default=None, init=False) # Optional
|
|
48
|
+
days: int = dataclasses.field(default=0, init=False)
|
|
49
|
+
hours: int = dataclasses.field(default=0, init=False)
|
|
50
|
+
minutes: int = dataclasses.field(default=0, init=False)
|
|
51
|
+
seconds: int = dataclasses.field(default=0, init=False)
|
|
52
|
+
|
|
53
|
+
time_delta: dataclasses.InitVar[TimeDelta]
|
|
54
|
+
include_weeks: dataclasses.InitVar[bool] = True
|
|
55
|
+
include_months: dataclasses.InitVar[bool] = True
|
|
56
|
+
include_years: dataclasses.InitVar[bool] = True
|
|
57
|
+
|
|
58
|
+
def get(self, field_name: str) -> str:
|
|
59
|
+
return f'{self.get_value(field_name)} {self._render_field_name(field_name)}'
|
|
60
|
+
|
|
61
|
+
def get_value(self, field_name: str) -> int:
|
|
62
|
+
return getattr(self, field_name, 0) or 0
|
|
63
|
+
|
|
64
|
+
def _render_field_name(self, field_name: str) -> str:
|
|
65
|
+
if self.get_value(field_name) == 1:
|
|
66
|
+
return field_name[:-1] # Cut off the trailing 's'
|
|
67
|
+
return field_name
|
|
68
|
+
|
|
69
|
+
def _be_greedy(self, field_name: str, total: T_NUMBER) -> T_NUMBER:
|
|
70
|
+
object.__setattr__(self, field_name, (int(total // _field_size_map[field_name])))
|
|
71
|
+
return total % _field_size_map[field_name]
|
|
72
|
+
|
|
73
|
+
def __post_init__(self, time_delta: TimeDelta, include_weeks: bool = True,
|
|
74
|
+
include_months: bool = True, include_years: bool = True):
|
|
75
|
+
field_order = []
|
|
76
|
+
if time_delta.total_seconds() < 0:
|
|
77
|
+
object.__setattr__(self, 'is_past', True)
|
|
78
|
+
# Invert deltas in the past because they track negative days but positive seconds, which gets confusing
|
|
79
|
+
time_delta = datetime.timedelta(seconds=-time_delta.total_seconds())
|
|
80
|
+
|
|
81
|
+
days = abs(time_delta.days)
|
|
82
|
+
if include_years:
|
|
83
|
+
days = self._be_greedy('years', days)
|
|
84
|
+
if self.years:
|
|
85
|
+
field_order.append('years')
|
|
86
|
+
|
|
87
|
+
if include_months:
|
|
88
|
+
days = self._be_greedy('months', days)
|
|
89
|
+
if self.months:
|
|
90
|
+
field_order.append('months')
|
|
91
|
+
|
|
92
|
+
if include_weeks:
|
|
93
|
+
days = self._be_greedy('weeks', days)
|
|
94
|
+
if self.weeks:
|
|
95
|
+
field_order.append('weeks')
|
|
96
|
+
|
|
97
|
+
object.__setattr__(self, 'days', int(days))
|
|
98
|
+
if self.days:
|
|
99
|
+
field_order.append('days')
|
|
100
|
+
|
|
101
|
+
seconds = self._be_greedy('hours', abs(time_delta.seconds))
|
|
102
|
+
if self.hours:
|
|
103
|
+
field_order.append('hours')
|
|
104
|
+
|
|
105
|
+
seconds = self._be_greedy('minutes', seconds)
|
|
106
|
+
if self.minutes:
|
|
107
|
+
field_order.append('minutes')
|
|
108
|
+
|
|
109
|
+
object.__setattr__(self, 'seconds', int(seconds))
|
|
110
|
+
if self.seconds:
|
|
111
|
+
field_order.append('seconds')
|
|
112
|
+
|
|
113
|
+
object.__setattr__(self, 'field_order', tuple(field_order))
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def largest(self) -> str:
|
|
117
|
+
"""
|
|
118
|
+
:return: The largest
|
|
119
|
+
"""
|
|
120
|
+
if self.field_order:
|
|
121
|
+
return f'{self.largest_value} {self._render_field_name(self.largest_field_name)}'
|
|
122
|
+
return ''
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def largest_field_name(self) -> Optional[str]:
|
|
126
|
+
"""
|
|
127
|
+
:return: The name of the largest unit field with a non-zero value or None if there is none.
|
|
128
|
+
"""
|
|
129
|
+
if self.field_order:
|
|
130
|
+
return self.field_order[0]
|
|
131
|
+
else:
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def largest_value(self) -> int:
|
|
136
|
+
"""
|
|
137
|
+
:return: The value of the largest unit field with a non-zero value or 0.
|
|
138
|
+
"""
|
|
139
|
+
return self.get_value(self.largest_field_name) if self.largest_field_name else 0
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def second_largest(self) -> str:
|
|
143
|
+
"""
|
|
144
|
+
:return: The largest
|
|
145
|
+
"""
|
|
146
|
+
if len(self.field_order) > 1:
|
|
147
|
+
return f'{self.second_largest_value} {self._render_field_name(self.second_largest_field_name)}'
|
|
148
|
+
return ''
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def second_largest_field_name(self) -> Optional[str]:
|
|
152
|
+
"""
|
|
153
|
+
:return: The name of the second-largest unit field with a non-zero value or None if there is none.
|
|
154
|
+
"""
|
|
155
|
+
if len(self.field_order) > 1:
|
|
156
|
+
return self.field_order[1]
|
|
157
|
+
else:
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def second_largest_value(self) -> int:
|
|
162
|
+
"""
|
|
163
|
+
:return: The value of the second-largest unit field with a non-zero value or 0.
|
|
164
|
+
"""
|
|
165
|
+
return self.get_value(self.second_largest_field_name) if self.second_largest_field_name else 0
|
|
166
|
+
|
|
167
|
+
def to_str(self, max_number_of_fields: int = 2, include_seconds: bool = False,
|
|
168
|
+
directionals: Optional[Tuple[str, str]] = _DEFAULT_DIRECTIONALS,
|
|
169
|
+
no_time_str: str = 'no time at all',
|
|
170
|
+
only_sec_str: str = 'a few seconds',
|
|
171
|
+
granularity_halting_threshold: Optional[int] = 1) -> str:
|
|
172
|
+
"""Returns a "human-readable" textual representation of this DeltaSplit, e.g.
|
|
173
|
+
- "3 weeks"
|
|
174
|
+
- "a few seconds"
|
|
175
|
+
- "1 year and 7 months"
|
|
176
|
+
- "7 years, 3 months, 1 week, 6 days, 34 minutes and 7 seconds"
|
|
177
|
+
- "no time at all"
|
|
178
|
+
|
|
179
|
+
:param max_number_of_fields: Maximum number of fields to include in the
|
|
180
|
+
output. There can at most be 7 fields with
|
|
181
|
+
values so any number than 6 will include
|
|
182
|
+
all possible fields. The default is 2.
|
|
183
|
+
:param include_seconds: By default, the seconds field is allways omitted
|
|
184
|
+
if there are any other larger unit fields with
|
|
185
|
+
non-zero values (even if `max_number_of_fields`
|
|
186
|
+
says it should be included) and if the only
|
|
187
|
+
non-zero value field is seconds, then the text
|
|
188
|
+
"a few seconds" is returned. If
|
|
189
|
+
`include seconds` is True, then the seconds
|
|
190
|
+
field is treated as all other fields and
|
|
191
|
+
returned if `max_number_of_fields` allows and
|
|
192
|
+
the exact number of seconds are always used.
|
|
193
|
+
:param directionals: A two-tuple of strings to use as final formatters
|
|
194
|
+
(using `{}` as placeholder for the actual string)
|
|
195
|
+
for future and past values respectively. By
|
|
196
|
+
default, this is `('in {}', '{} ago')` so future
|
|
197
|
+
values format like "in 3 weeks" and past like "7
|
|
198
|
+
days and 3 hours ago". This can be fully excluded
|
|
199
|
+
by setting the `directionals` to None.
|
|
200
|
+
:param no_time_str: The string to return if this DeltaSplit contains no
|
|
201
|
+
time (or less than a whole second).
|
|
202
|
+
:param only_sec_str: The string to return if `include_seconds` is False
|
|
203
|
+
and the DeltaSplit only contains seconds. Default
|
|
204
|
+
is `a few seconds`.
|
|
205
|
+
:param granularity_halting_threshold: An optional threshold value that,
|
|
206
|
+
if a field value exceeds, causes
|
|
207
|
+
the rendering to halt its granular
|
|
208
|
+
descent. E.g. if `granularity_halting_threshold=1`
|
|
209
|
+
and `max_number_of_fields=2` and
|
|
210
|
+
we have the fields `days=1,
|
|
211
|
+
hours=2, minutes=3` we'll get "1
|
|
212
|
+
day and 2 hours" while if `days=2,
|
|
213
|
+
hours=2, minutes=3` we'll just get
|
|
214
|
+
"2 days" because `days` exceeds
|
|
215
|
+
the threshold of 1 and thus halts
|
|
216
|
+
diving into more granular fields.
|
|
217
|
+
The default is `1` but setting
|
|
218
|
+
this to `None` will just turn this
|
|
219
|
+
check off entirely.
|
|
220
|
+
"""
|
|
221
|
+
if not self.field_order:
|
|
222
|
+
return no_time_str
|
|
223
|
+
|
|
224
|
+
def _internal() -> str:
|
|
225
|
+
# First of all, let's take care of this case where we only have seconds!
|
|
226
|
+
if self.largest_field_name == 'seconds':
|
|
227
|
+
if include_seconds:
|
|
228
|
+
return self.largest # Include them
|
|
229
|
+
else:
|
|
230
|
+
return only_sec_str # Skip them
|
|
231
|
+
|
|
232
|
+
# Now for a more "generic" approach
|
|
233
|
+
fields = list(self.field_order[0:max_number_of_fields])
|
|
234
|
+
if not include_seconds and fields[-1] == 'seconds': # We have, but don't want seconds!
|
|
235
|
+
fields = fields[0:-1] # Remove Seconds
|
|
236
|
+
|
|
237
|
+
as_strings = []
|
|
238
|
+
for f in fields:
|
|
239
|
+
as_strings.append(self.get(f))
|
|
240
|
+
if granularity_halting_threshold and self.get_value(f) > granularity_halting_threshold:
|
|
241
|
+
break
|
|
242
|
+
|
|
243
|
+
if len(as_strings) == 1:
|
|
244
|
+
return as_strings[0]
|
|
245
|
+
|
|
246
|
+
elif len(as_strings) == 2:
|
|
247
|
+
return f'{as_strings[0]} and {as_strings[1]}'
|
|
248
|
+
|
|
249
|
+
else:
|
|
250
|
+
return f'{", ".join(as_strings[0:-1])} and {as_strings[-1]}'
|
|
251
|
+
|
|
252
|
+
if directionals:
|
|
253
|
+
if self.is_past:
|
|
254
|
+
return directionals[1].format(_internal())
|
|
255
|
+
return directionals[0].format(_internal())
|
|
256
|
+
return _internal()
|
|
257
|
+
|
|
258
|
+
def __str__(self) -> str:
|
|
259
|
+
return self.to_str()
|
|
260
|
+
|
|
261
|
+
def __repr__(self) -> str:
|
|
262
|
+
return f'<DeltaSplit {self.to_str(max_number_of_fields=7, directionals=("{}", "{}, is_past=True"), no_time_str="0", include_seconds=True, granularity_halting_threshold=None)}>'
|
|
263
|
+
|
|
264
|
+
def to_dict(self) -> Dict:
|
|
265
|
+
parts = {'largest': self.largest_field_name,
|
|
266
|
+
'second_largest': self.second_largest_field_name,
|
|
267
|
+
'is_past': self.is_past,
|
|
268
|
+
'days': self.days,
|
|
269
|
+
'hours': self.hours,
|
|
270
|
+
'minutes': self.minutes,
|
|
271
|
+
'seconds': self.seconds}
|
|
272
|
+
if self.years:
|
|
273
|
+
parts['years'] = self.years
|
|
274
|
+
if self.months:
|
|
275
|
+
parts['months'] = self.months
|
|
276
|
+
if self.weeks:
|
|
277
|
+
parts['weeks'] = self.weeks
|
|
278
|
+
return parts
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
__all__ = [
|
|
2
|
+
'T_DATETIME_VALUE',
|
|
3
|
+
'T_DATE_VALUE',
|
|
4
|
+
'T_TIME_VALUE',
|
|
5
|
+
'T_TEMPORAL_VALUE',
|
|
6
|
+
'T_NUMBER',
|
|
7
|
+
]
|
|
8
|
+
|
|
9
|
+
from batutils.dtu.structs._base import *
|
|
10
|
+
|
|
11
|
+
# Any datetime value
|
|
12
|
+
T_DATETIME_VALUE = Union[datetime.datetime, datetime.date, datetime.time]
|
|
13
|
+
|
|
14
|
+
# Containing a date
|
|
15
|
+
T_DATE_VALUE = Union[datetime.datetime, datetime.date]
|
|
16
|
+
|
|
17
|
+
# Containing time
|
|
18
|
+
T_TIME_VALUE = Union[datetime.datetime, datetime.time]
|
|
19
|
+
|
|
20
|
+
# Any type that can possibly contain a date and/or time value
|
|
21
|
+
T_TEMPORAL_VALUE = Union[T_DATETIME_VALUE, float, int, str, bytes]
|
|
22
|
+
|
|
23
|
+
# Number
|
|
24
|
+
T_NUMBER = Union[int, float]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
__all__ = [
|
|
2
|
+
'Date',
|
|
3
|
+
'Time',
|
|
4
|
+
'Datetime',
|
|
5
|
+
'TimeDelta',
|
|
6
|
+
'TzInfo',
|
|
7
|
+
'TimeZone',
|
|
8
|
+
]
|
|
9
|
+
from batutils.dtu.structs._base import *
|
|
10
|
+
|
|
11
|
+
# Aliases to "standard" PEP 8 class names. This is to increase readability and
|
|
12
|
+
# reduce confusion related to the fact that the most commonly used class/object
|
|
13
|
+
# shared the name of its own module (i.e. `datetime.datetime`)
|
|
14
|
+
Date = datetime.date
|
|
15
|
+
Time = datetime.time
|
|
16
|
+
Datetime = datetime.datetime
|
|
17
|
+
TimeDelta = datetime.timedelta
|
|
18
|
+
TzInfo = datetime.tzinfo
|
|
19
|
+
TimeZone = datetime.timezone
|
|
20
|
+
|
|
21
|
+
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
__all__ = [
|
|
2
|
+
'DAYS_IN_MEAN_YEAR',
|
|
3
|
+
'DAYS_IN_MEAN_MONTH',
|
|
4
|
+
'DAYS_IN_MEDIAN_YEAR',
|
|
5
|
+
'DAYS_IN_MEDIAN_MONTH',
|
|
6
|
+
'WHOLE_DAYS_IN_MEAN_YEAR',
|
|
7
|
+
'WHOLE_DAYS_IN_MEAN_MONTH',
|
|
8
|
+
'SECONDS_IN_ONE_MINUTE',
|
|
9
|
+
'SECONDS_IN_ONE_HOUR',
|
|
10
|
+
'SECONDS_IN_ONE_DAY',
|
|
11
|
+
'Weekday',
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
import enum
|
|
15
|
+
|
|
16
|
+
DAYS_IN_MEAN_YEAR = 365.2425 # Every 400 years we get 97 leap years (303*365 + 97*366)/400
|
|
17
|
+
DAYS_IN_MEAN_MONTH = 30.436875 # Mean Year / 12
|
|
18
|
+
DAYS_IN_MEDIAN_YEAR = 365 # Most common value (303 vs. 97 per 400 year cycle)
|
|
19
|
+
DAYS_IN_MEDIAN_MONTH = 31 # Most common value (7 out of 12)
|
|
20
|
+
WHOLE_DAYS_IN_MEAN_YEAR = 365
|
|
21
|
+
WHOLE_DAYS_IN_MEAN_MONTH = 30
|
|
22
|
+
|
|
23
|
+
SECONDS_IN_ONE_MINUTE = 60
|
|
24
|
+
SECONDS_IN_ONE_HOUR = 3600 # 60 * 60
|
|
25
|
+
SECONDS_IN_ONE_DAY = 86400 # 60 * 60 * 24
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Weekday(enum.IntEnum):
|
|
29
|
+
MONDAY = 0
|
|
30
|
+
TUESDAY = 1
|
|
31
|
+
WEDNESDAY = 2
|
|
32
|
+
THURSDAY = 3
|
|
33
|
+
FRIDAY = 4
|
|
34
|
+
SATURDAY = 5
|
|
35
|
+
SUNDAY = 6
|