datedict 1.0.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
datedict/DateDict.py ADDED
@@ -0,0 +1,46 @@
1
+ from datetime import date, timedelta
2
+ from decimal import Decimal
3
+ from typing import TYPE_CHECKING
4
+
5
+ from .TimeDict import TimeDict
6
+
7
+ from .common import ZERO
8
+
9
+ if TYPE_CHECKING:
10
+ from .YearDict import YearDict
11
+
12
+ DateKey = str | date
13
+
14
+
15
+ class DateDict(TimeDict[DateKey]):
16
+ @classmethod
17
+ def _next_key(cls, key: DateKey) -> DateKey:
18
+ if isinstance(key, str):
19
+ key_date = date.fromisoformat(key)
20
+ else:
21
+ key_date = key
22
+ next_date = key_date + timedelta(days=1)
23
+ return next_date.strftime("%Y-%m-%d")
24
+
25
+ @classmethod
26
+ def _inclusive_range(cls, start: DateKey, end: DateKey) -> list[DateKey]:
27
+ start_date: str = (
28
+ str(start) if isinstance(start, str) else start.strftime("%Y-%m-%d")
29
+ )
30
+ end_date: str = str(end) if isinstance(end, str) else end.strftime("%Y-%m-%d")
31
+ return super()._inclusive_range(start_date, end_date)
32
+
33
+ def to_yeardict(self) -> "YearDict":
34
+ """
35
+ Convert the DateDict to a YearDict by summing values for each year.
36
+ """
37
+ from .YearDict import YearDict
38
+
39
+ year_data: dict[int, Decimal] = {}
40
+ for k, v in self.data.items():
41
+ year = int(k[:4]) if isinstance(k, str) else k.year
42
+ year_data[year] = year_data.get(year, ZERO) + v
43
+ return YearDict(year_data)
44
+
45
+ def to_dict(self) -> dict[str, Decimal]:
46
+ return {str(k): v for k, v in self.data.items()}
datedict/TimeDict.py ADDED
@@ -0,0 +1,236 @@
1
+ from decimal import Decimal
2
+ from typing import Callable, Generic, Mapping, Protocol, Self, TypeVar
3
+ from .common import NAN, ONE, ZERO, Decimable, to_decimal
4
+
5
+
6
+ K = TypeVar("K", bound="SupportsLe")
7
+
8
+
9
+ class SupportsLe(Protocol):
10
+ def __le__(self: K, other: K, /) -> bool: ...
11
+ def __lt__(self: K, other: K, /) -> bool: ...
12
+
13
+
14
+ class TimeDict(Generic[K]):
15
+
16
+ @classmethod
17
+ def _next_key(cls, key: K) -> K:
18
+ _ = key
19
+ raise NotImplementedError("_next_key must be implemented in subclasses")
20
+
21
+ @classmethod
22
+ def _inclusive_range(cls, start: K, end: K) -> list[K]:
23
+ keys = []
24
+ current = start
25
+ while current <= end:
26
+ keys.append(current)
27
+ current = cls._next_key(current)
28
+ return keys
29
+
30
+ def __init__(
31
+ self,
32
+ data: Mapping[K, Decimable] = dict(),
33
+ strict: bool = True,
34
+ cumulative: bool = False,
35
+ ) -> None:
36
+ keys = sorted(data.keys())
37
+ if not keys:
38
+ raise ValueError("Data cannot be empty.")
39
+ if strict:
40
+ # enforce contiguous coverage
41
+ if keys != self._inclusive_range(keys[0], keys[-1]):
42
+ raise ValueError(
43
+ "Data must cover all keys in the contiguous range. "
44
+ "To disable this check, set strict=False."
45
+ )
46
+ self.start, self.end = keys[0], keys[-1]
47
+ self.data = {k: to_decimal(v) for k, v in data.items()}
48
+ self.cumulative = cumulative
49
+
50
+ @classmethod
51
+ def fill(cls, start: K, end: K, value: Decimable) -> "Self":
52
+ """
53
+ Create a new graph with a specified range and value.
54
+ The range is defined by start and end.
55
+ """
56
+ v = to_decimal(value)
57
+ return cls({k: v for k in cls._inclusive_range(start, end)})
58
+
59
+ def get(self, key: K, default: Decimal = NAN) -> Decimal:
60
+ """
61
+ Get the value for a specific key.
62
+ If the key does not exist, return the default value.
63
+ If the value is None, return the default value.
64
+ """
65
+ temp = self.data.get(key, NAN)
66
+ if temp.is_nan():
67
+ return default
68
+ return temp
69
+
70
+ def __getitem__(self, key: K) -> Decimal:
71
+ return self.data[key]
72
+
73
+ def __setitem__(self, key: K, value) -> None:
74
+ self.data[key] = to_decimal(value)
75
+
76
+ def crop(
77
+ self,
78
+ start: K | None = None,
79
+ end: K | None = None,
80
+ initial_value: Decimable = NAN,
81
+ ) -> "Self":
82
+ if start is None and end is None:
83
+ return self
84
+ return type(self)(
85
+ {
86
+ k: (self.get(k, to_decimal(initial_value)))
87
+ for k in self._inclusive_range(
88
+ start if start is not None else self.start,
89
+ end if end is not None else self.end,
90
+ )
91
+ }
92
+ )
93
+
94
+ def non_negative(self) -> "Self":
95
+ """
96
+ Return a new TimeDict with all negative values set to zero.
97
+ """
98
+ return type(self)(
99
+ {
100
+ k: (v if (not v.is_nan() and v >= ZERO) else ZERO)
101
+ for k, v in self.data.items()
102
+ }
103
+ )
104
+
105
+ def sum(self, start: K | None = None, end: K | None = None) -> Decimal:
106
+ total = ZERO
107
+ for k in self._inclusive_range(
108
+ start if start is not None else self.start,
109
+ end if end is not None else self.end,
110
+ ):
111
+ v = self.data.get(k, NAN)
112
+ if not v.is_nan():
113
+ total += v
114
+ return total
115
+
116
+ def _binary_op(
117
+ self,
118
+ other: "Decimable | Self",
119
+ op: Callable[[Decimal, Decimal], Decimal],
120
+ neutral: Decimal,
121
+ ) -> "Self":
122
+ if isinstance(other, Decimable):
123
+ other_value = to_decimal(other)
124
+ return type(self)({k: op(v, other_value) for k, v in self.data.items()})
125
+ elif isinstance(other, type(self)):
126
+ return type(self)(
127
+ {
128
+ k: op(
129
+ self[k],
130
+ other.get(k, neutral),
131
+ )
132
+ for k in self.data.keys()
133
+ }
134
+ )
135
+ else:
136
+ raise TypeError(
137
+ "Unsupported operand type(s) for operation: "
138
+ f"'TimeDict' and '{type(other)}'"
139
+ )
140
+
141
+ def __mul__(self, other: "Decimable | Self") -> "Self":
142
+ return self._binary_op(other, lambda x, y: x * y, ONE)
143
+
144
+ def __rmul__(self, other):
145
+ return self.__mul__(other)
146
+
147
+ def __neg__(self) -> "Self":
148
+ return self * Decimal("-1")
149
+
150
+ def __add__(self, other: "Decimable | Self") -> "Self":
151
+ return self._binary_op(other, lambda x, y: x + y, ZERO)
152
+
153
+ def __radd__(self, other: "Decimable | Self") -> "Self":
154
+ return self.__add__(other)
155
+
156
+ def __sub__(self, other: "Decimable | Self") -> "Self":
157
+ return self._binary_op(other, lambda x, y: x - y, ZERO)
158
+
159
+ def __rsub__(self, other: "Decimable | Self") -> "Self":
160
+ return (-self).__add__(other)
161
+
162
+ def __truediv__(self, other: "Decimable | Self") -> "Self":
163
+ return self._binary_op(other, lambda x, y: x / y, ONE)
164
+
165
+ def __eq__(self, other: object) -> bool:
166
+ if not isinstance(other, type(self)):
167
+ return False
168
+ for k in set(self.data.keys()).union(other.data.keys()):
169
+ s = self.get(k)
170
+ o = other.get(k)
171
+ if s.is_nan() and o.is_nan():
172
+ continue
173
+ if s != o:
174
+ return False
175
+ return True
176
+
177
+ def __str__(self) -> str:
178
+ return "\n".join(f"{k}: {v}" for k, v in sorted(self.data.items()))
179
+
180
+ def __repr__(self) -> str:
181
+ return f"{self.data!r}"
182
+
183
+ def to_array(self) -> list[Decimal]:
184
+ return [self.data[k] for k in self.data.keys()]
185
+
186
+ def to_dict(self) -> dict:
187
+ return self.data.copy()
188
+
189
+ def average(self) -> Decimal:
190
+ """
191
+ Return the average of the values in the TimeDict.
192
+ If there are no valid (non-NaN) values, return ZERO.
193
+ """
194
+ valid_values = [v for v in self.data.values() if not v.is_nan()]
195
+ if not valid_values:
196
+ return ZERO
197
+ return sum(valid_values) / Decimal(len(valid_values))
198
+
199
+ def to_cumulative(self) -> "Self":
200
+ """
201
+ Convert the TimeDict to a cumulative TimeDict.
202
+ Each value is the sum of all previous values up to that key.
203
+ NaN values are ignored and left as NaN in the cumulative result.
204
+ """
205
+ if self.cumulative:
206
+ raise ValueError("TimeDict is already cumulative.")
207
+ running_total = ZERO
208
+ cumulative_data = {}
209
+ for k in sorted(self.data.keys()):
210
+ current_value = self.data[k]
211
+ if current_value.is_nan():
212
+ cumulative_data[k] = NAN
213
+ else:
214
+ running_total += current_value
215
+ cumulative_data[k] = running_total
216
+ return type(self)(cumulative_data, cumulative=True)
217
+
218
+ def to_incremental(self) -> "Self":
219
+ """
220
+ Convert the TimeDict to an incremental TimeDict.
221
+ Each value is the difference between the current cumulative value
222
+ and the previous cumulative value.
223
+ NaN values are ignored and left as NaN in the incremental result.
224
+ """
225
+ if not self.cumulative:
226
+ raise ValueError("TimeDict is not cumulative.")
227
+ previous_value = ZERO
228
+ incremental_data = {}
229
+ for k in sorted(self.data.keys()):
230
+ current_value = self.data[k]
231
+ if current_value.is_nan():
232
+ incremental_data[k] = NAN
233
+ else:
234
+ incremental_data[k] = current_value - previous_value
235
+ previous_value = current_value
236
+ return type(self)(incremental_data, cumulative=False)
datedict/YearDict.py ADDED
@@ -0,0 +1,24 @@
1
+ from typing import TYPE_CHECKING
2
+ from .TimeDict import TimeDict
3
+ from .common import NAN
4
+
5
+ if TYPE_CHECKING:
6
+ from .DateDict import DateDict
7
+
8
+
9
+ class YearDict(TimeDict[int]):
10
+ @classmethod
11
+ def _next_key(cls, key: int) -> int:
12
+ return key + 1
13
+
14
+ def to_datedict(self) -> "DateDict":
15
+ from .DateDict import DateDict
16
+
17
+ """
18
+ Convert the YearDict to a DateDict.
19
+ Each year in the YearDict is expanded to cover all dates in that year.
20
+ """
21
+ dd: DateDict = DateDict.fill(f"{self.start}-01-01", f"{self.end}-12-31", NAN)
22
+ for k in dd.data.keys():
23
+ dd.data[k] = self.get(int(k[:4]) if isinstance(k, str) else k.year, NAN)
24
+ return dd
datedict/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ from .DateDict import DateDict
2
+ from .YearDict import YearDict
3
+ from .common import ZERO, ONE, NAN, Decimable
4
+
5
+ __all__ = ["DateDict", "YearDict", "ZERO", "ONE", "NAN", "Decimable"]
6
+ __version__ = "1.0.4"
datedict/common.py ADDED
@@ -0,0 +1,19 @@
1
+ from decimal import Decimal
2
+ from typing import Union
3
+
4
+ Decimable = Union[Decimal, str, int, float, None]
5
+
6
+ ZERO = Decimal("0")
7
+ ONE = Decimal("1")
8
+ NAN = Decimal("NaN")
9
+
10
+
11
+ def to_decimal(x: Decimable) -> Decimal:
12
+ if x is None:
13
+ return Decimal("NaN")
14
+ if isinstance(x, Decimal):
15
+ return x
16
+ if isinstance(x, (int, str)):
17
+ return Decimal(x)
18
+ if isinstance(x, float):
19
+ return Decimal(str(x))
@@ -0,0 +1,50 @@
1
+ Metadata-Version: 2.4
2
+ Name: datedict
3
+ Version: 1.0.4
4
+ Summary: DateDict and YearDict: date-aware dictionary structures
5
+ Project-URL: Homepage, https://gitlab.com/grisus/datedict
6
+ Project-URL: Issues, https://gitlab.com/grisus/datedict/-/issues
7
+ Author-email: Lorenzo Guideri <lorenzo.guideri@dec-energy.ch>
8
+ License: MIT License
9
+
10
+ Copyright (c) 2025 DEC Energy SA
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to deal
14
+ in the Software without restriction, including without limitation the rights
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in all
20
+ copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ SOFTWARE.
29
+ License-File: LICENSE
30
+ Classifier: License :: OSI Approved :: MIT License
31
+ Classifier: Operating System :: OS Independent
32
+ Classifier: Programming Language :: Python :: 3
33
+ Classifier: Typing :: Typed
34
+ Requires-Python: >=3.10
35
+ Description-Content-Type: text/markdown
36
+
37
+ # datedict
38
+
39
+ `datedict` bundles two data structures: `DateDict` and `YearDict`.
40
+
41
+ ## Install
42
+ ```bash
43
+ pip install datedict
44
+ ```
45
+
46
+ ## Usage
47
+
48
+ ```python
49
+ from datedict import DateDict, YearDict
50
+ ```
@@ -0,0 +1,9 @@
1
+ datedict/DateDict.py,sha256=nJsE2m8aPfYdPzOs61Jg7uvykDQd4o0YwrTfJkx5dtI,1441
2
+ datedict/TimeDict.py,sha256=20jUsuEZ2rW0XCJZF1h3scYDfu80rXWT69qN2VvQXqA,7831
3
+ datedict/YearDict.py,sha256=xEJYxAFY3zhR8wKejlac976jMwJfkKsak9t4DHTmMKM,707
4
+ datedict/__init__.py,sha256=XB30NC1fBiG4DAswy7JcmIiisDPqgiDGiHxoNQU7bMk,201
5
+ datedict/common.py,sha256=aUCsMV6roKGQIQXSFIJIN0cxAXIlK_YyRDWS1EIBu8E,424
6
+ datedict-1.0.4.dist-info/METADATA,sha256=eDzXvzTdBdlX2I8tRUnMaCK8jlN3kVyZRijsoKX8Aaw,1991
7
+ datedict-1.0.4.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
8
+ datedict-1.0.4.dist-info/licenses/LICENSE,sha256=GMmiaNfJSsljmGtIp9lCY6V-Dkc9NDZn0jSZuAbFe7Y,1070
9
+ datedict-1.0.4.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 DEC Energy SA
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.