Skip to content

Commit 631592e

Browse files
Feat: Add helper function for replace timezone (#428)
* feat: add small helper function for replacing timezone * refactor: use helper function for timezone replacement * refactor: use helper function in fip mapper as well * test: update tests * fix linting * more linting fixes * refactor: use more focused function * test: update tests * fix linting
1 parent f4c744f commit 631592e

4 files changed

Lines changed: 62 additions & 8 deletions

File tree

src/aind_metadata_mapper/fip/mapper.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
get_intended_measurements,
5858
get_procedures,
5959
get_protocols_for_modality,
60+
normalize_utc_timezone,
6061
)
6162

6263
logger = logging.getLogger(__name__)
@@ -302,8 +303,10 @@ def transform(
302303

303304
# Get timing from all data streams (handle multiple epochs)
304305
# Find earliest start_time and latest end_time across all epochs
305-
start_times = [ensure_timezone(ds["start_time"]) for ds in data_streams]
306-
end_times = [ensure_timezone(ds["end_time"]) for ds in data_streams]
306+
start_times = [
307+
ensure_timezone(normalize_utc_timezone(ds["start_time"])) for ds in data_streams
308+
]
309+
end_times = [ensure_timezone(normalize_utc_timezone(ds["end_time"])) for ds in data_streams]
307310

308311
earliest_start = min(start_times)
309312
latest_end = max(end_times)

src/aind_metadata_mapper/gather_metadata.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
from aind_metadata_mapper.base import MapperJobSettings
2626
from aind_metadata_mapper.mapper_registry import registry
2727
from aind_metadata_mapper.models import JobSettings
28-
from aind_metadata_mapper.utils import get_procedures, get_subject, metadata_service_helper
28+
from aind_metadata_mapper.utils import get_procedures, get_subject, metadata_service_helper, normalize_utc_timezone
2929

3030

3131
class GatherMetadataJob:
@@ -184,7 +184,9 @@ def build_data_description(self, acquisition_start_time: str, subject_id: str) -
184184
logging.debug(f"Using existing {file_name}.")
185185
return self._get_file_from_user_defined_directory(file_name=file_name)
186186

187-
acquisition_start_time.replace("Z", "+00:00") # remove when we're past Python 3.11
187+
acquisition_start_time = normalize_utc_timezone(
188+
acquisition_start_time
189+
) # remove when we're past Python 3.11
188190
creation_time = datetime.fromisoformat(acquisition_start_time)
189191
logging.info(f"Using acquisition start time: {creation_time}")
190192

@@ -511,7 +513,9 @@ def _validate_acquisition_start_time(self, acquisition_start_time: str) -> str:
511513
ValueError
512514
If acquisition_start_time doesn't match settings and raise_if_invalid is True
513515
"""
514-
acquisition_start_time = acquisition_start_time.replace("Z", "+00:00") # remove when we're past Python 3.11
516+
acquisition_start_time = normalize_utc_timezone(
517+
acquisition_start_time
518+
) # remove when we're past Python 3.11
515519
local_acq_start_time = datetime.fromisoformat(acquisition_start_time)
516520

517521
if self.settings.acquisition_start_time and local_acq_start_time != self.settings.acquisition_start_time:

src/aind_metadata_mapper/utils.py

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,36 @@
2020
SUBJECT_BASE_URL = "http://aind-metadata-service/api/v2/subject"
2121

2222

23+
def normalize_utc_timezone(dt: str) -> str:
24+
"""
25+
Normalize UTC timezone indicator from 'Z' to '+00:00' offset format.
26+
27+
This ensures compatibility with Python 3.10's datetime.fromisoformat(),
28+
which doesn't support the 'Z' shorthand for UTC.
29+
30+
Parameters
31+
----------
32+
dt : str
33+
An ISO 8601 datetime string, potentially ending with 'Z'.
34+
35+
Returns
36+
-------
37+
str
38+
The datetime string with 'Z' replaced by '+00:00' if present,
39+
otherwise unchanged.
40+
41+
Examples
42+
--------
43+
>>> normalize_utc_timezone("2025-11-16T23:00:22Z")
44+
'2025-11-16T23:00:22+00:00'
45+
>>> normalize_utc_timezone("2025-11-16T23:00:22-05:00")
46+
'2025-11-16T23:00:22-05:00'
47+
"""
48+
if dt.endswith("Z"):
49+
return dt[:-1] + "+00:00"
50+
return dt
51+
52+
2353
def ensure_timezone(dt):
2454
"""Ensure datetime has timezone info using system local timezone.
2555
@@ -126,7 +156,8 @@ def get_procedures(subject_id: str, base_url: str = PROCEDURES_BASE_URL) -> Opti
126156

127157

128158
def get_intended_measurements(
129-
subject_id: str, base_url: str = "http://aind-metadata-service/intended_measurements"
159+
subject_id: str,
160+
base_url: str = "http://aind-metadata-service/intended_measurements",
130161
) -> Optional[dict]:
131162
"""Fetch intended measurements for a subject from the metadata service.
132163
@@ -245,7 +276,10 @@ def get_instrument(
245276
)
246277
return None
247278
else:
248-
return sorted(matching_records, key=lambda record: record["modification_date"])[-1]
279+
return sorted(
280+
matching_records,
281+
key=lambda record: record["modification_date"],
282+
)[-1]
249283
except Exception as e:
250284
logger.warning(f"Unexpected error fetching instrument {instrument_id}: {e}")
251285
return None
@@ -319,7 +353,11 @@ def check_existing_instrument(
319353
bool
320354
True if a record with the same instrument_id and modification_date exists.
321355
"""
322-
existing = get_instrument(instrument_id, modification_date=modification_date, suppress_warning=True)
356+
existing = get_instrument(
357+
instrument_id,
358+
modification_date=modification_date,
359+
suppress_warning=True,
360+
)
323361
return existing is not None
324362

325363

tests/test_utils.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,21 @@
2323
get_protocols_for_modality,
2424
get_subject,
2525
prompt_for_string,
26+
normalize_utc_timezone,
2627
)
2728

2829

2930
class TestUtils(unittest.TestCase):
3031
"""Test cases for utility functions in aind_metadata_mapper."""
3132

33+
def test_replace_z_with_offset(self):
34+
"""Test that a trailing 'Z' timezone shorthand is replaced with '+00:00'."""
35+
self.assertEqual(normalize_utc_timezone("2025-11-16T23:00:22Z"), "2025-11-16T23:00:22+00:00")
36+
37+
def test_no_replacement(self):
38+
"""Test that the original string is returned unchanged when 'old' is not found."""
39+
self.assertEqual(normalize_utc_timezone("2025-11-16T23:00:22"), "2025-11-16T23:00:22")
40+
3241
def test_ensure_timezone_none(self):
3342
"""Test that ensure_timezone handles None input by returning current time with timezone.
3443

0 commit comments

Comments
 (0)