Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Replace pendulum with whenever #256

Merged
merged 1 commit into from
Feb 26, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ jobs:
- "3.10"
- "3.11"
- "3.12"
- "3.13"
if: |
always() && !cancelled() &&
!contains(needs.*.result, 'failure') &&
Expand Down
1 change: 1 addition & 0 deletions changelog/+python313.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed support for Python 3.13, it's no longer required to have Rust installed on the system
2 changes: 1 addition & 1 deletion changelog/251.fixed.md
Original file line number Diff line number Diff line change
@@ -1 +1 @@
Fix typing for Python 3.9 and remove support for Python 3.13
Fix typing for Python 3.9
1 change: 1 addition & 0 deletions changelog/255.deprecated.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Timestamp: Direct access to `obj` and `add_delta` have been deprecated and will be removed in a future version.
1 change: 1 addition & 0 deletions changelog/255.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Refactor Timestamp to use `whenever` instead of `pendulum` and extend Timestamp with add(), subtract(), and to_datetime().
5 changes: 3 additions & 2 deletions infrahub_sdk/ctl/branch.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
from rich.table import Table

from ..async_typer import AsyncTyper
from ..ctl.client import initialize_client
from ..ctl.utils import calculate_time_diff, catch_exception
from ..utils import calculate_time_diff
from .client import initialize_client
from .parameters import CONFIG_PARAM
from .utils import catch_exception

app = AsyncTyper()
console = Console()
Expand Down
16 changes: 0 additions & 16 deletions infrahub_sdk/ctl/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,9 @@
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, NoReturn, Optional, TypeVar

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


def calculate_time_diff(value: str) -> str | None:
"""Calculate the time in human format between a timedate in string format and now."""
try:
time_value = pendulum.parse(value)
except pendulum.parsing.exceptions.ParserError:
return None

if not isinstance(time_value, DateTime):
return None

pendulum.set_locale("en")
return time_value.diff_for_humans(other=pendulum.now(), absolute=True)


def find_graphql_query(name: str, directory: str | Path = ".") -> str:
if isinstance(directory, str):
directory = Path(directory)
Expand Down
6 changes: 6 additions & 0 deletions infrahub_sdk/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,3 +137,9 @@ class FileNotValidError(Error):
def __init__(self, name: str, message: str = ""):
self.message = message or f"Cannot parse '{name}' content."
super().__init__(self.message)


class TimestampFormatError(Error):
def __init__(self, message: str | None = None):
self.message = message or "Invalid timestamp format"
super().__init__(self.message)
167 changes: 134 additions & 33 deletions infrahub_sdk/timestamp.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
from __future__ import annotations

import re
import warnings
from datetime import datetime, timezone
from typing import Literal

import pendulum
from pendulum.datetime import DateTime
from whenever import Date, Instant, LocalDateTime, Time, ZonedDateTime

from .exceptions import TimestampFormatError

UTC = timezone.utc # Required for older versions of Python

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


class TimestampFormatError(ValueError): ...


class Timestamp:
def __init__(self, value: str | DateTime | Timestamp | None = None):
if value and isinstance(value, DateTime):
self.obj = value
_obj: ZonedDateTime

def __init__(self, value: str | ZonedDateTime | Timestamp | None = None):
if value and isinstance(value, ZonedDateTime):
self._obj = value
elif value and isinstance(value, self.__class__):
self.obj = value.obj
self._obj = value._obj
elif isinstance(value, str):
self.obj = self._parse_string(value)
self._obj = self._parse_string(value)
else:
self.obj = DateTime.now(tz="UTC")
self._obj = ZonedDateTime.now("UTC").round(unit="microsecond")

@property
def obj(self) -> ZonedDateTime:
warnings.warn(

Check warning on line 36 in infrahub_sdk/timestamp.py

View check run for this annotation

Codecov / codecov/patch

infrahub_sdk/timestamp.py#L36

Added line #L36 was not covered by tests
"Direct access to obj property is deprecated. Use to_string(), to_timestamp(), or to_datetime() instead.",
UserWarning,
stacklevel=2,
)
return self._obj

Check warning on line 41 in infrahub_sdk/timestamp.py

View check run for this annotation

Codecov / codecov/patch

infrahub_sdk/timestamp.py#L41

Added line #L41 was not covered by tests
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd suggest creating a follow up issue for when this should be removed and tie it into a future milestone so we know when to remove it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will do


@classmethod
def _parse_string(cls, value: str) -> DateTime:
def _parse_string(cls, value: str) -> ZonedDateTime:
try:
zoned_date = ZonedDateTime.parse_common_iso(value)
return zoned_date

Check warning on line 47 in infrahub_sdk/timestamp.py

View check run for this annotation

Codecov / codecov/patch

infrahub_sdk/timestamp.py#L47

Added line #L47 was not covered by tests
except ValueError:
pass

try:
parsed_date = pendulum.parse(value)
if isinstance(parsed_date, DateTime):
return parsed_date
except (pendulum.parsing.exceptions.ParserError, ValueError):
instant_date = Instant.parse_common_iso(value)
return instant_date.to_tz("UTC")
except ValueError:
pass

params = {}
try:
local_date_time = LocalDateTime.parse_common_iso(value)
return local_date_time.assume_utc().to_tz("UTC")
except ValueError:
pass

try:
date = Date.parse_common_iso(value)
local_date = date.at(Time(12, 00))
return local_date.assume_tz("UTC", disambiguate="compatible")
except ValueError:
pass

params: dict[str, float] = {}
for key, regex in REGEX_MAPPING.items():
match = re.search(regex, value)
if match:
params[key] = int(match.group(1))
params[key] = float(match.group(1))

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

return DateTime.now(tz="UTC").subtract(**params)
raise TimestampFormatError(f"Invalid time format for {value}")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know it was like this before but I think the error definition should live in exceptions.py like the other core SDK exceptions.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good point, I moved it


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

def to_string(self, with_z: bool = True) -> str:
iso8601_string = self.obj.to_iso8601_string()
if not with_z and iso8601_string[-1] == "Z":
iso8601_string = iso8601_string[:-1] + "+00:00"
return iso8601_string
if with_z:
return self._obj.instant().format_common_iso()
return self.to_datetime().isoformat()

def to_timestamp(self) -> int:
return self.obj.int_timestamp
return self._obj.timestamp()

Check warning on line 90 in infrahub_sdk/timestamp.py

View check run for this annotation

Codecov / codecov/patch

infrahub_sdk/timestamp.py#L90

Added line #L90 was not covered by tests

def to_datetime(self) -> datetime:
return self._obj.py_datetime()

def get_obj(self) -> ZonedDateTime:
return self._obj

def __eq__(self, other: object) -> bool:
if not isinstance(other, Timestamp):
return NotImplemented
return self.obj == other.obj
return self._obj == other._obj

def __lt__(self, other: object) -> bool:
if not isinstance(other, Timestamp):
return NotImplemented
return self.obj < other.obj
return self._obj < other._obj

def __gt__(self, other: object) -> bool:
if not isinstance(other, Timestamp):
return NotImplemented
return self.obj > other.obj
return self._obj > other._obj

def __le__(self, other: object) -> bool:
if not isinstance(other, Timestamp):
return NotImplemented
return self.obj <= other.obj
return self._obj <= other._obj

def __ge__(self, other: object) -> bool:
if not isinstance(other, Timestamp):
return NotImplemented
return self.obj >= other.obj
return self._obj >= other._obj

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

def add_delta(self, hours: int = 0, minutes: int = 0, seconds: int = 0, microseconds: int = 0) -> Timestamp:
time = self.obj.add(hours=hours, minutes=minutes, seconds=seconds, microseconds=microseconds)
return Timestamp(time)
warnings.warn(

Check warning on line 127 in infrahub_sdk/timestamp.py

View check run for this annotation

Codecov / codecov/patch

infrahub_sdk/timestamp.py#L127

Added line #L127 was not covered by tests
"add_delta() is deprecated. Use add() instead.",
UserWarning,
stacklevel=2,
)
return self.add(hours=hours, minutes=minutes, seconds=seconds, microseconds=microseconds)

Check warning on line 132 in infrahub_sdk/timestamp.py

View check run for this annotation

Codecov / codecov/patch

infrahub_sdk/timestamp.py#L132

Added line #L132 was not covered by tests

def add(
self,
years: int = 0,
months: int = 0,
weeks: int = 0,
days: int = 0,
hours: float = 0,
minutes: float = 0,
seconds: float = 0,
milliseconds: float = 0,
microseconds: float = 0,
nanoseconds: int = 0,
disambiguate: Literal["compatible"] = "compatible",
) -> Timestamp:
return Timestamp(
self._obj.add(
years=years,
months=months,
weeks=weeks,
days=days,
hours=hours,
minutes=minutes,
seconds=seconds,
milliseconds=milliseconds,
microseconds=microseconds,
nanoseconds=nanoseconds,
disambiguate=disambiguate,
)
)

def subtract(
self,
years: int = 0,
months: int = 0,
weeks: int = 0,
days: int = 0,
hours: float = 0,
minutes: float = 0,
seconds: float = 0,
milliseconds: float = 0,
microseconds: float = 0,
nanoseconds: int = 0,
disambiguate: Literal["compatible"] = "compatible",
) -> Timestamp:
return Timestamp(
self._obj.subtract(
years=years,
months=months,
weeks=weeks,
days=days,
hours=hours,
minutes=minutes,
seconds=seconds,
milliseconds=milliseconds,
microseconds=microseconds,
nanoseconds=nanoseconds,
disambiguate=disambiguate,
)
)
30 changes: 29 additions & 1 deletion infrahub_sdk/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@

from infrahub_sdk.repository import GitRepoManager

from .exceptions import FileNotValidError, JsonDecodeError
from .exceptions import FileNotValidError, JsonDecodeError, TimestampFormatError
from .timestamp import Timestamp

if TYPE_CHECKING:
from graphql import GraphQLResolveInfo
from whenever import TimeDelta


def base36encode(number: int) -> str:
Expand Down Expand Up @@ -367,3 +369,29 @@
groups[group_name] = permissions

return groups


def calculate_time_diff(value: str) -> str | None:
"""Calculate the time in human format between a timedate in string format and now."""
try:
time_value = Timestamp(value)
except TimestampFormatError:
return None

Check warning on line 379 in infrahub_sdk/utils.py

View check run for this annotation

Codecov / codecov/patch

infrahub_sdk/utils.py#L378-L379

Added lines #L378 - L379 were not covered by tests

delta: TimeDelta = Timestamp().get_obj().difference(time_value.get_obj())
(hrs, mins, secs, nanos) = delta.in_hrs_mins_secs_nanos()

if nanos and nanos > 500_000_000:
secs += 1

if hrs and hrs < 24 and mins:
return f"{hrs}h {mins}m and {secs}s ago"
if hrs and hrs > 24:
remaining_hrs = hrs % 24
days = int((hrs - remaining_hrs) / 24)
return f"{days}d and {remaining_hrs}h ago"
if hrs == 0 and mins and secs:
return f"{mins}m and {secs}s ago"
if hrs == 0 and mins == 0 and secs:
return f"{secs}s ago"
return "now"
Loading