Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add rruleset class with __str__ helper #24

Merged
merged 5 commits into from
Jan 15, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions src/budge/rrule.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
from dataclasses import dataclass, field
from datetime import date, datetime, time
from typing import List

import dateutil.rrule

date_format = "%Y%m%dT%H%M%S"


@dataclass(unsafe_hash=True)
class rruleset:
_rruleset: dateutil.rrule.rruleset = field(
default_factory=dateutil.rrule.rruleset, hash=True
)

def rrule(self, rrule: dateutil.rrule.rrule):
self._rruleset.rrule(rrule)

def rdate(self, rdate: date | datetime):
self._rruleset.rdate(self._combine(rdate))

def exrule(self, rrule: dateutil.rrule.rrule):
self._rruleset.exrule(rrule)

def exdate(self, exdate: date | datetime):
self._rruleset.exdate(self._combine(exdate))

def _combine(self, d: date | datetime):
return d if isinstance(d, datetime) else datetime.combine(d, time(0, 0))

@property
def _rrule(self) -> List[dateutil.rrule.rrule]:
raise AttributeError

@property
def _rdate(self) -> List[date]:
raise AttributeError

@property
def _exrule(self) -> List[dateutil.rrule.rrule]:
raise AttributeError

@property
def _exdate(self) -> List[date]:
raise AttributeError

def __getattr__(self, name):
return self._rruleset.__getattribute__(name)

def __iter__(self):
yield from self._rruleset

def __str__(self):
rrule_str = "\n".join(str(rule) for rule in self._rrule)
dtstart_lines = [
line for line in rrule_str.splitlines() if line.startswith("DTSTART")
]

rule = "\n".join(
[
dtstart_lines[0],
*(
line
for line in rrule_str.splitlines()
if not line.startswith("DTSTART")
),
]
)

if self._rdate:
rule += f"\nRDATE:{','.join(date.strftime(date_format) for date in self._rdate)}"

if self._exrule:
exrule_str = "\n".join(
str(rule).replace("RRULE", "EXRULE") for rule in self._exrule
)

rule += "\n" + "\n".join(
line
for line in exrule_str.splitlines()
if not line.startswith("DTSTART")
)

if self._exdate:
rule += f"\nEXDATE:{','.join(date.strftime(date_format) for date in self._exdate)}"

return rule


def rrulestr(s: str, **kwargs):
rule = dateutil.rrule.rrulestr(s, **kwargs)

return rruleset(rule) if isinstance(rule, dateutil.rrule.rruleset) else rule
3 changes: 2 additions & 1 deletion src/budge/transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@
from datetime import date as _date
from typing import Self

from dateutil.rrule import rrule, rruleset
from dateutil.rrule import rrule

from budge.money import IntoMoney
from budge.rrule import rruleset

from . import account

Expand Down
29 changes: 29 additions & 0 deletions tests/budge/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from datetime import date

import dateutil.rrule
from dateutil.rrule import MONTHLY
from pytest import fixture

import budge.rrule


@fixture(scope="session")
def today():
return date(2022, 12, 6)


@fixture(scope="session")
def rrule(today: date):
return dateutil.rrule.rrule(MONTHLY, bymonthday=1, dtstart=today)


@fixture(scope="session")
def rruleset(today: date):
ruleset = budge.rrule.rruleset()

ruleset.rrule(dateutil.rrule.rrule(freq=MONTHLY, bymonthday=15, dtstart=today))
ruleset.rdate(date(2022, 12, 17))
ruleset.exdate(date(2022, 12, 15))
ruleset.exrule(dateutil.rrule.rrule(freq=MONTHLY, bymonthday=20))

return ruleset
189 changes: 95 additions & 94 deletions tests/budge/test_account.py
Original file line number Diff line number Diff line change
@@ -1,102 +1,103 @@
from datetime import date

import dateutil.rrule
from dateutil.relativedelta import relativedelta
from dateutil.rrule import MONTHLY, rrule
from pytest import fixture
from stockholm import Money

from budge import Account, RepeatingTransaction, Transaction
from budge.rrule import rruleset


class TestAccount:
today = date(2022, 12, 6)

t1 = Transaction("test 1", Money(1), date(2022, 12, 1))

rule1 = rrule(freq=MONTHLY, bymonthday=1, dtstart=today)
rt1 = RepeatingTransaction("test 1", Money(1), schedule=rule1)
rt1_manual = Transaction(rt1.description, rt1.amount, rule1[0].date())

rule2 = rrule(freq=MONTHLY, bymonthday=15, dtstart=today)
rt2 = RepeatingTransaction("test 2", Money(2), schedule=rule2)

acct = Account("test")
acct.transactions.add(t1, rt1_manual)
acct.repeating_transactions.add(rt1)
acct.repeating_transactions.add(rt2)

def test_balance(self):
"""
Verify that the balance on the given date is equal to the value of all
transactions up to and including that date.
"""
assert self.acct.balance(self.today) == Money(1)

def test_balance_as_of_future(self):
"""
Verify that the balance as of one year in the future is equal to the
expected amount after accounting for all repeating transactions.
"""
as_of = self.today + relativedelta(years=1)
assert self.acct.balance(as_of) == Money(37)

def test_transactions_range(self):
"""
Verify that the transactions_range method returns the correct number of
transactions between the given start and end dates.
"""
start_date = self.today + relativedelta(months=6)
end_date = self.today + relativedelta(months=9)

transactions = list(self.acct.transactions_range(start_date, end_date))
assert len(transactions) == 6

def test_transactions_range_is_sorted(self):
"""Verify that transactions yielded by Account.__iter__ are ordered by ascending
date."""
next_date = list(self.acct.transactions)[0].date

for transaction in self.acct.transactions:
assert transaction.date >= next_date
next_date = transaction.date

def test_running_balance(self):
end_date = self.today + relativedelta(months=3)
balances = list(self.acct.running_balance(self.today, end_date))

assert len(balances) == 6
assert balances[0].balance == Money(3)
assert balances[1].balance == Money(4)
assert balances[-1].balance == Money(10)

def test_daily_balance_past(self):
"""
Verify that the daily_balance method returns the correct balances for each
day in the past month, starting from a given start date and ending on today's
date. The initial balance should be zero, and the balance on today's date
should match the expected value.
"""
start_date = date(2022, 11, 6)
balances = list(
self.acct.daily_balance(start_date=start_date, end_date=self.today)
)

assert len(balances) == 31
assert balances[0] == (start_date, Money(0))
assert balances[-1] == (self.today, Money(1))

def test_daily_balance_future(self):
"""
Verify that the daily_balance method returns the correct balances for each
day in the future month, starting from today's date and ending on a given
end date. The initial balance should be the expected value, and the balance
on the end date should match the expected value.
"""
end_date = self.today + relativedelta(months=1)
balances = list(
self.acct.daily_balance(start_date=self.today, end_date=end_date)
)

assert len(balances) == 32
assert balances[0] == (self.today, Money(1))
assert balances[9] == (date(2022, 12, 15), Money(3))
assert balances[-1] == (end_date, Money(4))
@fixture(scope="session")
def account(
transaction: Transaction,
repeating_transaction_rrule: RepeatingTransaction,
repeating_transaction_rruleset: RepeatingTransaction,
):
acct = Account(name="test")

manual_transaction = Transaction(
repeating_transaction_rrule.description,
repeating_transaction_rrule.amount,
list(repeating_transaction_rrule)[0].date,
)

acct.repeating_transactions.add(
repeating_transaction_rrule, repeating_transaction_rruleset
)
acct.transactions.add(transaction, manual_transaction)

return acct


@fixture(scope="session")
def transaction():
return Transaction("test transaction", Money(1), date(2022, 12, 1))


@fixture(scope="session")
def repeating_transaction_rrule(today: date, rrule: dateutil.rrule.rrule):
return RepeatingTransaction(
"test repeating transaction with rrule",
Money(1),
schedule=rrule,
)


@fixture(scope="session")
def repeating_transaction_rruleset(rruleset: rruleset):
return RepeatingTransaction(
"test repeating transaction with rruleset", Money(2), schedule=rruleset
)


def test_account_balance(account: Account, today: date):
assert account.balance(today) == Money(1)
assert account.balance(today + relativedelta(years=1)) == Money(37)


def test_account_transactions_range(account: Account, today: date):
end_date = today + relativedelta(months=3)
transactions = list(account.transactions_range(today, end_date))

assert len(transactions) == 6
assert transactions[0].description == "test repeating transaction with rruleset"
assert transactions[0].date == date(2022, 12, 17)

assert transactions[-1].date == date(2023, 3, 1)

next_date = transactions[0].date
for transaction in transactions:
assert transaction.date >= next_date
next_date = transaction.date


def test_account_running_balance(account: Account, today: date):
end_date = today + relativedelta(months=3)
balances = list(account.running_balance(today, end_date))

assert len(balances) == 6
assert balances[0].balance == Money(3)
assert balances[1].balance == Money(4)
assert balances[-1].balance == Money(10)


def test_account_daily_balance_past(account: Account, today: date):
start_date = today + relativedelta(months=-1)
balances = list(account.daily_balance(start_date, today))

assert len(balances) == 31
assert balances[0] == (start_date, Money(0))
assert balances[-1] == (today, Money(1))


def test_account_daily_balance_future(account: Account, today: date):
end_date = today + relativedelta(months=1)
balances = list(account.daily_balance(today, end_date))

assert len(balances) == 32
assert balances[0] == (today, Money(1))
assert balances[9] == (date(2022, 12, 15), Money(1))
assert balances[11] == (date(2022, 12, 17), Money(3))
assert balances[-1] == (end_date, Money(4))
25 changes: 25 additions & 0 deletions tests/budge/test_rrule.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from pytest import fixture

import budge.rrule


@fixture
def rrulestr():
return (
"DTSTART:20221206T000000\n"
"RRULE:FREQ=MONTHLY;BYMONTHDAY=15\n"
"RDATE:20221217T000000\n"
"EXRULE:FREQ=MONTHLY;BYMONTHDAY=20\n"
"EXDATE:20221215T000000"
)


def test_rruleset_str(rruleset: budge.rrule.rruleset, rrulestr):
assert str(rruleset) == rrulestr


def test_rrulestr(rruleset, rrulestr):
from_rrulestr = budge.rrule.rrulestr(rrulestr)

assert isinstance(from_rrulestr, budge.rrule.rruleset)
assert str(from_rrulestr) == str(rruleset)
Loading
Loading