Skip to content

Commit 55be7bb

Browse files
authored
Merge pull request #256 from opsmill/dga-20250130-whenever
Replace pendulum with whenever
2 parents 1a6f3cd + a2a496f commit 55be7bb

File tree

14 files changed

+363
-181
lines changed

14 files changed

+363
-181
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ jobs:
126126
- "3.10"
127127
- "3.11"
128128
- "3.12"
129+
- "3.13"
129130
if: |
130131
always() && !cancelled() &&
131132
!contains(needs.*.result, 'failure') &&

changelog/+python313.fixed.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed support for Python 3.13, it's no longer required to have Rust installed on the system

changelog/251.fixed.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
Fix typing for Python 3.9 and remove support for Python 3.13
1+
Fix typing for Python 3.9

changelog/255.deprecated.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Timestamp: Direct access to `obj` and `add_delta` have been deprecated and will be removed in a future version.

changelog/255.fixed.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Refactor Timestamp to use `whenever` instead of `pendulum` and extend Timestamp with add(), subtract(), and to_datetime().

infrahub_sdk/ctl/branch.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@
55
from rich.table import Table
66

77
from ..async_typer import AsyncTyper
8-
from ..ctl.client import initialize_client
9-
from ..ctl.utils import calculate_time_diff, catch_exception
8+
from ..utils import calculate_time_diff
9+
from .client import initialize_client
1010
from .parameters import CONFIG_PARAM
11+
from .utils import catch_exception
1112

1213
app = AsyncTyper()
1314
console = Console()

infrahub_sdk/ctl/utils.py

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,9 @@
88
from pathlib import Path
99
from typing import TYPE_CHECKING, Any, Callable, NoReturn, Optional, TypeVar
1010

11-
import pendulum
1211
import typer
1312
from click.exceptions import Exit
1413
from httpx import HTTPError
15-
from pendulum.datetime import DateTime
1614
from rich.console import Console
1715
from rich.logging import RichHandler
1816
from rich.markup import escape
@@ -152,20 +150,6 @@ def parse_cli_vars(variables: Optional[list[str]]) -> dict[str, str]:
152150
return {var.split("=")[0]: var.split("=")[1] for var in variables if "=" in var}
153151

154152

155-
def calculate_time_diff(value: str) -> str | None:
156-
"""Calculate the time in human format between a timedate in string format and now."""
157-
try:
158-
time_value = pendulum.parse(value)
159-
except pendulum.parsing.exceptions.ParserError:
160-
return None
161-
162-
if not isinstance(time_value, DateTime):
163-
return None
164-
165-
pendulum.set_locale("en")
166-
return time_value.diff_for_humans(other=pendulum.now(), absolute=True)
167-
168-
169153
def find_graphql_query(name: str, directory: str | Path = ".") -> str:
170154
if isinstance(directory, str):
171155
directory = Path(directory)

infrahub_sdk/exceptions.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,3 +137,9 @@ class FileNotValidError(Error):
137137
def __init__(self, name: str, message: str = ""):
138138
self.message = message or f"Cannot parse '{name}' content."
139139
super().__init__(self.message)
140+
141+
142+
class TimestampFormatError(Error):
143+
def __init__(self, message: str | None = None):
144+
self.message = message or "Invalid timestamp format"
145+
super().__init__(self.message)

infrahub_sdk/timestamp.py

Lines changed: 134 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
from __future__ import annotations
22

33
import re
4+
import warnings
5+
from datetime import datetime, timezone
6+
from typing import Literal
47

5-
import pendulum
6-
from pendulum.datetime import DateTime
8+
from whenever import Date, Instant, LocalDateTime, Time, ZonedDateTime
9+
10+
from .exceptions import TimestampFormatError
11+
12+
UTC = timezone.utc # Required for older versions of Python
713

814
REGEX_MAPPING = {
915
"seconds": r"(\d+)(s|sec|second|seconds)",
@@ -12,80 +18,175 @@
1218
}
1319

1420

15-
class TimestampFormatError(ValueError): ...
16-
17-
1821
class Timestamp:
19-
def __init__(self, value: str | DateTime | Timestamp | None = None):
20-
if value and isinstance(value, DateTime):
21-
self.obj = value
22+
_obj: ZonedDateTime
23+
24+
def __init__(self, value: str | ZonedDateTime | Timestamp | None = None):
25+
if value and isinstance(value, ZonedDateTime):
26+
self._obj = value
2227
elif value and isinstance(value, self.__class__):
23-
self.obj = value.obj
28+
self._obj = value._obj
2429
elif isinstance(value, str):
25-
self.obj = self._parse_string(value)
30+
self._obj = self._parse_string(value)
2631
else:
27-
self.obj = DateTime.now(tz="UTC")
32+
self._obj = ZonedDateTime.now("UTC").round(unit="microsecond")
33+
34+
@property
35+
def obj(self) -> ZonedDateTime:
36+
warnings.warn(
37+
"Direct access to obj property is deprecated. Use to_string(), to_timestamp(), or to_datetime() instead.",
38+
UserWarning,
39+
stacklevel=2,
40+
)
41+
return self._obj
2842

2943
@classmethod
30-
def _parse_string(cls, value: str) -> DateTime:
44+
def _parse_string(cls, value: str) -> ZonedDateTime:
45+
try:
46+
zoned_date = ZonedDateTime.parse_common_iso(value)
47+
return zoned_date
48+
except ValueError:
49+
pass
50+
3151
try:
32-
parsed_date = pendulum.parse(value)
33-
if isinstance(parsed_date, DateTime):
34-
return parsed_date
35-
except (pendulum.parsing.exceptions.ParserError, ValueError):
52+
instant_date = Instant.parse_common_iso(value)
53+
return instant_date.to_tz("UTC")
54+
except ValueError:
3655
pass
3756

38-
params = {}
57+
try:
58+
local_date_time = LocalDateTime.parse_common_iso(value)
59+
return local_date_time.assume_utc().to_tz("UTC")
60+
except ValueError:
61+
pass
62+
63+
try:
64+
date = Date.parse_common_iso(value)
65+
local_date = date.at(Time(12, 00))
66+
return local_date.assume_tz("UTC", disambiguate="compatible")
67+
except ValueError:
68+
pass
69+
70+
params: dict[str, float] = {}
3971
for key, regex in REGEX_MAPPING.items():
4072
match = re.search(regex, value)
4173
if match:
42-
params[key] = int(match.group(1))
74+
params[key] = float(match.group(1))
4375

44-
if not params:
45-
raise TimestampFormatError(f"Invalid time format for {value}")
76+
if params:
77+
return ZonedDateTime.now("UTC").subtract(**params) # type: ignore[call-overload]
4678

47-
return DateTime.now(tz="UTC").subtract(**params)
79+
raise TimestampFormatError(f"Invalid time format for {value}")
4880

4981
def __repr__(self) -> str:
5082
return f"Timestamp: {self.to_string()}"
5183

5284
def to_string(self, with_z: bool = True) -> str:
53-
iso8601_string = self.obj.to_iso8601_string()
54-
if not with_z and iso8601_string[-1] == "Z":
55-
iso8601_string = iso8601_string[:-1] + "+00:00"
56-
return iso8601_string
85+
if with_z:
86+
return self._obj.instant().format_common_iso()
87+
return self.to_datetime().isoformat()
5788

5889
def to_timestamp(self) -> int:
59-
return self.obj.int_timestamp
90+
return self._obj.timestamp()
91+
92+
def to_datetime(self) -> datetime:
93+
return self._obj.py_datetime()
94+
95+
def get_obj(self) -> ZonedDateTime:
96+
return self._obj
6097

6198
def __eq__(self, other: object) -> bool:
6299
if not isinstance(other, Timestamp):
63100
return NotImplemented
64-
return self.obj == other.obj
101+
return self._obj == other._obj
65102

66103
def __lt__(self, other: object) -> bool:
67104
if not isinstance(other, Timestamp):
68105
return NotImplemented
69-
return self.obj < other.obj
106+
return self._obj < other._obj
70107

71108
def __gt__(self, other: object) -> bool:
72109
if not isinstance(other, Timestamp):
73110
return NotImplemented
74-
return self.obj > other.obj
111+
return self._obj > other._obj
75112

76113
def __le__(self, other: object) -> bool:
77114
if not isinstance(other, Timestamp):
78115
return NotImplemented
79-
return self.obj <= other.obj
116+
return self._obj <= other._obj
80117

81118
def __ge__(self, other: object) -> bool:
82119
if not isinstance(other, Timestamp):
83120
return NotImplemented
84-
return self.obj >= other.obj
121+
return self._obj >= other._obj
85122

86123
def __hash__(self) -> int:
87124
return hash(self.to_string())
88125

89126
def add_delta(self, hours: int = 0, minutes: int = 0, seconds: int = 0, microseconds: int = 0) -> Timestamp:
90-
time = self.obj.add(hours=hours, minutes=minutes, seconds=seconds, microseconds=microseconds)
91-
return Timestamp(time)
127+
warnings.warn(
128+
"add_delta() is deprecated. Use add() instead.",
129+
UserWarning,
130+
stacklevel=2,
131+
)
132+
return self.add(hours=hours, minutes=minutes, seconds=seconds, microseconds=microseconds)
133+
134+
def add(
135+
self,
136+
years: int = 0,
137+
months: int = 0,
138+
weeks: int = 0,
139+
days: int = 0,
140+
hours: float = 0,
141+
minutes: float = 0,
142+
seconds: float = 0,
143+
milliseconds: float = 0,
144+
microseconds: float = 0,
145+
nanoseconds: int = 0,
146+
disambiguate: Literal["compatible"] = "compatible",
147+
) -> Timestamp:
148+
return Timestamp(
149+
self._obj.add(
150+
years=years,
151+
months=months,
152+
weeks=weeks,
153+
days=days,
154+
hours=hours,
155+
minutes=minutes,
156+
seconds=seconds,
157+
milliseconds=milliseconds,
158+
microseconds=microseconds,
159+
nanoseconds=nanoseconds,
160+
disambiguate=disambiguate,
161+
)
162+
)
163+
164+
def subtract(
165+
self,
166+
years: int = 0,
167+
months: int = 0,
168+
weeks: int = 0,
169+
days: int = 0,
170+
hours: float = 0,
171+
minutes: float = 0,
172+
seconds: float = 0,
173+
milliseconds: float = 0,
174+
microseconds: float = 0,
175+
nanoseconds: int = 0,
176+
disambiguate: Literal["compatible"] = "compatible",
177+
) -> Timestamp:
178+
return Timestamp(
179+
self._obj.subtract(
180+
years=years,
181+
months=months,
182+
weeks=weeks,
183+
days=days,
184+
hours=hours,
185+
minutes=minutes,
186+
seconds=seconds,
187+
milliseconds=milliseconds,
188+
microseconds=microseconds,
189+
nanoseconds=nanoseconds,
190+
disambiguate=disambiguate,
191+
)
192+
)

infrahub_sdk/utils.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,12 @@
1717

1818
from infrahub_sdk.repository import GitRepoManager
1919

20-
from .exceptions import FileNotValidError, JsonDecodeError
20+
from .exceptions import FileNotValidError, JsonDecodeError, TimestampFormatError
21+
from .timestamp import Timestamp
2122

2223
if TYPE_CHECKING:
2324
from graphql import GraphQLResolveInfo
25+
from whenever import TimeDelta
2426

2527

2628
def base36encode(number: int) -> str:
@@ -367,3 +369,29 @@ def get_user_permissions(data: list[dict]) -> dict:
367369
groups[group_name] = permissions
368370

369371
return groups
372+
373+
374+
def calculate_time_diff(value: str) -> str | None:
375+
"""Calculate the time in human format between a timedate in string format and now."""
376+
try:
377+
time_value = Timestamp(value)
378+
except TimestampFormatError:
379+
return None
380+
381+
delta: TimeDelta = Timestamp().get_obj().difference(time_value.get_obj())
382+
(hrs, mins, secs, nanos) = delta.in_hrs_mins_secs_nanos()
383+
384+
if nanos and nanos > 500_000_000:
385+
secs += 1
386+
387+
if hrs and hrs < 24 and mins:
388+
return f"{hrs}h {mins}m and {secs}s ago"
389+
if hrs and hrs > 24:
390+
remaining_hrs = hrs % 24
391+
days = int((hrs - remaining_hrs) / 24)
392+
return f"{days}d and {remaining_hrs}h ago"
393+
if hrs == 0 and mins and secs:
394+
return f"{mins}m and {secs}s ago"
395+
if hrs == 0 and mins == 0 and secs:
396+
return f"{secs}s ago"
397+
return "now"

0 commit comments

Comments
 (0)