Skip to content

Commit 861636e

Browse files
committed
New Period
1 parent 9d16390 commit 861636e

7 files changed

Lines changed: 733 additions & 629 deletions

File tree

Makefile

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ lint-check: ## Run linters in check mode
2626

2727
.PHONY: test
2828
test: ## Test with python 3.8 with coverage
29-
@uv run pytest -x -v --cov --cov-report xml
29+
@uv run ./dev/test
3030

3131
.PHONY: publish
3232
publish: ## Release to pypi
@@ -44,6 +44,10 @@ docs-serve: ## Serve docs locally with live reload
4444
publish-docs: ## Publish docs to github pages
4545
uv run mkdocs gh-deploy --force
4646

47+
.PHONY: update
48+
update: ## Update packages
49+
uv lock --upgrade
50+
4751
.PHONY: outdated
4852
outdated: ## Show outdated packages
4953
uv tree --outdated

ccy/dates/converters.py

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,7 @@
22
from datetime import date, datetime
33
from typing import Any
44

5-
try:
6-
from dateutil.parser import parse as date_from_string
7-
except ImportError: # noqa
8-
9-
def date_from_string(dte): # type: ignore
10-
raise NotImplementedError
5+
from dateutil.parser import parse as date_from_string
116

127

138
def todate(val: Any) -> date:

ccy/dates/period.py

Lines changed: 103 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
from typing import Any, Self
44

5+
from dateutil.relativedelta import relativedelta
6+
from pydantic import BaseModel, Field
7+
58

69
def period(pstr: str = "") -> Period:
710
"""Create a period object from a period string"""
@@ -17,155 +20,149 @@ def find_first_of(st: str, possible: str) -> int:
1720
return lowi
1821

1922

20-
def safediv(x: int, d: int) -> int:
21-
return x // d if x >= 0 else -(-x // d)
22-
23-
24-
def safemod(x: int, d: int) -> int:
25-
return x % d if x >= 0 else -(-x % d)
26-
27-
28-
class Period:
29-
def __init__(self, months: int = 0, days: int = 0) -> None:
30-
self._months = months
31-
self._days = days
23+
class Period(BaseModel):
24+
months: int = Field(default=0, ge=0, description="Number of months in the period")
25+
days: int = Field(default=0, ge=0, description="Number of days in the period")
26+
hours: int = Field(default=0, ge=0, description="Number of hours in the period")
3227

3328
@classmethod
3429
def make(cls, data: Any) -> Self:
3530
if isinstance(data, cls):
3631
return data
3732
elif isinstance(data, str):
38-
return cls().add_tenure(data)
33+
return cls().add_period(data)
34+
elif isinstance(data, relativedelta):
35+
return cls(
36+
months=data.years * 12 + data.months,
37+
days=data.days,
38+
hours=data.hours,
39+
)
3940
else:
4041
raise TypeError("Cannot convert %s to Period" % data)
4142

4243
def isempty(self) -> bool:
43-
return self._months == 0 and self._days == 0
44+
return self.months == 0 and self.days == 0 and self.hours == 0
4445

4546
def add_days(self, days: int) -> None:
46-
self._days += days
47+
if days < 0:
48+
raise ValueError("Negative days are not supported")
49+
self.days += days
4750

4851
def add_weeks(self, weeks: int) -> None:
49-
self._days += int(7 * weeks)
52+
if weeks < 0:
53+
raise ValueError("Negative weeks are not supported")
54+
self.days += int(7 * weeks)
5055

5156
def add_months(self, months: int) -> None:
52-
self._months += months
57+
if months < 0:
58+
raise ValueError("Negative months are not supported")
59+
self.months += months
5360

5461
def add_years(self, years: int) -> None:
55-
self._months += int(12 * years)
62+
if years < 0:
63+
raise ValueError("Negative years are not supported")
64+
self.months += int(12 * years)
65+
66+
def add_hours(self, hours: int) -> None:
67+
if hours < 0:
68+
raise ValueError("Negative hours are not supported")
69+
self.hours += hours
70+
if self.hours >= 24:
71+
self.days += self.hours // 24
72+
self.hours = self.hours % 24
5673

5774
@property
5875
def years(self) -> int:
59-
return safediv(self._months, 12)
76+
return self.months // 12
6077

6178
@property
62-
def months(self) -> int:
63-
return safemod(self._months, 12)
79+
def months_remaining(self) -> int:
80+
"""Months remaining after extracting years from the period"""
81+
return self.months % 12
6482

6583
@property
6684
def weeks(self) -> int:
67-
return safediv(self._days, 7)
85+
"""Weeks in the period after extracting months and years from the period"""
86+
return self.days // 7
6887

6988
@property
70-
def days(self) -> int:
71-
return safemod(self._days, 7)
72-
73-
@property
74-
def totaldays(self) -> int:
75-
return 30 * self._months + self._days
76-
77-
def __repr__(self) -> str:
78-
"""The period string"""
79-
return self.components()
80-
81-
def __str__(self) -> str:
82-
return self.__repr__()
89+
def days_remaining(self) -> int:
90+
"""Days remaining after extracting weeks from the period"""
91+
return self.days % 7
8392

84-
def components(self) -> str:
93+
def to_str(self) -> str:
8594
"""The period string"""
8695
p = ""
87-
neg = self.totaldays < 0
8896
y = self.years
8997
m = self.months
9098
w = self.weeks
9199
d = self.days
92-
if y:
93-
p = "%sY" % abs(y)
94-
if m:
95-
p = "%s%sM" % (p, abs(m))
96-
if w:
97-
p = "%s%sW" % (p, abs(w))
98-
if d:
99-
p = "%s%sD" % (p, abs(d))
100-
return "-" + p if neg else p
101-
102-
def simple(self) -> str:
103-
"""A string representation with only one period delimiter."""
104-
if self._days:
105-
return "%sD" % self.totaldays
106-
elif self.months:
107-
return "%sM" % self._months
108-
elif self.years:
109-
return "%sY" % self.years
110-
else:
111-
return ""
112-
113-
def add_tenure(self, pstr: str) -> Self:
114-
if isinstance(pstr, self.__class__):
115-
self._months += pstr._months
116-
self._days += pstr._days
100+
if y := self.years:
101+
p = "%sY" % y
102+
if m := self.months_remaining:
103+
p = "%s%sM" % (p, m)
104+
if w := self.weeks:
105+
p = "%s%sW" % (p, w)
106+
if d := self.days_remaining:
107+
p = "%s%sD" % (p, d)
108+
if h := self.hours:
109+
p = "%s%sH" % (p, h)
110+
return p
111+
112+
def to_relativedelta(self) -> relativedelta:
113+
"""Convert the period to a dateutil.relativedelta object"""
114+
return relativedelta(
115+
years=self.years,
116+
months=self.months_remaining,
117+
weeks=self.weeks,
118+
days=self.days_remaining,
119+
hours=self.hours,
120+
)
121+
122+
def add_period(self, pstr: str | Self) -> Self:
123+
if isinstance(pstr, Period):
124+
self.add_days(pstr.days)
125+
self.add_months(pstr.months)
126+
self.add_hours(pstr.hours)
117127
return self
118128
st = str(pstr).upper()
119-
done = False
120-
sign = 1
121-
while not done:
122-
if not st:
123-
done = True
124-
else:
125-
ip = find_first_of(st, "DWMY")
126-
if ip == -1:
127-
raise ValueError("Unknown period %s" % pstr)
128-
p = st[ip]
129-
v = int(st[:ip])
130-
sign = sign if v > 0 else -sign
131-
v = sign * abs(v)
132-
if p == "D":
133-
self.add_days(v)
134-
elif p == "W":
135-
self.add_weeks(v)
136-
elif p == "M":
137-
self.add_months(v)
138-
elif p == "Y":
139-
self.add_years(v)
140-
ip += 1
141-
st = st[ip:]
129+
while st:
130+
ip = find_first_of(st, "HDWMY")
131+
if ip == -1:
132+
raise ValueError("Unknown period %s" % pstr)
133+
p = st[ip]
134+
v = int(st[:ip])
135+
if v < 0:
136+
raise ValueError("Negative values are not supported")
137+
if p == "D":
138+
self.add_days(v)
139+
elif p == "W":
140+
self.add_weeks(v)
141+
elif p == "M":
142+
self.add_months(v)
143+
elif p == "H":
144+
self.add_hours(v)
145+
elif p == "Y":
146+
self.add_years(v)
147+
ip += 1
148+
st = st[ip:]
142149
return self
143150

144151
def __add__(self, other: Any) -> Self:
145152
p = self.make(other)
146-
return self.__class__(self._months + p._months, self._days + p._days)
153+
period = self.model_copy()
154+
period.add_days(p.days)
155+
period.add_months(p.months)
156+
period.add_hours(p.hours)
157+
return period
147158

148159
def __radd__(self, other: Any) -> Self:
149160
return self + other
150161

151-
def __sub__(self, other: Any) -> Self:
152-
p = self.make(other)
153-
return self.__class__(self._months - p._months, self._days - p._days)
154-
155-
def __rsub__(self, other: Any) -> Self:
156-
return self.make(other) - self
157-
158-
def __gt__(self, other: Any) -> bool:
159-
return self.totaldays > self.make(other).totaldays
160-
161-
def __lt__(self, other: Any) -> bool:
162-
return self.totaldays < self.make(other).totaldays
163-
164-
def __ge__(self, other: Any) -> bool:
165-
return self.totaldays >= self.make(other).totaldays
166-
167-
def __le__(self, other: Any) -> bool:
168-
return self.totaldays <= self.make(other).totaldays
169-
170162
def __eq__(self, other: Any) -> bool:
171-
return self.totaldays == self.make(other).totaldays
163+
period = self.make(other)
164+
return (
165+
self.months == period.months
166+
and self.days == period.days
167+
and self.hours == period.hours
168+
)

dev/test

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
#!/usr/bin/env sh
2+
3+
pytest -x -vv \
4+
--log-cli-level error \
5+
--cov --cov-report xml --cov-report html "$@"

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "ccy"
3-
version = "2.0.0"
3+
version = "2.1.0"
44
description = "Python currencies"
55
authors = [ { name = "Luca Sbardella", email = "luca@quantmind.com" } ]
66
license = { text = "BSD" }

0 commit comments

Comments
 (0)