datedict 0.1.1__tar.gz → 0.1.3__tar.gz
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.
Potentially problematic release.
This version of datedict might be problematic. Click here for more details.
- {datedict-0.1.1 → datedict-0.1.3}/.gitlab-ci.yml +14 -0
- {datedict-0.1.1 → datedict-0.1.3}/PKG-INFO +1 -1
- {datedict-0.1.1 → datedict-0.1.3}/pyproject.toml +10 -1
- datedict-0.1.3/pytest.ini +4 -0
- datedict-0.1.3/src/datedict/DateDict.py +214 -0
- datedict-0.1.3/src/datedict/YearDict.py +148 -0
- datedict-0.1.3/src/datedict/__init__.py +6 -0
- datedict-0.1.3/src/datedict/common.py +19 -0
- datedict-0.1.1/src/datedict/DateDict.py +0 -137
- datedict-0.1.1/src/datedict/YearDict.py +0 -103
- datedict-0.1.1/src/datedict/__init__.py +0 -5
- datedict-0.1.1/src/datedict/common.py +0 -14
- {datedict-0.1.1 → datedict-0.1.3}/.gitignore +0 -0
- {datedict-0.1.1 → datedict-0.1.3}/LICENSE +0 -0
- {datedict-0.1.1 → datedict-0.1.3}/README.md +0 -0
|
@@ -13,6 +13,18 @@ build:
|
|
|
13
13
|
paths: [dist/]
|
|
14
14
|
expire_in: 1 hour
|
|
15
15
|
|
|
16
|
+
test_pkg:
|
|
17
|
+
stage: test-release
|
|
18
|
+
image: python:3.11-bookworm
|
|
19
|
+
needs:
|
|
20
|
+
- job: build
|
|
21
|
+
artifacts: true
|
|
22
|
+
script:
|
|
23
|
+
- python -m pip install -U pip
|
|
24
|
+
- pip install pytest
|
|
25
|
+
- pip install dist/*.whl || pip install dist/*.tar.gz
|
|
26
|
+
- pytest -q
|
|
27
|
+
|
|
16
28
|
# Publish to TestPyPI on any tag vX.Y.Z
|
|
17
29
|
publish_testpypi:
|
|
18
30
|
stage: test-release
|
|
@@ -20,6 +32,7 @@ publish_testpypi:
|
|
|
20
32
|
needs:
|
|
21
33
|
- job: build
|
|
22
34
|
artifacts: true
|
|
35
|
+
- job: test_pkg
|
|
23
36
|
rules:
|
|
24
37
|
- if: '$CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/'
|
|
25
38
|
id_tokens:
|
|
@@ -39,6 +52,7 @@ publish_pypi:
|
|
|
39
52
|
- job: build
|
|
40
53
|
artifacts: true
|
|
41
54
|
- job: publish_testpypi
|
|
55
|
+
- job: test_pkg
|
|
42
56
|
rules:
|
|
43
57
|
- if: '$CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/'
|
|
44
58
|
id_tokens:
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "datedict"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.3"
|
|
8
8
|
description = "DateDict and YearDict: date-aware dictionary structures"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -27,3 +27,12 @@ packages = ["src/datedict"]
|
|
|
27
27
|
|
|
28
28
|
[tool.hatch.build]
|
|
29
29
|
exclude = ["tests", ".github", ".gitignore"]
|
|
30
|
+
|
|
31
|
+
[tool.pytest.ini_options]
|
|
32
|
+
addopts = "-q --strict-markers --maxfail=1"
|
|
33
|
+
testpaths = ["tests"]
|
|
34
|
+
pythonpath = ["src"]
|
|
35
|
+
|
|
36
|
+
[tool.coverage.run]
|
|
37
|
+
source = ["datedict"]
|
|
38
|
+
branch = true
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
from datetime import date, timedelta
|
|
2
|
+
from decimal import Decimal
|
|
3
|
+
from typing import Mapping
|
|
4
|
+
|
|
5
|
+
from .common import NAN, ONE, Decimable, to_decimal, ZERO
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DateDict:
|
|
9
|
+
def __init__(self, data: Mapping[str, Decimable], strict: bool = True):
|
|
10
|
+
if not data:
|
|
11
|
+
raise ValueError("Data cannot be empty.")
|
|
12
|
+
dates = sorted(data.keys())
|
|
13
|
+
if strict:
|
|
14
|
+
# enforce contiguous coverage
|
|
15
|
+
if dates != [
|
|
16
|
+
(date.fromisoformat(dates[0]) + timedelta(days=i)).strftime("%Y-%m-%d")
|
|
17
|
+
for i in range(
|
|
18
|
+
(date.fromisoformat(dates[-1]) - date.fromisoformat(dates[0])).days
|
|
19
|
+
+ 1
|
|
20
|
+
)
|
|
21
|
+
]:
|
|
22
|
+
raise ValueError(
|
|
23
|
+
"Data must cover all dates in the contiguous range. "
|
|
24
|
+
"To disable this check, set strict=False."
|
|
25
|
+
)
|
|
26
|
+
self.start_date, self.end_date = dates[0], dates[-1]
|
|
27
|
+
self.data: dict[str, Decimal] = {k: to_decimal(v) for k, v in data.items()}
|
|
28
|
+
|
|
29
|
+
def fill(self, start_date, end_date, value: Decimable) -> "DateDict":
|
|
30
|
+
"""
|
|
31
|
+
Create a new graph with a specified range and value.
|
|
32
|
+
The range is defined by start_date and end_date.
|
|
33
|
+
"""
|
|
34
|
+
v = to_decimal(value)
|
|
35
|
+
return DateDict(
|
|
36
|
+
{
|
|
37
|
+
(date.fromisoformat(start_date) + timedelta(days=i)).strftime(
|
|
38
|
+
"%Y-%m-%d"
|
|
39
|
+
): v
|
|
40
|
+
for i in range(
|
|
41
|
+
(date.fromisoformat(end_date) - date.fromisoformat(start_date)).days
|
|
42
|
+
+ 1
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def get(self, key: str, default: Decimal = NAN) -> Decimal:
|
|
48
|
+
"""
|
|
49
|
+
Get the value for a specific date. If the date does not exist, return the default value.
|
|
50
|
+
The date should be in the format yyyy-mm-dd.
|
|
51
|
+
If the value is None, return the default value.
|
|
52
|
+
"""
|
|
53
|
+
temp = self.data.get(key, NAN)
|
|
54
|
+
if temp.is_nan():
|
|
55
|
+
return default
|
|
56
|
+
return temp
|
|
57
|
+
|
|
58
|
+
def __getitem__(self, key) -> Decimal:
|
|
59
|
+
return self.data[key]
|
|
60
|
+
|
|
61
|
+
def __setitem__(self, key: str, value) -> None:
|
|
62
|
+
self.data[key] = to_decimal(value)
|
|
63
|
+
|
|
64
|
+
def crop(
|
|
65
|
+
self,
|
|
66
|
+
start: str | None = None,
|
|
67
|
+
end: str | None = None,
|
|
68
|
+
initial_value: Decimable = NAN,
|
|
69
|
+
) -> "DateDict":
|
|
70
|
+
"""
|
|
71
|
+
Crop the graph data to a specific range defined by start and end.
|
|
72
|
+
If any of the parameters is None, it will not filter by that parameter.
|
|
73
|
+
"""
|
|
74
|
+
if start is None and end is None:
|
|
75
|
+
return self
|
|
76
|
+
return DateDict(
|
|
77
|
+
{
|
|
78
|
+
k: (self.get(k, to_decimal(initial_value)))
|
|
79
|
+
for k in map(
|
|
80
|
+
lambda x: x.strftime("%Y-%m-%d"),
|
|
81
|
+
[
|
|
82
|
+
(
|
|
83
|
+
date.fromisoformat(self.start_date)
|
|
84
|
+
if start is None
|
|
85
|
+
else date.fromisoformat(start)
|
|
86
|
+
)
|
|
87
|
+
+ timedelta(days=i)
|
|
88
|
+
for i in range(
|
|
89
|
+
(
|
|
90
|
+
(
|
|
91
|
+
date.fromisoformat(self.end_date)
|
|
92
|
+
if end is None
|
|
93
|
+
else date.fromisoformat(end)
|
|
94
|
+
)
|
|
95
|
+
- (
|
|
96
|
+
date.fromisoformat(self.start_date)
|
|
97
|
+
if start is None
|
|
98
|
+
else date.fromisoformat(start)
|
|
99
|
+
)
|
|
100
|
+
).days
|
|
101
|
+
+ 1
|
|
102
|
+
)
|
|
103
|
+
],
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def non_negative(self) -> "DateDict":
|
|
109
|
+
"""
|
|
110
|
+
Return a new DateDict with all negative values set to zero.
|
|
111
|
+
"""
|
|
112
|
+
return DateDict({k: (v if (not v.is_nan() and v >= ZERO) else ZERO) for k, v in self.data.items()})
|
|
113
|
+
|
|
114
|
+
def sum(
|
|
115
|
+
self: "DateDict", start: str | None = None, end: str | None = None
|
|
116
|
+
) -> Decimal:
|
|
117
|
+
"""
|
|
118
|
+
Return the sum of values in the specified range.
|
|
119
|
+
If a value is NaN, it is treated as zero.
|
|
120
|
+
"""
|
|
121
|
+
s = self.start_date if start is None else start
|
|
122
|
+
e = self.end_date if end is None else end
|
|
123
|
+
return sum(
|
|
124
|
+
[
|
|
125
|
+
(self.get(k, ZERO))
|
|
126
|
+
for k in map(
|
|
127
|
+
lambda x: x.strftime("%Y-%m-%d"),
|
|
128
|
+
[
|
|
129
|
+
date.fromisoformat(s) + timedelta(days=i)
|
|
130
|
+
for i in range(
|
|
131
|
+
(date.fromisoformat(e) - date.fromisoformat(s)).days + 1
|
|
132
|
+
)
|
|
133
|
+
],
|
|
134
|
+
)
|
|
135
|
+
],
|
|
136
|
+
ZERO,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def __mul__(self, other: "Decimable | DateDict") -> "DateDict":
|
|
140
|
+
if isinstance(other, Decimable):
|
|
141
|
+
return DateDict({k: v * to_decimal(other) for k, v in self.data.items()})
|
|
142
|
+
elif isinstance(other, DateDict):
|
|
143
|
+
return DateDict({k: v * other.get(k, ONE) for k, v in self.data.items()})
|
|
144
|
+
|
|
145
|
+
def __rmul__(self, other: "Decimable | DateDict") -> "DateDict":
|
|
146
|
+
return self.__mul__(other)
|
|
147
|
+
|
|
148
|
+
def __neg__(self) -> "DateDict":
|
|
149
|
+
return self * Decimal("-1")
|
|
150
|
+
|
|
151
|
+
def __add__(self, other: "Decimable | DateDict") -> "DateDict":
|
|
152
|
+
if isinstance(other, Decimable):
|
|
153
|
+
return DateDict({k: (v + to_decimal(other)) for k, v in self.data.items()})
|
|
154
|
+
else:
|
|
155
|
+
return DateDict({k: v + other.get(k, ZERO) for k, v in self.data.items()})
|
|
156
|
+
|
|
157
|
+
def __radd__(self, other: "Decimable | DateDict") -> "DateDict":
|
|
158
|
+
return self.__add__(other)
|
|
159
|
+
|
|
160
|
+
def __sub__(self, other: "Decimable | DateDict") -> "DateDict":
|
|
161
|
+
if isinstance(other, Decimable):
|
|
162
|
+
return self.__add__(-to_decimal(other))
|
|
163
|
+
else:
|
|
164
|
+
return self.__add__(-other)
|
|
165
|
+
|
|
166
|
+
def __rsub__(self, other: "Decimable | DateDict") -> "DateDict":
|
|
167
|
+
return (-self).__add__(other)
|
|
168
|
+
|
|
169
|
+
def __truediv__(self, other: "Decimable | DateDict") -> "DateDict":
|
|
170
|
+
if isinstance(other, Decimable):
|
|
171
|
+
return DateDict({k: v / to_decimal(other) for k, v in self.data.items()})
|
|
172
|
+
else:
|
|
173
|
+
return DateDict({k: v / other.get(k, ONE) for k, v in self.data.items()})
|
|
174
|
+
|
|
175
|
+
def __eq__(self, other: object) -> bool:
|
|
176
|
+
if not isinstance(other, DateDict):
|
|
177
|
+
return False
|
|
178
|
+
for k in set(self.data.keys()).union(other.data.keys()):
|
|
179
|
+
s = self.get(k)
|
|
180
|
+
o = other.get(k)
|
|
181
|
+
if s.is_nan() and o.is_nan():
|
|
182
|
+
continue
|
|
183
|
+
if s != o:
|
|
184
|
+
return False
|
|
185
|
+
return True
|
|
186
|
+
|
|
187
|
+
def __str__(self) -> str:
|
|
188
|
+
return "\n".join(f"{k}: {v}" for k, v in sorted(self.data.items()))
|
|
189
|
+
|
|
190
|
+
def __repr__(self) -> str:
|
|
191
|
+
return f"{self.data!r}"
|
|
192
|
+
|
|
193
|
+
def to_array(self) -> list[Decimal]:
|
|
194
|
+
"""
|
|
195
|
+
Convert the DateDict values to a list of Decimals, ordered by date.
|
|
196
|
+
"""
|
|
197
|
+
return [self.data[k] for k in sorted(self.data.keys())]
|
|
198
|
+
|
|
199
|
+
def to_dict(self) -> dict[str, Decimal]:
|
|
200
|
+
"""
|
|
201
|
+
Convert the DateDict to a standard dictionary.
|
|
202
|
+
"""
|
|
203
|
+
return dict(self.data)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def average(self) -> Decimal:
|
|
207
|
+
"""
|
|
208
|
+
Calculate the average of all values in the DateDict.
|
|
209
|
+
If there are no valid values, return ZERO.
|
|
210
|
+
"""
|
|
211
|
+
valid_values = [v for v in self.data.values() if not v.is_nan()]
|
|
212
|
+
if not valid_values:
|
|
213
|
+
return ZERO
|
|
214
|
+
return Decimal(sum(valid_values)) / len(valid_values)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
from decimal import Decimal
|
|
2
|
+
from typing import Mapping
|
|
3
|
+
|
|
4
|
+
from .common import NAN, ONE, Decimable, to_decimal, ZERO
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class YearDict:
|
|
8
|
+
def __init__(self, data: Mapping[int, Decimable], strict: bool = True):
|
|
9
|
+
if not data:
|
|
10
|
+
raise ValueError("Data cannot be empty.")
|
|
11
|
+
years = sorted(data.keys())
|
|
12
|
+
if strict:
|
|
13
|
+
# enforce contiguous coverage
|
|
14
|
+
if years != list(range(years[0], years[-1] + 1)):
|
|
15
|
+
raise ValueError(
|
|
16
|
+
"Data must cover all years in the contiguous range. "
|
|
17
|
+
"To disable this check, set strict=False."
|
|
18
|
+
)
|
|
19
|
+
self.start_year, self.end_year = years[0], years[-1]
|
|
20
|
+
self.data = {k: to_decimal(v) for k, v in data.items()}
|
|
21
|
+
|
|
22
|
+
def fill(self, start_year: int, end_year: int, value: Decimable) -> "YearDict":
|
|
23
|
+
"""
|
|
24
|
+
Create a new graph with a specified range and value.
|
|
25
|
+
The range is defined by start_year and end_year.
|
|
26
|
+
"""
|
|
27
|
+
v = to_decimal(value)
|
|
28
|
+
return YearDict({y: v for y in range(int(start_year), int(end_year) + 1)})
|
|
29
|
+
|
|
30
|
+
def get(self, year: int, default: Decimal = NAN) -> Decimal:
|
|
31
|
+
"""
|
|
32
|
+
Get the value for a specific year. If the year does not exist, return the default value.
|
|
33
|
+
If the value is None, return the default value.
|
|
34
|
+
"""
|
|
35
|
+
temp = self.data.get(year, NAN)
|
|
36
|
+
if temp.is_nan():
|
|
37
|
+
return default
|
|
38
|
+
return temp
|
|
39
|
+
|
|
40
|
+
def __getitem__(self, year: int) -> Decimal:
|
|
41
|
+
return self.data[year]
|
|
42
|
+
|
|
43
|
+
def __setitem__(self, year: int, value) -> None:
|
|
44
|
+
self.data[int(year)] = to_decimal(value)
|
|
45
|
+
|
|
46
|
+
def crop(
|
|
47
|
+
self,
|
|
48
|
+
start: int | None = None,
|
|
49
|
+
end: int | None = None,
|
|
50
|
+
initial_value: Decimable = NAN,
|
|
51
|
+
) -> "YearDict":
|
|
52
|
+
if start is None and end is None:
|
|
53
|
+
return self
|
|
54
|
+
return YearDict(
|
|
55
|
+
{
|
|
56
|
+
k: (self.get(k, to_decimal(initial_value)))
|
|
57
|
+
for k in range(
|
|
58
|
+
self.start_year if start is None else int(start),
|
|
59
|
+
self.end_year if end is None else int(end)
|
|
60
|
+
+1,
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def non_negative(self) -> "YearDict":
|
|
66
|
+
"""
|
|
67
|
+
Return a new YearDict with all negative values set to zero.
|
|
68
|
+
"""
|
|
69
|
+
return YearDict({y: (v if (not v.is_nan() and v >= ZERO) else ZERO) for y, v in self.data.items()})
|
|
70
|
+
|
|
71
|
+
def sum(self, start: int | None = None, end: int | None = None) -> Decimal:
|
|
72
|
+
"""
|
|
73
|
+
Return the sum of values in the specified range.
|
|
74
|
+
If a value is NaN, it is treated as zero.
|
|
75
|
+
"""
|
|
76
|
+
s = self.start_year if start is None else start
|
|
77
|
+
e = self.end_year if end is None else end
|
|
78
|
+
return sum((self.get(y, ZERO) for y in range(s, e + 1) if y in self.data), ZERO)
|
|
79
|
+
|
|
80
|
+
def __mul__(self, other: "Decimable | YearDict") -> "YearDict":
|
|
81
|
+
if isinstance(other, Decimable):
|
|
82
|
+
return YearDict({k: v * to_decimal(other) for k, v in self.data.items()})
|
|
83
|
+
elif isinstance(other, YearDict):
|
|
84
|
+
return YearDict({k: v * other.get(k, ONE) for k, v in self.data.items()})
|
|
85
|
+
|
|
86
|
+
def __rmul__(self, other):
|
|
87
|
+
return self.__mul__(other)
|
|
88
|
+
|
|
89
|
+
def __neg__(self) -> "YearDict":
|
|
90
|
+
return self * Decimal("-1")
|
|
91
|
+
|
|
92
|
+
def __add__(self, other: "Decimable | YearDict") -> "YearDict":
|
|
93
|
+
if isinstance(other, Decimable):
|
|
94
|
+
return YearDict({k: v + to_decimal(other) for k, v in self.data.items()})
|
|
95
|
+
elif isinstance(other, YearDict):
|
|
96
|
+
return YearDict({k: v + other.get(k, ZERO) for k, v in self.data.items()})
|
|
97
|
+
|
|
98
|
+
def __radd__(self, other: "Decimable | YearDict") -> "YearDict":
|
|
99
|
+
return self.__add__(other)
|
|
100
|
+
|
|
101
|
+
def __sub__(self, other: "Decimable | YearDict") -> "YearDict":
|
|
102
|
+
if isinstance(other, Decimable):
|
|
103
|
+
return self.__add__(-to_decimal(other))
|
|
104
|
+
elif isinstance(other, YearDict):
|
|
105
|
+
return self.__add__(-other)
|
|
106
|
+
|
|
107
|
+
def __rsub__(self, other: "Decimable | YearDict") -> "YearDict":
|
|
108
|
+
return (-self).__add__(other)
|
|
109
|
+
|
|
110
|
+
def __truediv__(self, other: "Decimable | YearDict") -> "YearDict":
|
|
111
|
+
if isinstance(other, Decimable):
|
|
112
|
+
return YearDict({k: v / to_decimal(other) for k, v in self.data.items()})
|
|
113
|
+
elif isinstance(other, YearDict):
|
|
114
|
+
return YearDict({k: v / other.get(k, ONE) for k, v in self.data.items()})
|
|
115
|
+
|
|
116
|
+
def __eq__(self, other: object) -> bool:
|
|
117
|
+
if not isinstance(other, YearDict):
|
|
118
|
+
return False
|
|
119
|
+
for k in set(self.data.keys()).union(other.data.keys()):
|
|
120
|
+
s = self.get(k)
|
|
121
|
+
o = other.get(k)
|
|
122
|
+
if s.is_nan() and o.is_nan():
|
|
123
|
+
continue
|
|
124
|
+
if s != o:
|
|
125
|
+
return False
|
|
126
|
+
return True
|
|
127
|
+
|
|
128
|
+
def __str__(self):
|
|
129
|
+
return "\n".join(f"{y}: {v}" for y, v in sorted(self.data.items()))
|
|
130
|
+
|
|
131
|
+
def __repr__(self):
|
|
132
|
+
return f"{self.data!r}"
|
|
133
|
+
|
|
134
|
+
def to_array(self):
|
|
135
|
+
return [self.data[k] for k in self.data.keys()]
|
|
136
|
+
|
|
137
|
+
def to_dict(self):
|
|
138
|
+
return dict(self.data)
|
|
139
|
+
|
|
140
|
+
def average(self) -> Decimal:
|
|
141
|
+
"""
|
|
142
|
+
Return the average of the values in the YearDict.
|
|
143
|
+
If there are no years, return Zero
|
|
144
|
+
"""
|
|
145
|
+
valid_values = [v for v in self.data.values() if not v.is_nan()]
|
|
146
|
+
if not valid_values:
|
|
147
|
+
return ZERO
|
|
148
|
+
return sum(valid_values, ZERO) / Decimal(len(valid_values))
|
|
@@ -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))
|
|
@@ -1,137 +0,0 @@
|
|
|
1
|
-
from datetime import date
|
|
2
|
-
from decimal import Decimal
|
|
3
|
-
|
|
4
|
-
from datetime import timedelta
|
|
5
|
-
|
|
6
|
-
from .common import _to_decimal, _Z
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
class DateDict:
|
|
10
|
-
def __init__(self, data=dict()):
|
|
11
|
-
self.data: dict[str, Decimal | None] = data
|
|
12
|
-
|
|
13
|
-
def create(self, start_date, end_date, value: Decimal) -> "DateDict":
|
|
14
|
-
"""
|
|
15
|
-
Create a new graph with a specified range and value.
|
|
16
|
-
The range is defined by start_date and end_date.
|
|
17
|
-
If the value is None, it will not be included in the graph.
|
|
18
|
-
"""
|
|
19
|
-
start = (
|
|
20
|
-
date.fromisoformat(start_date)
|
|
21
|
-
if isinstance(start_date, str)
|
|
22
|
-
else start_date
|
|
23
|
-
)
|
|
24
|
-
end = date.fromisoformat(end_date) if isinstance(end_date, str) else end_date
|
|
25
|
-
self.data = {
|
|
26
|
-
date.strftime("%Y-%m-%d"): (value if value is not None else None)
|
|
27
|
-
for date in (
|
|
28
|
-
start + timedelta(days=i) for i in range((end - start).days + 1)
|
|
29
|
-
)
|
|
30
|
-
}
|
|
31
|
-
return self
|
|
32
|
-
|
|
33
|
-
def get(self, key: str, default: Decimal) -> Decimal:
|
|
34
|
-
"""
|
|
35
|
-
Get the value for a specific date. If the date does not exist, return the default value.
|
|
36
|
-
The date should be in the format yyyy-mm-dd.
|
|
37
|
-
If the value is None, return the default value.
|
|
38
|
-
"""
|
|
39
|
-
return self.data.get(key) or default
|
|
40
|
-
|
|
41
|
-
def __getitem__(self, key) -> Decimal | None:
|
|
42
|
-
return self.data.get(key, None)
|
|
43
|
-
|
|
44
|
-
def __setitem__(self, key: str, value) -> None:
|
|
45
|
-
self.data[key] = value
|
|
46
|
-
|
|
47
|
-
def crop(self, start: str | None = None, end: str | None = None) -> "DateDict":
|
|
48
|
-
"""
|
|
49
|
-
Crop the graph data to a specific range defined by start and end.
|
|
50
|
-
If any of the parameters is None, it will not filter by that parameter.
|
|
51
|
-
"""
|
|
52
|
-
if start is None and end is None:
|
|
53
|
-
return self
|
|
54
|
-
return DateDict(
|
|
55
|
-
{
|
|
56
|
-
k: v
|
|
57
|
-
for k, v in self.data.items()
|
|
58
|
-
if (start is None or k >= start) and (end is None or k <= end)
|
|
59
|
-
}
|
|
60
|
-
)
|
|
61
|
-
|
|
62
|
-
def sum(self: "DateDict") -> Decimal:
|
|
63
|
-
"""
|
|
64
|
-
Calculate the sum of all values in the graph.
|
|
65
|
-
If a value is None, it is treated as zero.
|
|
66
|
-
"""
|
|
67
|
-
return Decimal(
|
|
68
|
-
sum(value if value is not None else _Z for value in self.data.values())
|
|
69
|
-
)
|
|
70
|
-
|
|
71
|
-
def __add__(self, other: "Decimal | DateDict") -> "DateDict":
|
|
72
|
-
if isinstance(other, Decimal):
|
|
73
|
-
return DateDict(
|
|
74
|
-
{
|
|
75
|
-
k: (v + other if v is not None else None)
|
|
76
|
-
for k, v in self.data.items()
|
|
77
|
-
}
|
|
78
|
-
)
|
|
79
|
-
else:
|
|
80
|
-
return DateDict(
|
|
81
|
-
{
|
|
82
|
-
k: ((v + (other.data.get(k) or _Z) if v is not None else None))
|
|
83
|
-
for k, v in self.data.items()
|
|
84
|
-
}
|
|
85
|
-
)
|
|
86
|
-
|
|
87
|
-
def __mul__(self, other: "Decimal | DateDict") -> "DateDict":
|
|
88
|
-
if isinstance(other, Decimal):
|
|
89
|
-
return DateDict(
|
|
90
|
-
{
|
|
91
|
-
k: (v * other if v is not None else None)
|
|
92
|
-
for k, v in self.data.items()
|
|
93
|
-
}
|
|
94
|
-
)
|
|
95
|
-
elif isinstance(other, DateDict):
|
|
96
|
-
return DateDict(
|
|
97
|
-
{
|
|
98
|
-
k: ((v * (other.data.get(k) or _Z) if v is not None else None))
|
|
99
|
-
for k, v in self.data.items()
|
|
100
|
-
}
|
|
101
|
-
)
|
|
102
|
-
|
|
103
|
-
def __sub__(self, other: "Decimal | DateDict") -> "DateDict":
|
|
104
|
-
return self + (other * Decimal(-1))
|
|
105
|
-
|
|
106
|
-
def __truediv__(self, other: "Decimal | DateDict") -> "DateDict":
|
|
107
|
-
if isinstance(other, Decimal):
|
|
108
|
-
return DateDict(
|
|
109
|
-
{
|
|
110
|
-
k: (v / other if v is not None else None)
|
|
111
|
-
for k, v in self.data.items()
|
|
112
|
-
}
|
|
113
|
-
)
|
|
114
|
-
elif isinstance(other, DateDict):
|
|
115
|
-
return DateDict(
|
|
116
|
-
{
|
|
117
|
-
k: ((v / (other.data.get(k) or _Z) if v is not None else None))
|
|
118
|
-
for k, v in self.data.items()
|
|
119
|
-
}
|
|
120
|
-
)
|
|
121
|
-
|
|
122
|
-
def to_dict(self) -> dict[str, Decimal | None]:
|
|
123
|
-
"""
|
|
124
|
-
Convert the graph data to a dictionary.
|
|
125
|
-
This is useful for serialization or returning as a response.
|
|
126
|
-
"""
|
|
127
|
-
return self.data.copy()
|
|
128
|
-
|
|
129
|
-
def average(self) -> Decimal:
|
|
130
|
-
"""
|
|
131
|
-
Calculate the average of all values in the graph.
|
|
132
|
-
If there are no valid values, return Decimal(0).
|
|
133
|
-
"""
|
|
134
|
-
valid_values = [v for v in self.data.values() if v is not None]
|
|
135
|
-
if not valid_values:
|
|
136
|
-
return _Z
|
|
137
|
-
return Decimal(sum(valid_values)) / len(valid_values)
|
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
from decimal import Decimal
|
|
2
|
-
|
|
3
|
-
from .common import _to_decimal, _Z
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
class YearDict:
|
|
7
|
-
def __init__(self, start_year=2025, end_year=2025, initial_value: Decimal | int | float = _Z):
|
|
8
|
-
self.start_year: int = start_year
|
|
9
|
-
self.end_year: int = end_year
|
|
10
|
-
iv: Decimal = _to_decimal(initial_value)
|
|
11
|
-
self.data: dict[int, Decimal] = {y: iv for y in range(self.start_year, self.end_year + 1)}
|
|
12
|
-
|
|
13
|
-
def __getitem__(self, year: int) -> Decimal:
|
|
14
|
-
return self.data[year]
|
|
15
|
-
|
|
16
|
-
def __setitem__(self, year: int, value):
|
|
17
|
-
self.data[int(year)] = _to_decimal(value)
|
|
18
|
-
|
|
19
|
-
def override(self, data: dict[int, Decimal | float | int | str]) -> "YearDict":
|
|
20
|
-
if not data:
|
|
21
|
-
raise ValueError("Data cannot be empty.")
|
|
22
|
-
ys = sorted(data.keys())
|
|
23
|
-
# enforce contiguous coverage
|
|
24
|
-
if ys != list(range(ys[0], ys[-1] + 1)):
|
|
25
|
-
raise ValueError("Data must cover all years in the contiguous range.")
|
|
26
|
-
self.start_year, self.end_year = ys[0], ys[-1] # inclusive
|
|
27
|
-
self.data = {
|
|
28
|
-
y: _to_decimal(data[y]) for y in range(self.start_year, self.end_year + 1)
|
|
29
|
-
}
|
|
30
|
-
return self
|
|
31
|
-
|
|
32
|
-
def fit(self, start_year: int, end_year: int, initial_value: Decimal = _Z):
|
|
33
|
-
self.start_year, self.end_year = int(start_year), int(end_year)
|
|
34
|
-
iv = _to_decimal(initial_value)
|
|
35
|
-
self.data = {
|
|
36
|
-
y: _to_decimal(self.data[y]) if y in self.data else iv
|
|
37
|
-
for y in range(self.start_year, self.end_year + 1)
|
|
38
|
-
}
|
|
39
|
-
return self
|
|
40
|
-
|
|
41
|
-
def non_negative(self) -> "YearDict":
|
|
42
|
-
out = YearDict(self.start_year, self.end_year)
|
|
43
|
-
out.data = {y: (v if v >= _Z else _Z) for y, v in self.data.items()}
|
|
44
|
-
return out
|
|
45
|
-
|
|
46
|
-
def sum(
|
|
47
|
-
self, start_year: int | None = None, end_year: int | None = None
|
|
48
|
-
) -> Decimal:
|
|
49
|
-
sy = self.start_year if start_year is None else int(start_year)
|
|
50
|
-
ey = self.end_year if end_year is None else int(end_year)
|
|
51
|
-
return sum((self.data[y] for y in range(sy, ey + 1) if y in self.data), _Z)
|
|
52
|
-
|
|
53
|
-
def __mul__(self, other):
|
|
54
|
-
result = YearDict(self.start_year, self.end_year)
|
|
55
|
-
if isinstance(other, (int, float, Decimal)):
|
|
56
|
-
result.data = {
|
|
57
|
-
year: Decimal(value) * Decimal(str(other))
|
|
58
|
-
for year, value in self.data.items()
|
|
59
|
-
}
|
|
60
|
-
elif isinstance(other, YearDict):
|
|
61
|
-
result.data = {
|
|
62
|
-
year: Decimal(self.data[year]) * Decimal(other.data[year])
|
|
63
|
-
for year in self.data.keys() & other.data.keys()
|
|
64
|
-
}
|
|
65
|
-
else:
|
|
66
|
-
return NotImplemented
|
|
67
|
-
return result
|
|
68
|
-
|
|
69
|
-
def __rmul__(self, other):
|
|
70
|
-
return self.__mul__(other)
|
|
71
|
-
|
|
72
|
-
def __add__(self, other):
|
|
73
|
-
result = YearDict(self.start_year, self.end_year)
|
|
74
|
-
if isinstance(other, (int, float, Decimal)):
|
|
75
|
-
result.data = {
|
|
76
|
-
year: value + Decimal(str(other)) for year, value in self.data.items()
|
|
77
|
-
}
|
|
78
|
-
elif isinstance(other, YearDict):
|
|
79
|
-
result.data = {
|
|
80
|
-
year: Decimal(self.data[year]) + Decimal(other.data[year])
|
|
81
|
-
for year in self.data.keys() & other.data.keys()
|
|
82
|
-
}
|
|
83
|
-
else:
|
|
84
|
-
return NotImplemented
|
|
85
|
-
return result
|
|
86
|
-
|
|
87
|
-
def __radd__(self, other):
|
|
88
|
-
return self.__add__(other)
|
|
89
|
-
|
|
90
|
-
def __sub__(self, other):
|
|
91
|
-
return self.__add__(-1 * other)
|
|
92
|
-
|
|
93
|
-
def __str__(self):
|
|
94
|
-
return "\n".join(f"{y}: {v}" for y, v in sorted(self.data.items()))
|
|
95
|
-
|
|
96
|
-
def __repr__(self):
|
|
97
|
-
return f"{self.data!r}"
|
|
98
|
-
|
|
99
|
-
def to_array(self):
|
|
100
|
-
return [self.data[y] for y in range(self.start_year, self.end_year + 1)]
|
|
101
|
-
|
|
102
|
-
def to_dict(self):
|
|
103
|
-
return dict(self.data)
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
from decimal import Decimal
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
_Z = Decimal("0")
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
def _to_decimal(x) -> Decimal:
|
|
8
|
-
if isinstance(x, Decimal):
|
|
9
|
-
return x
|
|
10
|
-
if isinstance(x, (int, str)):
|
|
11
|
-
return Decimal(x)
|
|
12
|
-
if isinstance(x, float):
|
|
13
|
-
return Decimal(str(x))
|
|
14
|
-
raise TypeError(f"Unsupported type for Decimal: {type(x)}")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|