Coverage for ramose / datatype.py: 93%

61 statements  

« 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 

9 

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 

15 

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) 

31 

32 

33class _ISODuration(NamedTuple): 

34 """Parsed ISO 8601 duration with calendar components kept separate. 

35 

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 """ 

40 

41 years: int 

42 months: int 

43 remainder: timedelta 

44 

45 

46def _parse_datetime(date_str: str) -> datetime: 

47 """Parse ISO 8601 date strings, including partial formats not supported by fromisoformat. 

48 

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 

65 

66 

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 ) 

84 

85 

86def _add_duration(base: datetime, duration: _ISODuration) -> datetime: 

87 """Add an ISO 8601 duration to a datetime using calendar arithmetic. 

88 

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 

102 

103 

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 } 

116 

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] 

120 

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) 

129 

130 return _add_duration(reference_date, duration) 

131 

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) 

138 

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() 

144 

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) 

150 

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)