frist 0.6.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.
- frist/__init__.py +26 -0
- frist/_age.py +132 -0
- frist/_cal.py +480 -0
- frist/_constants.py +50 -0
- frist/_frist.py +176 -0
- frist-0.6.0.dist-info/METADATA +5 -0
- frist-0.6.0.dist-info/RECORD +9 -0
- frist-0.6.0.dist-info/WHEEL +5 -0
- frist-0.6.0.dist-info/top_level.txt +1 -0
frist/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Frist: Standalone datetime utility package
|
|
3
|
+
|
|
4
|
+
Provides robust tools for:
|
|
5
|
+
- Age and duration calculations across multiple time units
|
|
6
|
+
- Calendar window filtering (days, weeks, months, quarters, years)
|
|
7
|
+
- Fiscal year/quarter logic and holiday detection
|
|
8
|
+
- Flexible datetime parsing and normalization
|
|
9
|
+
|
|
10
|
+
Designed for use in any Python project requiring advanced datetime analysis, not limited to file operations.
|
|
11
|
+
|
|
12
|
+
Exports:
|
|
13
|
+
Frist -- Main datetime utility class
|
|
14
|
+
Age -- Duration and age calculations
|
|
15
|
+
Cal -- Calendar window and filtering logic
|
|
16
|
+
TimeSpan -- Time span representation for advanced calculations
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from ._age import Age
|
|
20
|
+
from ._cal import Cal, TimeSpan
|
|
21
|
+
from ._frist import Frist
|
|
22
|
+
|
|
23
|
+
__version__ = "0.6.0"
|
|
24
|
+
__author__ = "Chuck Bass"
|
|
25
|
+
|
|
26
|
+
__all__ = ["Frist", "Age", "Cal", "TimeSpan"]
|
frist/_age.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Age property implementation for Frist package.
|
|
3
|
+
|
|
4
|
+
Handles age calculations in various time units, supporting both file-based and standalone usage.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import datetime as dt
|
|
8
|
+
import re
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from ._constants import (
|
|
12
|
+
DAYS_PER_MONTH,
|
|
13
|
+
DAYS_PER_YEAR,
|
|
14
|
+
SECONDS_PER_DAY,
|
|
15
|
+
SECONDS_PER_HOUR,
|
|
16
|
+
SECONDS_PER_MINUTE,
|
|
17
|
+
SECONDS_PER_MONTH,
|
|
18
|
+
SECONDS_PER_WEEK,
|
|
19
|
+
SECONDS_PER_YEAR,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Age:
|
|
24
|
+
"""Property class for handling age calculations in various time units."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, path: Path | None, timestamp: float, base_time: dt.datetime):
|
|
27
|
+
self.path = path
|
|
28
|
+
self.timestamp = timestamp
|
|
29
|
+
self.base_time = base_time
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def seconds(self) -> float:
|
|
33
|
+
"""Get age in seconds."""
|
|
34
|
+
# Only check file existence if we have a path
|
|
35
|
+
if self.path is not None and not self.path.exists():
|
|
36
|
+
return 0
|
|
37
|
+
file_time = dt.datetime.fromtimestamp(self.timestamp)
|
|
38
|
+
return (self.base_time - file_time).total_seconds()
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def minutes(self) -> float:
|
|
42
|
+
"""Get age in minutes."""
|
|
43
|
+
return self.seconds / SECONDS_PER_MINUTE
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def hours(self) -> float:
|
|
47
|
+
"""Get age in hours."""
|
|
48
|
+
return self.seconds / SECONDS_PER_HOUR
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def days(self) -> float:
|
|
52
|
+
"""Get age in days."""
|
|
53
|
+
return self.seconds / SECONDS_PER_DAY
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def weeks(self) -> float:
|
|
57
|
+
"""Get age in weeks."""
|
|
58
|
+
return self.days / 7
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def months(self) -> float:
|
|
62
|
+
"""Get age in months (approximate - 30.44 days)."""
|
|
63
|
+
return self.days / DAYS_PER_MONTH
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def years(self) -> float:
|
|
67
|
+
"""Get age in years (approximate - 365.25 days, can be negative)."""
|
|
68
|
+
# Allow negative ages if base_time is before timestamp
|
|
69
|
+
return self.days / DAYS_PER_YEAR
|
|
70
|
+
|
|
71
|
+
@staticmethod
|
|
72
|
+
def parse(age_str: str) -> float:
|
|
73
|
+
"""
|
|
74
|
+
Parse an age string and return the age in seconds.
|
|
75
|
+
|
|
76
|
+
Examples:
|
|
77
|
+
"30" -> 30 seconds
|
|
78
|
+
"5m" -> 300 seconds (5 minutes)
|
|
79
|
+
"2h" -> 7200 seconds (2 hours)
|
|
80
|
+
"3d" -> 259200 seconds (3 days)
|
|
81
|
+
"1w" -> 604800 seconds (1 week)
|
|
82
|
+
"2months" -> 5260032 seconds (2 months)
|
|
83
|
+
"1y" -> 31557600 seconds (1 year)
|
|
84
|
+
"""
|
|
85
|
+
age_str = age_str.strip().lower()
|
|
86
|
+
|
|
87
|
+
# Handle plain numbers (seconds)
|
|
88
|
+
if age_str.isdigit():
|
|
89
|
+
return float(age_str)
|
|
90
|
+
|
|
91
|
+
# Regular expression to parse age with unit
|
|
92
|
+
match = re.match(r"^(\d+(?:\.\d+)?)\s*([a-zA-Z]+)$", age_str)
|
|
93
|
+
if not match:
|
|
94
|
+
raise ValueError(f"Invalid age format: {age_str}")
|
|
95
|
+
|
|
96
|
+
value: float = float(match.group(1))
|
|
97
|
+
unit: str = match.group(2).lower()
|
|
98
|
+
|
|
99
|
+
# Define multipliers (convert to seconds)
|
|
100
|
+
unit_multipliers = {
|
|
101
|
+
"s": 1,
|
|
102
|
+
"sec": 1,
|
|
103
|
+
"second": 1,
|
|
104
|
+
"seconds": 1,
|
|
105
|
+
"m": SECONDS_PER_MINUTE,
|
|
106
|
+
"min": SECONDS_PER_MINUTE,
|
|
107
|
+
"minute": SECONDS_PER_MINUTE,
|
|
108
|
+
"minutes": SECONDS_PER_MINUTE,
|
|
109
|
+
"h": SECONDS_PER_HOUR,
|
|
110
|
+
"hr": SECONDS_PER_HOUR,
|
|
111
|
+
"hour": SECONDS_PER_HOUR,
|
|
112
|
+
"hours": SECONDS_PER_HOUR,
|
|
113
|
+
"d": SECONDS_PER_DAY,
|
|
114
|
+
"day": SECONDS_PER_DAY,
|
|
115
|
+
"days": SECONDS_PER_DAY,
|
|
116
|
+
"w": SECONDS_PER_WEEK,
|
|
117
|
+
"week": SECONDS_PER_WEEK,
|
|
118
|
+
"weeks": SECONDS_PER_WEEK,
|
|
119
|
+
"month": SECONDS_PER_MONTH,
|
|
120
|
+
"months": SECONDS_PER_MONTH,
|
|
121
|
+
"y": SECONDS_PER_YEAR,
|
|
122
|
+
"year": SECONDS_PER_YEAR,
|
|
123
|
+
"years": SECONDS_PER_YEAR,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if unit not in unit_multipliers:
|
|
127
|
+
raise ValueError(f"Unknown unit: {unit}")
|
|
128
|
+
|
|
129
|
+
return value * unit_multipliers[unit]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
__all__ = ["Age"]
|
frist/_cal.py
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
|
|
2
|
+
"""
|
|
3
|
+
Calendar-based time window filtering for Frist package.
|
|
4
|
+
|
|
5
|
+
Provides calendar window filtering functionality that works with any object
|
|
6
|
+
having datetime and base_time properties (Time or Frist objects).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import datetime as dt
|
|
10
|
+
from typing import TYPE_CHECKING, Protocol
|
|
11
|
+
|
|
12
|
+
from ._constants import WEEKDAY_INDEX
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TimeSpan(Protocol):
|
|
19
|
+
"""Protocol for objects that represent a time span between two datetime points."""
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def target_dt(self) -> dt.datetime:
|
|
23
|
+
"""The target datetime being analyzed."""
|
|
24
|
+
raise NotImplementedError
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def ref_dt(self) -> dt.datetime:
|
|
28
|
+
"""The reference datetime for span calculations."""
|
|
29
|
+
raise NotImplementedError
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def normalize_weekday(day_spec: str) -> int:
|
|
34
|
+
"""Normalize various day-of-week specifications to Python weekday numbers.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
day_spec: Day specification as a string
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
int: Python weekday number (0=Monday, 1=Tuesday, ..., 6=Sunday)
|
|
41
|
+
|
|
42
|
+
Accepts:
|
|
43
|
+
- Full names: 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'
|
|
44
|
+
- 3-letter abbrev: 'mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'
|
|
45
|
+
- 2-letter abbrev: 'mo', 'tu', 'we', 'th', 'fr', 'sa', 'su'
|
|
46
|
+
- Pandas style: 'w-mon', 'w-tue', etc.
|
|
47
|
+
- All case insensitive
|
|
48
|
+
|
|
49
|
+
Examples:
|
|
50
|
+
normalize_weekday('monday') -> 0
|
|
51
|
+
normalize_weekday('MON') -> 0
|
|
52
|
+
normalize_weekday('w-sun') -> 6
|
|
53
|
+
normalize_weekday('thu') -> 3
|
|
54
|
+
"""
|
|
55
|
+
day_spec = str(day_spec).lower().strip()
|
|
56
|
+
|
|
57
|
+
# Remove pandas-style prefix
|
|
58
|
+
if day_spec.startswith("w-"):
|
|
59
|
+
day_spec = day_spec[2:]
|
|
60
|
+
|
|
61
|
+
if day_spec in WEEKDAY_INDEX:
|
|
62
|
+
return WEEKDAY_INDEX[day_spec]
|
|
63
|
+
|
|
64
|
+
# Generate helpful error message
|
|
65
|
+
valid_examples = [
|
|
66
|
+
"Full: 'monday', 'sunday'",
|
|
67
|
+
"3-letter: 'mon', 'sun', 'tue', 'wed', 'thu', 'fri', 'sat'",
|
|
68
|
+
"2-letter: 'mo', 'su', 'tu', 'we', 'th', 'fr', 'sa'",
|
|
69
|
+
"Pandas: 'w-mon', 'w-sun'",
|
|
70
|
+
]
|
|
71
|
+
raise ValueError(
|
|
72
|
+
f"Invalid day specification: '{day_spec}'. Valid formats:\n"
|
|
73
|
+
+ "\n".join(f" • {ex}" for ex in valid_examples)
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Cal:
|
|
78
|
+
"""Calendar window filtering functionality for TimeSpan objects."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, time_span: TimeSpan, fy_start_month: int = 1, holidays: set[str] | None = None) -> None:
|
|
81
|
+
"""Initialize with a TimeSpan object to provide calendar filtering methods."""
|
|
82
|
+
self.time_span: TimeSpan = time_span
|
|
83
|
+
self.fy_start_month: int = fy_start_month
|
|
84
|
+
self.holidays: set[str] = holidays if holidays is not None else set()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def holiday(self) -> bool:
|
|
92
|
+
"""Return True if dt_val is a holiday (in holidays set)."""
|
|
93
|
+
date_str = self.dt_val.strftime('%Y-%m-%d')
|
|
94
|
+
return date_str in self.holidays
|
|
95
|
+
@property
|
|
96
|
+
def fiscal_year(self) -> int:
|
|
97
|
+
"""Return the fiscal year for dt_val based on fy_start_month."""
|
|
98
|
+
month = self.dt_val.month
|
|
99
|
+
year = self.dt_val.year
|
|
100
|
+
if month >= self.fy_start_month:
|
|
101
|
+
return year
|
|
102
|
+
else:
|
|
103
|
+
return year - 1
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def fiscal_quarter(self) -> int:
|
|
107
|
+
"""Return the fiscal quarter for dt_val based on fy_start_month."""
|
|
108
|
+
month = self.dt_val.month
|
|
109
|
+
offset = (month - self.fy_start_month) % 12
|
|
110
|
+
return (offset // 3) + 1
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def dt_val(self) -> dt.datetime:
|
|
114
|
+
"""Get target datetime from the time span."""
|
|
115
|
+
return self.time_span.target_dt
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def base_time(self) -> dt.datetime:
|
|
119
|
+
"""Get reference datetime from the time span."""
|
|
120
|
+
return self.time_span.ref_dt
|
|
121
|
+
|
|
122
|
+
def in_minutes(self, start: int = 0, end: int | None = None) -> bool:
|
|
123
|
+
"""
|
|
124
|
+
True if timestamp falls within the minute window(s) from start to end.
|
|
125
|
+
|
|
126
|
+
Uses a half-open interval: start_minute <= target_time < end_minute.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
start: Minutes from now to start range (negative = past, 0 = current minute, positive = future)
|
|
130
|
+
end: Minutes from now to end range (defaults to start for single minute)
|
|
131
|
+
|
|
132
|
+
Examples:
|
|
133
|
+
zeit.cal.in_minutes(0) # This minute (now)
|
|
134
|
+
zeit.cal.in_minutes(-5) # 5 minutes ago only
|
|
135
|
+
zeit.cal.in_minutes(-10, -5) # From 10 minutes ago through 5 minutes ago
|
|
136
|
+
zeit.cal.in_minutes(-30, 0) # Last 30 minutes through now
|
|
137
|
+
"""
|
|
138
|
+
if end is None:
|
|
139
|
+
end = start
|
|
140
|
+
|
|
141
|
+
if start > end:
|
|
142
|
+
raise ValueError(f"start ({start}) must not be greater than end ({end})")
|
|
143
|
+
|
|
144
|
+
target_time = self.dt_val
|
|
145
|
+
|
|
146
|
+
# Calculate the time window boundaries
|
|
147
|
+
start_time = self.base_time + dt.timedelta(minutes=start)
|
|
148
|
+
start_minute = start_time.replace(second=0, microsecond=0)
|
|
149
|
+
|
|
150
|
+
end_time = self.base_time + dt.timedelta(minutes=end)
|
|
151
|
+
end_minute = end_time.replace(second=0, microsecond=0) + dt.timedelta(minutes=1)
|
|
152
|
+
|
|
153
|
+
return start_minute <= target_time < end_minute
|
|
154
|
+
|
|
155
|
+
def in_hours(self, start: int = 0, end: int | None = None) -> bool:
|
|
156
|
+
"""
|
|
157
|
+
True if timestamp falls within the hour window(s) from start to end.
|
|
158
|
+
|
|
159
|
+
Uses a half-open interval: start_hour <= target_time < end_hour.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
start: Hours from now to start range (negative = past, 0 = current hour, positive = future)
|
|
163
|
+
end: Hours from now to end range (defaults to start for single hour)
|
|
164
|
+
|
|
165
|
+
Examples:
|
|
166
|
+
zeit.cal.in_hours(0) # This hour (now)
|
|
167
|
+
zeit.cal.in_hours(-2) # 2 hours ago only
|
|
168
|
+
zeit.cal.in_hours(-6, -1) # From 6 hours ago through 1 hour ago
|
|
169
|
+
zeit.cal.in_hours(-24, 0) # Last 24 hours through now
|
|
170
|
+
"""
|
|
171
|
+
if end is None:
|
|
172
|
+
end = start
|
|
173
|
+
|
|
174
|
+
if start > end:
|
|
175
|
+
raise ValueError(f"start ({start}) must not be greater than end ({end})")
|
|
176
|
+
|
|
177
|
+
target_time = self.dt_val
|
|
178
|
+
|
|
179
|
+
# Calculate the time window boundaries
|
|
180
|
+
start_time = self.base_time + dt.timedelta(hours=start)
|
|
181
|
+
start_hour = start_time.replace(minute=0, second=0, microsecond=0)
|
|
182
|
+
|
|
183
|
+
end_time = self.base_time + dt.timedelta(hours=end)
|
|
184
|
+
end_hour = end_time.replace(minute=0, second=0, microsecond=0) + dt.timedelta(
|
|
185
|
+
hours=1
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
return start_hour <= target_time < end_hour
|
|
189
|
+
|
|
190
|
+
def in_days(self, start: int = 0, end: int | None = None) -> bool:
|
|
191
|
+
"""True if timestamp falls within the day window(s) from start to end.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
start: Days from now to start range (negative = past, 0 = today, positive = future)
|
|
195
|
+
end: Days from now to end range (defaults to start for single day)
|
|
196
|
+
|
|
197
|
+
Examples:
|
|
198
|
+
zeit.cal.in_days(0) # Today only
|
|
199
|
+
zeit.cal.in_days(-1) # Yesterday only
|
|
200
|
+
zeit.cal.in_days(-7, -1) # From 7 days ago through yesterday
|
|
201
|
+
zeit.cal.in_days(-30, 0) # Last 30 days through today
|
|
202
|
+
"""
|
|
203
|
+
if end is None:
|
|
204
|
+
end = start
|
|
205
|
+
|
|
206
|
+
if start > end:
|
|
207
|
+
msg = f"start ({start}) must not be greater than end ({end})"
|
|
208
|
+
raise ValueError(msg)
|
|
209
|
+
|
|
210
|
+
target_date = self.dt_val.date()
|
|
211
|
+
|
|
212
|
+
# Calculate the date range boundaries
|
|
213
|
+
start_date = (self.base_time + dt.timedelta(days=start)).date()
|
|
214
|
+
end_date = (self.base_time + dt.timedelta(days=end)).date()
|
|
215
|
+
|
|
216
|
+
return start_date <= target_date <= end_date
|
|
217
|
+
|
|
218
|
+
def in_months(self, start: int = 0, end: int | None = None) -> bool:
|
|
219
|
+
"""True if timestamp falls within the month window(s) from start to end.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
start: Months from now to start range (negative = past, 0 = this month, positive = future)
|
|
223
|
+
end: Months from now to end range (defaults to start for single month)
|
|
224
|
+
|
|
225
|
+
Examples:
|
|
226
|
+
zeit.cal.in_months(0) # This month
|
|
227
|
+
zeit.cal.in_months(-1) # Last month only
|
|
228
|
+
zeit.cal.in_months(-6, -1) # From 6 months ago through last month
|
|
229
|
+
zeit.cal.in_months(-12, 0) # Last 12 months through this month
|
|
230
|
+
"""
|
|
231
|
+
if end is None:
|
|
232
|
+
end = start
|
|
233
|
+
|
|
234
|
+
if start > end:
|
|
235
|
+
raise ValueError(f"start ({start}) must not be greater than end ({end})")
|
|
236
|
+
|
|
237
|
+
target_time = self.dt_val
|
|
238
|
+
base_year = self.base_time.year
|
|
239
|
+
base_month = self.base_time.month
|
|
240
|
+
|
|
241
|
+
# Calculate the start month (earliest)
|
|
242
|
+
start_month = base_month + start
|
|
243
|
+
start_year = base_year
|
|
244
|
+
while start_month <= 0:
|
|
245
|
+
start_month += 12
|
|
246
|
+
start_year -= 1
|
|
247
|
+
while start_month > 12:
|
|
248
|
+
start_month -= 12
|
|
249
|
+
start_year += 1
|
|
250
|
+
|
|
251
|
+
# Calculate the end month (latest)
|
|
252
|
+
end_month = base_month + end
|
|
253
|
+
end_year = base_year
|
|
254
|
+
while end_month <= 0:
|
|
255
|
+
end_month += 12
|
|
256
|
+
end_year -= 1
|
|
257
|
+
while end_month > 12:
|
|
258
|
+
end_month -= 12
|
|
259
|
+
end_year += 1
|
|
260
|
+
|
|
261
|
+
# Convert months to a comparable format (year * 12 + month)
|
|
262
|
+
file_month_index = target_time.year * 12 + target_time.month
|
|
263
|
+
start_month_index = start_year * 12 + start_month
|
|
264
|
+
end_month_index = end_year * 12 + end_month
|
|
265
|
+
|
|
266
|
+
return start_month_index <= file_month_index <= end_month_index
|
|
267
|
+
|
|
268
|
+
def in_quarters(self, start: int = 0, end: int | None = None) -> bool:
|
|
269
|
+
"""
|
|
270
|
+
True if timestamp falls within the quarter window(s) from start to end.
|
|
271
|
+
|
|
272
|
+
Uses a half-open interval: start_tuple <= target_tuple < (end_tuple[0], end_tuple[1] + 1).
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
start: Quarters from now to start range (negative = past, 0 = this quarter, positive = future)
|
|
276
|
+
end: Quarters from now to end range (defaults to start for single quarter)
|
|
277
|
+
|
|
278
|
+
Examples:
|
|
279
|
+
zeit.cal.in_quarters(0) # This quarter (Q1: Jan-Mar, Q2: Apr-Jun, Q3: Jul-Sep, Q4: Oct-Dec)
|
|
280
|
+
zeit.cal.in_quarters(-1) # Last quarter
|
|
281
|
+
zeit.cal.in_quarters(-4, -1) # From 4 quarters ago through last quarter
|
|
282
|
+
zeit.cal.in_quarters(-8, 0) # Last 8 quarters through this quarter
|
|
283
|
+
"""
|
|
284
|
+
if end is None:
|
|
285
|
+
end = start
|
|
286
|
+
|
|
287
|
+
if start > end:
|
|
288
|
+
raise ValueError(f"start ({start}) must not be greater than end ({end})")
|
|
289
|
+
|
|
290
|
+
target_time = self.dt_val
|
|
291
|
+
base_time = self.base_time
|
|
292
|
+
|
|
293
|
+
# Get current quarter (1-4) and year
|
|
294
|
+
current_quarter = ((base_time.month - 1) // 3) + 1
|
|
295
|
+
current_year = base_time.year
|
|
296
|
+
|
|
297
|
+
def normalize_quarter_year(offset: int) -> tuple[int, int]:
|
|
298
|
+
total_quarters = (current_year * 4 + current_quarter + offset - 1)
|
|
299
|
+
year = total_quarters // 4
|
|
300
|
+
quarter = (total_quarters % 4) + 1
|
|
301
|
+
return year, quarter
|
|
302
|
+
|
|
303
|
+
start_year, start_quarter = normalize_quarter_year(start)
|
|
304
|
+
end_year, end_quarter = normalize_quarter_year(end)
|
|
305
|
+
|
|
306
|
+
# Get target's quarter
|
|
307
|
+
target_quarter = ((target_time.month - 1) // 3) + 1
|
|
308
|
+
target_year = target_time.year
|
|
309
|
+
|
|
310
|
+
# Use tuple comparison for (year, quarter)
|
|
311
|
+
target_tuple = (target_year, target_quarter)
|
|
312
|
+
start_tuple = (start_year, start_quarter)
|
|
313
|
+
end_tuple = (end_year, end_quarter)
|
|
314
|
+
|
|
315
|
+
# Check if target falls within the quarter range: start <= target < end
|
|
316
|
+
return start_tuple <= target_tuple < (end_tuple[0], end_tuple[1] + 1)
|
|
317
|
+
|
|
318
|
+
def in_years(self, start: int = 0, end: int | None = None) -> bool:
|
|
319
|
+
"""True if timestamp falls within the year window(s) from start to end.
|
|
320
|
+
|
|
321
|
+
Args:
|
|
322
|
+
start: Years from now to start range (negative = past, 0 = this year, positive = future)
|
|
323
|
+
end: Years from now to end range (defaults to start for single year)
|
|
324
|
+
|
|
325
|
+
Examples:
|
|
326
|
+
zeit.cal.in_years(0) # This year
|
|
327
|
+
zeit.cal.in_years(-1) # Last year only
|
|
328
|
+
zeit.cal.in_years(-5, -1) # From 5 years ago through last year
|
|
329
|
+
zeit.cal.in_years(-10, 0) # Last 10 years through this year
|
|
330
|
+
"""
|
|
331
|
+
if end is None:
|
|
332
|
+
end = start
|
|
333
|
+
|
|
334
|
+
if start > end:
|
|
335
|
+
raise ValueError(f"start ({start}) must not be greater than end ({end})")
|
|
336
|
+
|
|
337
|
+
target_year = self.dt_val.year
|
|
338
|
+
base_year = self.base_time.year
|
|
339
|
+
|
|
340
|
+
# Calculate year range boundaries
|
|
341
|
+
start_year = base_year + start
|
|
342
|
+
end_year = base_year + end
|
|
343
|
+
|
|
344
|
+
return start_year <= target_year <= end_year
|
|
345
|
+
|
|
346
|
+
def in_weeks(
|
|
347
|
+
self, start: int = 0, end: int | None = None, week_start: str = "monday"
|
|
348
|
+
) -> bool:
|
|
349
|
+
"""True if timestamp falls within the week window(s) from start to end.
|
|
350
|
+
|
|
351
|
+
Args:
|
|
352
|
+
start: Weeks from now to start range (negative = past, 0 = current week, positive = future)
|
|
353
|
+
end: Weeks from now to end range (defaults to start for single week)
|
|
354
|
+
week_start: Week start day (default: 'monday' for ISO weeks)
|
|
355
|
+
- 'monday'/'mon'/'mo' (ISO 8601 default)
|
|
356
|
+
- 'sunday'/'sun'/'su' (US convention)
|
|
357
|
+
- Supports full names, abbreviations, pandas style ('w-mon')
|
|
358
|
+
- Case insensitive
|
|
359
|
+
|
|
360
|
+
Examples:
|
|
361
|
+
zeit.cal.in_weeks(0) # This week (Monday start)
|
|
362
|
+
zeit.cal.in_weeks(-1, week_start='sun') # Last week (Sunday start)
|
|
363
|
+
zeit.cal.in_weeks(-4, 0) # Last 4 weeks through this week
|
|
364
|
+
zeit.cal.in_weeks(-2, -1, 'sunday') # 2-1 weeks ago (Sunday weeks)
|
|
365
|
+
"""
|
|
366
|
+
if end is None:
|
|
367
|
+
end = start
|
|
368
|
+
|
|
369
|
+
if start > end:
|
|
370
|
+
raise ValueError(f"start ({start}) must not be greater than end ({end})")
|
|
371
|
+
|
|
372
|
+
# Normalize the week start day
|
|
373
|
+
week_start_day = normalize_weekday(week_start)
|
|
374
|
+
|
|
375
|
+
target_date = self.dt_val.date()
|
|
376
|
+
base_date = self.base_time.date()
|
|
377
|
+
|
|
378
|
+
# Calculate the start of the current week based on week_start_day
|
|
379
|
+
days_since_week_start = (base_date.weekday() - week_start_day) % 7
|
|
380
|
+
current_week_start = base_date - dt.timedelta(days=days_since_week_start)
|
|
381
|
+
|
|
382
|
+
# Calculate week boundaries
|
|
383
|
+
start_week_start = current_week_start + dt.timedelta(weeks=start)
|
|
384
|
+
end_week_start = current_week_start + dt.timedelta(weeks=end)
|
|
385
|
+
end_week_end = end_week_start + dt.timedelta(
|
|
386
|
+
days=6
|
|
387
|
+
) # End of week (6 days after start)
|
|
388
|
+
|
|
389
|
+
return start_week_start <= target_date <= end_week_end
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def in_fiscal_quarters(self, start: int = 0, end: int | None = None) -> bool:
|
|
393
|
+
"""
|
|
394
|
+
True if timestamp falls within the fiscal quarter window(s) from start to end.
|
|
395
|
+
|
|
396
|
+
Uses a half-open interval: start_tuple <= target_tuple < (end_tuple[0], end_tuple[1] + 1).
|
|
397
|
+
|
|
398
|
+
Args:
|
|
399
|
+
start: Fiscal quarters from now to start range (negative = past, 0 = this fiscal quarter, positive = future)
|
|
400
|
+
end: Fiscal quarters from now to end range (defaults to start for single fiscal quarter)
|
|
401
|
+
|
|
402
|
+
Examples:
|
|
403
|
+
zeit.cal.in_fiscal_quarters(0) # This fiscal quarter
|
|
404
|
+
zeit.cal.in_fiscal_quarters(-1) # Last fiscal quarter
|
|
405
|
+
zeit.cal.in_fiscal_quarters(-4, -1) # From 4 fiscal quarters ago through last fiscal quarter
|
|
406
|
+
zeit.cal.in_fiscal_quarters(-8, 0) # Last 8 fiscal quarters through this fiscal quarter
|
|
407
|
+
"""
|
|
408
|
+
if end is None:
|
|
409
|
+
end = start
|
|
410
|
+
|
|
411
|
+
if start > end:
|
|
412
|
+
raise ValueError(f"start ({start}) must not be greater than end ({end})")
|
|
413
|
+
|
|
414
|
+
base_time = self.base_time
|
|
415
|
+
fy_start_month = self.fy_start_month
|
|
416
|
+
|
|
417
|
+
fy = Cal.get_fiscal_year(base_time, fy_start_month)
|
|
418
|
+
fq = Cal.get_fiscal_quarter(base_time, fy_start_month)
|
|
419
|
+
|
|
420
|
+
def normalize_fiscal_quarter_year(offset: int) -> tuple[int, int]:
|
|
421
|
+
total_quarters = (fy * 4 + fq + offset - 1)
|
|
422
|
+
year = total_quarters // 4
|
|
423
|
+
quarter = (total_quarters % 4) + 1
|
|
424
|
+
return year, quarter
|
|
425
|
+
|
|
426
|
+
start_year, start_quarter = normalize_fiscal_quarter_year(start)
|
|
427
|
+
end_year, end_quarter = normalize_fiscal_quarter_year(end)
|
|
428
|
+
|
|
429
|
+
target_fy = Cal.get_fiscal_year(self.dt_val, fy_start_month)
|
|
430
|
+
target_fq = Cal.get_fiscal_quarter(self.dt_val, fy_start_month)
|
|
431
|
+
|
|
432
|
+
target_tuple = (target_fy, target_fq)
|
|
433
|
+
start_tuple = (start_year, start_quarter)
|
|
434
|
+
end_tuple = (end_year, end_quarter)
|
|
435
|
+
|
|
436
|
+
return start_tuple <= target_tuple < (end_tuple[0], end_tuple[1] + 1)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def in_fiscal_years(self, start: int = 0, end: int | None = None) -> bool:
|
|
440
|
+
"""
|
|
441
|
+
True if timestamp falls within the fiscal year window(s) from start to end.
|
|
442
|
+
|
|
443
|
+
Uses a half-open interval: start_year <= target_year < end_year + 1.
|
|
444
|
+
|
|
445
|
+
Args:
|
|
446
|
+
start: Fiscal years from now to start range (negative = past, 0 = this fiscal year, positive = future)
|
|
447
|
+
end: Fiscal years from now to end range (defaults to start for single fiscal year)
|
|
448
|
+
|
|
449
|
+
Examples:
|
|
450
|
+
zeit.cal.in_fiscal_years(0) # This fiscal year
|
|
451
|
+
zeit.cal.in_fiscal_years(-1) # Last fiscal year
|
|
452
|
+
zeit.cal.in_fiscal_years(-5, -1) # From 5 fiscal years ago through last fiscal year
|
|
453
|
+
zeit.cal.in_fiscal_years(-10, 0) # Last 10 fiscal years through this fiscal year
|
|
454
|
+
"""
|
|
455
|
+
if end is None:
|
|
456
|
+
end = start
|
|
457
|
+
|
|
458
|
+
if start > end:
|
|
459
|
+
raise ValueError(f"start ({start}) must not be greater than end ({end})")
|
|
460
|
+
|
|
461
|
+
base_time = self.base_time
|
|
462
|
+
fy_start_month = self.fy_start_month
|
|
463
|
+
|
|
464
|
+
fy = Cal.get_fiscal_year(base_time, fy_start_month)
|
|
465
|
+
start_year = fy + start
|
|
466
|
+
end_year = fy + end
|
|
467
|
+
|
|
468
|
+
target_fy = Cal.get_fiscal_year(self.dt_val, fy_start_month)
|
|
469
|
+
|
|
470
|
+
return start_year <= target_fy < end_year + 1
|
|
471
|
+
@staticmethod
|
|
472
|
+
def get_fiscal_year(dt: dt.datetime, fy_start_month: int) -> int:
|
|
473
|
+
"""Return the fiscal year for a given datetime and fiscal year start month."""
|
|
474
|
+
return dt.year if dt.month >= fy_start_month else dt.year - 1
|
|
475
|
+
|
|
476
|
+
@staticmethod
|
|
477
|
+
def get_fiscal_quarter(dt: dt.datetime, fy_start_month: int) -> int:
|
|
478
|
+
"""Return the fiscal quarter for a given datetime and fiscal year start month."""
|
|
479
|
+
offset = (dt.month - fy_start_month) % 12 if dt.month >= fy_start_month else (dt.month + 12 - fy_start_month) % 12
|
|
480
|
+
return (offset // 3) + 1
|
frist/_constants.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
|
|
2
|
+
"""
|
|
3
|
+
Constants used throughout the Frist package.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Dict, Final
|
|
7
|
+
|
|
8
|
+
# Time conversion constants
|
|
9
|
+
SECONDS_PER_MINUTE: Final[int] = 60
|
|
10
|
+
SECONDS_PER_HOUR: Final[int] = 3600
|
|
11
|
+
SECONDS_PER_DAY: Final[int] = 86400
|
|
12
|
+
SECONDS_PER_WEEK: Final[int] = 604800 # 7 * 24 * 60 * 60
|
|
13
|
+
|
|
14
|
+
# Advanced time constants for age calculations
|
|
15
|
+
DAYS_PER_MONTH: Final[float] = 30.44 # Average days per month
|
|
16
|
+
DAYS_PER_YEAR: Final[float] = 365.25 # Average days per year (accounting for leap years)
|
|
17
|
+
SECONDS_PER_MONTH: Final[int] = int(DAYS_PER_MONTH * SECONDS_PER_DAY) # 2630016
|
|
18
|
+
SECONDS_PER_YEAR: Final[int] = int(DAYS_PER_YEAR * SECONDS_PER_DAY) # 31557600
|
|
19
|
+
|
|
20
|
+
# Default fallback timestamp (1 day after Unix epoch to avoid timezone issues)
|
|
21
|
+
DEFAULT_FALLBACK_TIMESTAMP: Final[int] = SECONDS_PER_DAY
|
|
22
|
+
|
|
23
|
+
# Calendar constants
|
|
24
|
+
DAYS_PER_WEEK: Final[int] = 7
|
|
25
|
+
MONTHS_PER_YEAR: Final[int] = 12
|
|
26
|
+
|
|
27
|
+
# Unified weekday mapping: all supported names/abbreviations to weekday index
|
|
28
|
+
WEEKDAY_INDEX: Final[Dict[str, int]] = {
|
|
29
|
+
"monday": 0, "mon": 0, "mo": 0,
|
|
30
|
+
"tuesday": 1, "tue": 1, "tu": 1,
|
|
31
|
+
"wednesday": 2, "wed": 2, "we": 2,
|
|
32
|
+
"thursday": 3, "thu": 3, "th": 3,
|
|
33
|
+
"friday": 4, "fri": 4, "fr": 4,
|
|
34
|
+
"saturday": 5, "sat": 5, "sa": 5,
|
|
35
|
+
"sunday": 6, "sun": 6, "su": 6,
|
|
36
|
+
}
|
|
37
|
+
__all__ = [
|
|
38
|
+
"SECONDS_PER_MINUTE",
|
|
39
|
+
"SECONDS_PER_HOUR",
|
|
40
|
+
"SECONDS_PER_DAY",
|
|
41
|
+
"SECONDS_PER_WEEK",
|
|
42
|
+
"DAYS_PER_MONTH",
|
|
43
|
+
"DAYS_PER_YEAR",
|
|
44
|
+
"SECONDS_PER_MONTH",
|
|
45
|
+
"SECONDS_PER_YEAR",
|
|
46
|
+
"DEFAULT_FALLBACK_TIMESTAMP",
|
|
47
|
+
"DAYS_PER_WEEK",
|
|
48
|
+
"MONTHS_PER_YEAR",
|
|
49
|
+
"WEEKDAY_INDEX",
|
|
50
|
+
]
|
frist/_frist.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Frist - Comprehensive datetime utility class.
|
|
3
|
+
|
|
4
|
+
Handles age calculations, calendar windows, and datetime parsing for any datetime operations.
|
|
5
|
+
Designed to be reusable beyond file operations.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import datetime as dt
|
|
9
|
+
|
|
10
|
+
from ._age import Age
|
|
11
|
+
from ._cal import Cal
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Frist:
|
|
15
|
+
"""
|
|
16
|
+
Comprehensive datetime utility with age and window calculations.
|
|
17
|
+
|
|
18
|
+
Provides age calculations, calendar windows, and datetime parsing that can be used
|
|
19
|
+
for any datetime operations, not just file timestamps.
|
|
20
|
+
|
|
21
|
+
Examples:
|
|
22
|
+
# Standalone datetime operations
|
|
23
|
+
meeting = Frist(datetime(2024, 12, 1, 14, 0))
|
|
24
|
+
if meeting.age.hours < 2:
|
|
25
|
+
print("Meeting was recent")
|
|
26
|
+
|
|
27
|
+
# Custom reference time
|
|
28
|
+
project = Frist(start_date, reference_time=deadline)
|
|
29
|
+
if project.age.days > 30:
|
|
30
|
+
print("Project overdue")
|
|
31
|
+
|
|
32
|
+
# Calendar windows
|
|
33
|
+
if meeting.cal.in_days(0):
|
|
34
|
+
print("Meeting was today")
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
*,
|
|
40
|
+
target_time: dt.datetime,
|
|
41
|
+
reference_time: dt.datetime | None = None,
|
|
42
|
+
fy_start_month: int = 1,
|
|
43
|
+
holidays: set[str] | None = None,
|
|
44
|
+
):
|
|
45
|
+
"""
|
|
46
|
+
Initialize Frist with target and reference times.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
target_time: The datetime to analyze (e.g., file timestamp, meeting time)
|
|
50
|
+
reference_time: Reference time for calculations (defaults to now)
|
|
51
|
+
fy_start_month: Fiscal year start month (1=Jan, 2=Feb, ... 12=Dec)
|
|
52
|
+
holidays: Set of date strings (YYYY-MM-DD) that are holidays
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
ValueError: If fy_start_month is not between 1 and 12
|
|
56
|
+
"""
|
|
57
|
+
if not (1 <= fy_start_month <= 12):
|
|
58
|
+
raise ValueError(f"fy_start_month must be between 1 and 12, got {fy_start_month}")
|
|
59
|
+
if not isinstance(target_time, dt.datetime):
|
|
60
|
+
raise ValueError(f"target_time must be a datetime instance, got {type(target_time)}")
|
|
61
|
+
self.target_time:dt.datetime = target_time
|
|
62
|
+
self.reference_time :dt.datetime= reference_time or dt.datetime.now()
|
|
63
|
+
self.fy_start_month:int = fy_start_month
|
|
64
|
+
self.holidays :set[str]= holidays if holidays is not None else set()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def age(self) -> Age:
|
|
69
|
+
"""
|
|
70
|
+
Get age of target_time relative to reference_time.
|
|
71
|
+
|
|
72
|
+
Returns Age object with properties like .seconds, .minutes, .hours, .days, etc.
|
|
73
|
+
"""
|
|
74
|
+
# Convert datetime objects to timestamps for Age class
|
|
75
|
+
target_timestamp = self.target_time.timestamp()
|
|
76
|
+
|
|
77
|
+
# Age expects (path, timestamp, base_time) - we pass None for path since standalone
|
|
78
|
+
return Age(None, target_timestamp, self.reference_time) # type: ignore
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def cal(self):
|
|
82
|
+
|
|
83
|
+
"""
|
|
84
|
+
Get calendar window functionality for target_time.
|
|
85
|
+
|
|
86
|
+
Returns Cal object for checking if target_time falls within calendar windows.
|
|
87
|
+
"""
|
|
88
|
+
# Cal can work directly with Frist since we have .target_dt and .ref_dt properties
|
|
89
|
+
return Cal(self, fy_start_month=self.fy_start_month, holidays=self.holidays)
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def timestamp(self) -> float:
|
|
93
|
+
"""Get the raw timestamp for target_time."""
|
|
94
|
+
return self.target_time.timestamp()
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def target_dt(self) -> dt.datetime:
|
|
98
|
+
"""Get the target datetime for TimeSpan compatibility."""
|
|
99
|
+
return self.target_time
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def ref_dt(self) -> dt.datetime:
|
|
103
|
+
"""Get the reference datetime for TimeSpan compatibility."""
|
|
104
|
+
return self.reference_time
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@staticmethod
|
|
108
|
+
def parse(time_str: str, reference_time: dt.datetime | None = None):
|
|
109
|
+
"""
|
|
110
|
+
Parse a time string and return a Frist object.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
time_str: Time string to parse
|
|
114
|
+
reference_time: Optional reference time for age calculations
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
Frist object for the parsed time
|
|
118
|
+
|
|
119
|
+
Examples:
|
|
120
|
+
"2023-12-25" -> Frist for Dec 25, 2023
|
|
121
|
+
"2023-12-25 14:30" -> Frist for Dec 25, 2023 2:30 PM
|
|
122
|
+
"2023-12-25T14:30:00" -> ISO format datetime
|
|
123
|
+
"1640995200" -> Frist from Unix timestamp
|
|
124
|
+
"""
|
|
125
|
+
time_str = time_str.strip()
|
|
126
|
+
|
|
127
|
+
# Handle Unix timestamp (all digits)
|
|
128
|
+
if time_str.isdigit():
|
|
129
|
+
target_time = dt.datetime.fromtimestamp(float(time_str))
|
|
130
|
+
return Frist(target_time=target_time, reference_time=reference_time)
|
|
131
|
+
|
|
132
|
+
# Try common datetime formats
|
|
133
|
+
formats = [
|
|
134
|
+
"%Y-%m-%d", # 2023-12-25
|
|
135
|
+
"%Y-%m-%d %H:%M", # 2023-12-25 14:30
|
|
136
|
+
"%Y-%m-%d %H:%M:%S", # 2023-12-25 14:30:00
|
|
137
|
+
"%Y-%m-%dT%H:%M:%S", # 2023-12-25T14:30:00 (ISO)
|
|
138
|
+
"%Y-%m-%dT%H:%M:%SZ", # 2023-12-25T14:30:00Z (ISO with Z)
|
|
139
|
+
"%Y/%m/%d", # 2023/12/25
|
|
140
|
+
"%Y/%m/%d %H:%M", # 2023/12/25 14:30
|
|
141
|
+
"%m/%d/%Y", # 12/25/2023
|
|
142
|
+
"%m/%d/%Y %H:%M", # 12/25/2023 14:30
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
for fmt in formats:
|
|
146
|
+
try:
|
|
147
|
+
target_time = dt.datetime.strptime(time_str, fmt)
|
|
148
|
+
return Frist(target_time=target_time, reference_time=reference_time)
|
|
149
|
+
except ValueError:
|
|
150
|
+
continue
|
|
151
|
+
|
|
152
|
+
raise ValueError(f"Unable to parse time string: {time_str}")
|
|
153
|
+
|
|
154
|
+
def with_reference_time(self, reference_time: dt.datetime):
|
|
155
|
+
"""
|
|
156
|
+
Create a new Frist object with a different reference time.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
reference_time: New reference time for calculations
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
New Frist object with same target_time but different reference_time
|
|
163
|
+
"""
|
|
164
|
+
return Frist(target_time=self.target_time, reference_time=reference_time)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def __repr__(self) -> str:
|
|
168
|
+
"""String representation of Frist object."""
|
|
169
|
+
return f"Frist(target={self.target_time.isoformat()}, reference={self.reference_time.isoformat()})"
|
|
170
|
+
|
|
171
|
+
def __str__(self) -> str:
|
|
172
|
+
"""Human-readable string representation."""
|
|
173
|
+
return f"Frist for {self.target_time.strftime('%Y-%m-%d %H:%M:%S')}"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
__all__ = ["Frist"]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
frist/__init__.py,sha256=SwjHUWZ3fijtn2ccXhRn13fL8x7AHIZ4Jn-aT_cNhek,851
|
|
2
|
+
frist/_age.py,sha256=2ek2fQqrdQRJJ2Fe_-we7glzCYrqheDBeGGhRX6LfTM,3986
|
|
3
|
+
frist/_cal.py,sha256=V5JWxz4SuYZId7PDPF63Z5ChHkkSp_80dS9o4pN89k0,18918
|
|
4
|
+
frist/_constants.py,sha256=tfzI3STohpXsG58h-9He2ezUmSILauu_yBm7Gd3o3sE,1599
|
|
5
|
+
frist/_frist.py,sha256=GT03d1I9fZEyTlVLVuWWC8cYoFONi_ZpWfPptqHLj6Q,6240
|
|
6
|
+
frist-0.6.0.dist-info/METADATA,sha256=Enqfx3pSK6pYU-Bg2NXvaGPbq8G44n9_Jx7feH5T1qI,95
|
|
7
|
+
frist-0.6.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
8
|
+
frist-0.6.0.dist-info/top_level.txt,sha256=o1-mQrds1xg1-gralev0pv2G8GnD_Wwq4QPi6u2XfOA,6
|
|
9
|
+
frist-0.6.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
frist
|