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 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,5 @@
1
+ Metadata-Version: 2.4
2
+ Name: frist
3
+ Version: 0.6.0
4
+ Summary: Test Package
5
+ Author: Chuck Bass
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ frist