Coverage for ramose / datatype.py: 93%
61 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-15 15:58 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-15 15:58 +0000
1# SPDX-FileCopyrightText: 2018-2021 Silvio Peroni <silvio.peroni@unibo.it>
2# SPDX-FileCopyrightText: 2020-2021 Marilena Daquino <marilena.daquino2@unibo.it>
3# SPDX-FileCopyrightText: 2022 Davide Brembilla
4# SPDX-FileCopyrightText: 2024 Ivan Heibi <ivan.heibi2@unibo.it>
5# SPDX-FileCopyrightText: 2025 Sergei Slinkin
6# SPDX-FileCopyrightText: 2026 Arcangelo Massari <arcangelo.massari@unibo.it>
7#
8# SPDX-License-Identifier: ISC
10from calendar import monthrange
11from datetime import datetime, timedelta, timezone
12from re import compile as re_compile
13from sys import maxsize
14from typing import NamedTuple
16# ISO 8601 duration format: PnYnMnDTnHnMnS
17# Python's stdlib has no parser for this format, so we handle it manually.
18# Each component is optional. The T separator marks the transition from date to time components.
19# Examples: "P1Y", "P2M3D", "PT4H5M6S", "P1Y2M3DT4H5M6.5S"
20_DURATION_PATTERN = re_compile(
21 r"P"
22 r"(?:(?P<years>\d+)Y)?"
23 r"(?:(?P<months>\d+)M)?"
24 r"(?:(?P<days>\d+)D)?"
25 r"(?:T"
26 r"(?:(?P<hours>\d+)H)?"
27 r"(?:(?P<minutes>\d+)M)?"
28 r"(?:(?P<seconds>\d+(?:\.\d+)?)S)?"
29 r")?"
30)
33class _ISODuration(NamedTuple):
34 """Parsed ISO 8601 duration with calendar components kept separate.
36 Years and months cannot be converted to a fixed number of days (a month is 28-31 days,
37 a year is 365 or 366). Following the same approach as isodate, they are stored as
38 integers and resolved only when added to a concrete reference date via calendar arithmetic.
39 """
41 years: int
42 months: int
43 remainder: timedelta
46def _parse_datetime(date_str: str) -> datetime:
47 """Parse ISO 8601 date strings, including partial formats not supported by fromisoformat.
49 fromisoformat does not accept year-only ("2015") or year-month ("2015-06") strings,
50 so those are handled explicitly. The trailing "Z" suffix is also normalized for Python 3.10
51 compatibility, where fromisoformat does not recognize it.
52 """
53 date_str = date_str.strip()
54 if len(date_str) == 4 and date_str.isdigit():
55 return datetime(int(date_str), 1, 1, tzinfo=timezone.utc)
56 if len(date_str) in (6, 7) and date_str[4] == "-":
57 year, month = date_str.split("-")
58 return datetime(int(year), int(month), 1, tzinfo=timezone.utc)
59 if date_str.endswith("Z"):
60 date_str = date_str[:-1] + "+00:00"
61 parsed = datetime.fromisoformat(date_str)
62 if parsed.tzinfo is None:
63 return parsed.replace(tzinfo=timezone.utc)
64 return parsed
67def _parse_duration(duration_str: str) -> _ISODuration:
68 """Parse an ISO 8601 duration string into an _ISODuration."""
69 duration_match = _DURATION_PATTERN.fullmatch(duration_str)
70 if not duration_match:
71 msg = f"Invalid ISO 8601 duration: {duration_str}"
72 raise ValueError(msg)
73 parts = {key: value or "0" for key, value in duration_match.groupdict().items()}
74 return _ISODuration(
75 years=int(parts["years"]),
76 months=int(parts["months"]),
77 remainder=timedelta(
78 days=int(parts["days"]),
79 hours=int(parts["hours"]),
80 minutes=int(parts["minutes"]),
81 seconds=float(parts["seconds"]),
82 ),
83 )
86def _add_duration(base: datetime, duration: _ISODuration) -> datetime:
87 """Add an ISO 8601 duration to a datetime using calendar arithmetic.
89 Years and months are added by adjusting the calendar fields directly,
90 clamping the day to the maximum valid day for the resulting month
91 (e.g. Jan 31 + 1 month = Feb 28). Days and smaller units are then
92 added as a timedelta.
93 """
94 total_months = base.month + duration.years * 12 + duration.months
95 year_carry, new_month = divmod(total_months - 1, 12)
96 new_month += 1
97 new_year = base.year + year_carry
98 max_day = monthrange(new_year, new_month)[1]
99 new_day = min(base.day, max_day)
100 shifted = base.replace(year=new_year, month=new_month, day=new_day)
101 return shifted + duration.remainder
104class DataType:
105 def __init__(self):
106 """This class implements all the possible data types that can be used within
107 the configuration file of RAMOSE. In particular, it provides methods for converting
108 a string into the related Python data type representation."""
109 self.func = {
110 "str": DataType.str,
111 "int": DataType.int,
112 "float": DataType.float,
113 "duration": DataType.duration,
114 "datetime": DataType.datetime,
115 }
117 def get_func(self, name_str: str):
118 """This method returns the method for handling a given data type expressed as a string name."""
119 return self.func[name_str]
121 @staticmethod
122 def duration(s):
123 """This method returns the data type for durations according to the XML Schema
124 Recommendation (https://www.w3.org/TR/xmlschema11-2/#duration) from the input string.
125 In case the input string is None or it is empty, an high duration value
126 (i.e. 2000 years) is returned."""
127 duration = _parse_duration("P2000Y") if s is None or s == "" else _parse_duration(s)
128 reference_date = datetime(1983, 1, 15, tzinfo=timezone.utc)
130 return _add_duration(reference_date, duration)
132 @staticmethod
133 def datetime(s):
134 """This method returns the data type for datetime according to the ISO 8601
135 (https://en.wikipedia.org/wiki/ISO_8601) from the input string. In case the input string is None or
136 it is empty, a low date value (i.e. 0001-01-01) is returned."""
137 return datetime(1, 1, 1, tzinfo=timezone.utc) if s is None or s == "" else _parse_datetime(s)
139 @staticmethod
140 def str(s):
141 """This method returns the data type for strings. In case the input string is None, an empty string
142 is returned."""
143 return "" if s is None else str(s).lower()
145 @staticmethod
146 def int(s):
147 """This method returns the data type for integer numbers from the input string. In case the input string is
148 None or it is empty, a low integer value is returned."""
149 return -maxsize if s is None or s == "" else int(s)
151 @staticmethod
152 def float(s):
153 """This method returns the data type for float numbers from the input string. In case the input string is
154 None or it is empty, a low float value is returned."""
155 return float(-maxsize) if s is None or s == "" else float(s)