Skip to content

Commit 2513952

Browse files
authored
DEVOPS-759 fix: handle timezone awareness in ScratchOrgConfig expiration logic (#11)
* DEVOPS-759 fix: handle timezone awareness in ScratchOrgConfig expiration logic * DEVOPS-759 fix: update _as_aware_utc method to accept both datetime and date types
1 parent 1650c1d commit 2513952

2 files changed

Lines changed: 35 additions & 10 deletions

File tree

cumulusci/core/config/scratch_org_config.py

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import json
33
import os
44
import tempfile
5-
from typing import List, NoReturn, Optional, TYPE_CHECKING
5+
from typing import List, NoReturn, Optional, TYPE_CHECKING, Union
66

77
import sarge
88

@@ -43,6 +43,17 @@ class ScratchOrgConfig(SfdxOrgConfig):
4343
org_pool_id: str
4444

4545
createable: bool = True
46+
@staticmethod
47+
def _as_aware_utc(dt: Union[datetime.datetime, datetime.date]) -> datetime.datetime:
48+
"""Normalize datetimes to aware UTC for safe comparisons."""
49+
if isinstance(dt, datetime.date) and not isinstance(dt, datetime.datetime):
50+
dt = datetime.datetime.combine(dt, datetime.time.min)
51+
52+
tzinfo = getattr(dt, "tzinfo", None)
53+
if tzinfo is None or tzinfo.utcoffset(dt) is None:
54+
return dt.replace(tzinfo=datetime.timezone.utc)
55+
56+
return dt.astimezone(datetime.timezone.utc)
4657

4758
@property
4859
def scratch_info(self):
@@ -68,23 +79,20 @@ def expired(self) -> bool:
6879
if not expires:
6980
return False
7081

71-
if expires.tzinfo is None:
72-
expires = expires.replace(tzinfo=datetime.timezone.utc)
73-
82+
expires = self._as_aware_utc(expires)
7483
now = datetime.datetime.now(datetime.timezone.utc)
7584
return expires < now
7685

7786
@property
7887
def expires(self) -> Optional[datetime.datetime]:
7988
if self.date_created:
80-
return self.date_created + datetime.timedelta(days=int(self.days))
89+
expires = self.date_created + datetime.timedelta(days=int(self.days))
90+
return self._as_aware_utc(expires)
8191

8292
@property
8393
def days_alive(self) -> Optional[int]:
8494
if self.date_created and not self.expired:
85-
created = self.date_created
86-
if created.tzinfo is None:
87-
created = created.replace(tzinfo=datetime.timezone.utc)
95+
created = self._as_aware_utc(self.date_created)
8896
delta = datetime.datetime.now(datetime.timezone.utc) - created
8997
return delta.days + 1
9098

cumulusci/core/config/tests/test_config_expensive.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import os
33
import shutil
44
import tempfile
5-
from datetime import datetime, timedelta, timezone
5+
from datetime import datetime, timedelta, timezone, tzinfo
66
from pathlib import Path
77
from types import SimpleNamespace
88
from unittest import mock
@@ -546,14 +546,31 @@ def test_expired(self, Command):
546546

547547
def test_expires(self, Command):
548548
config = ScratchOrgConfig({"days": 1}, "test")
549-
now = datetime.now()
549+
now = datetime.now(timezone.utc)
550550
config.date_created = now
551551
assert config.expires == now + timedelta(days=1)
552552

553553
config = ScratchOrgConfig({"days": 1}, "test")
554554
config.date_created = None
555555
assert config.expires is None
556556

557+
def test_expired_handles_tzinfo_without_offset(self, Command):
558+
class TzinfoWithoutOffset(tzinfo):
559+
def utcoffset(self, dt):
560+
return None
561+
562+
def dst(self, dt):
563+
return None
564+
565+
def tzname(self, dt):
566+
return "NoneOffset"
567+
568+
config = ScratchOrgConfig({"days": 1}, "test")
569+
config.date_created = datetime(2000, 1, 1, tzinfo=TzinfoWithoutOffset())
570+
571+
assert config.expired
572+
assert config.expires.tzinfo is not None
573+
557574
def test_days_alive(self, Command):
558575
config = ScratchOrgConfig({}, "test")
559576
config.date_created = datetime.now()

0 commit comments

Comments
 (0)