diff --git a/herbie/core.py b/herbie/core.py index d8ae9996..c7057f24 100644 --- a/herbie/core.py +++ b/herbie/core.py @@ -18,7 +18,7 @@ from datetime import datetime, timedelta from io import StringIO from shutil import which -from typing import Union, Optional, Literal +from typing import Literal, Optional, Union import cfgrib import pandas as pd @@ -31,6 +31,7 @@ from herbie import Path, config from herbie.help import _search_help from herbie.misc import ANSI +from herbie.toolbox import str_to_datetime Datetime = Union[datetime, pd.Timestamp, str] @@ -191,6 +192,12 @@ def __init__( self.fxx = pd.to_timedelta(fxx).round("1h").total_seconds() / 60 / 60 self.fxx = int(self.fxx) + # TODO: Do I really need this if I'm using Pandas? + # if isinstance(date, str): + # date = str_to_datetime(date) + # if isinstance(valid_date, str): + # valid_date = str_to_datetime(valid_date) + if date: # User supplied `date`, which is the model initialization datetime. self.date = pd.to_datetime(date) @@ -1146,13 +1153,13 @@ def xarray( use_pygrib = False if use_pygrib: with pygrib.open(str(local_file)) as grb: - msg = grb.message(1) - cf_params = CRS(msg.projparams).to_cf() + msg = grb.message(1) + cf_params = CRS(msg.projparams).to_cf() - #grb = pygrib.open(str(local_file)) - #msg = grb.message(1) - #cf_params = CRS(msg.projparams).to_cf() - #grb.close() + # grb = pygrib.open(str(local_file)) + # msg = grb.message(1) + # cf_params = CRS(msg.projparams).to_cf() + # grb.close() # Funny stuff with polar stereographic (https://github.com/pyproj4/pyproj/issues/856) # TODO: Is there a better way to handle this? What about south pole? diff --git a/herbie/toolbox/__init__.py b/herbie/toolbox/__init__.py index 73f9e20e..9358061a 100644 --- a/herbie/toolbox/__init__.py +++ b/herbie/toolbox/__init__.py @@ -1,2 +1,3 @@ from .cartopy_tools import EasyMap, ccrs, pc, to_180, to_360 from .wind import spddir_to_uv, uv_to_spddir, wind_degree_labels +from .datetime_tools import str_to_datetime diff --git a/herbie/toolbox/datetime_tools.py b/herbie/toolbox/datetime_tools.py new file mode 100644 index 00000000..c74d52cf --- /dev/null +++ b/herbie/toolbox/datetime_tools.py @@ -0,0 +1,71 @@ +"""Tools for datetime related operations.""" + +import re +from datetime import datetime + + +def str_to_datetime(date_str: str) -> datetime: + """ + Convert a string representing a date and time into a `datetime` object. + + The function uses regular expressions with named groups to extract components of a + date and time (year, month, day, hour, minute, second). It supports formats like: + - ISO 8601 (e.g., "2025-01-26T14:30:45") + - Compact representations (e.g., "20250126143045") + - Common variations (e.g., "2025/01/26 14:30") + + Parameters + ---------- + date_str : str + The input string containing the date and time. + + Returns + ------- + datetime + A `datetime` object representing the parsed date and time. + + Raises + ------ + ValueError + If the input string does not match the expected format or contains invalid date-time components. + + Examples + -------- + >>> str_to_datetime("2025-01-26T14:30:45") + datetime.datetime(2025, 1, 26, 14, 30, 45) + >>> str_to_datetime("20250126143045") + datetime.datetime(2025, 1, 26, 14, 30, 45) + >>> str_to_datetime("2025/01/26 14:30") + datetime.datetime(2025, 1, 26, 14, 30) + >>> str_to_datetime("invalid-string") + Traceback (most recent call last): + ... + ValueError: Input string 'invalid-string' does not match the expected date-time format. + """ + pattern = re.compile( + r"(?P\d{4})[-/]?" + r"(?P\d{2})[-/]?" + r"(?P\d{2})[T ]?" + r"(?P\d{2})?:?(?P\d{2})?:?(?P\d{2})?" + ) + match = pattern.search(date_str) + + if not match: + raise ValueError( + f"Input string '{date_str}' does not match the expected date-time format." + ) + + # Extract groups and convert them to integers, filling in defaults for missing values + year = int(match.group("year")) + month = int(match.group("month")) + day = int(match.group("day")) + hour = int(match.group("hour") or 0) + minute = int(match.group("minute") or 0) + second = int(match.group("second") or 0) + + try: + return datetime(year, month, day, hour, minute, second) + except ValueError as e: + raise ValueError( + f"Invalid date-time components extracted from '{date_str}': {e}" + ) diff --git a/tests/test_toolbox_datetime.py b/tests/test_toolbox_datetime.py new file mode 100644 index 00000000..9d7ce5fd --- /dev/null +++ b/tests/test_toolbox_datetime.py @@ -0,0 +1,45 @@ +import pytest +from datetime import datetime +from herbie.toolbox import str_to_datetime + + +def test_valid_iso_format(): + """Test parsing of ISO 8601 format.""" + assert str_to_datetime("2025-01-26T14:30:45") == datetime(2025, 1, 26, 14, 30, 45) + + +def test_valid_compact_format(): + """Test parsing of compact date-time format.""" + assert str_to_datetime("20250126143045") == datetime(2025, 1, 26, 14, 30, 45) + + +def test_valid_slash_separated_format(): + """Test parsing of slash-separated format.""" + assert str_to_datetime("2025/01/26 14:30") == datetime(2025, 1, 26, 14, 30) + + +def test_missing_time(): + """Test parsing of a string with only the date.""" + assert str_to_datetime("2025-01-26") == datetime(2025, 1, 26, 0, 0, 0) + + +def test_invalid_format(): + """Test handling of an invalid format.""" + with pytest.raises( + ValueError, match=r"does not match the expected date-time format" + ): + str_to_datetime("invalid-string") + + +def test_invalid_date(): + """Test handling of an invalid date.""" + with pytest.raises(ValueError, match=r"Invalid date-time components extracted"): + str_to_datetime("2025-02-30T12:00:00") + + +def test_missing_components(): + """Test handling of missing components in the input.""" + with pytest.raises( + ValueError, match=r"does not match the expected date-time format" + ): + str_to_datetime("2025")