diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml index 0c255cb..e14c8e5 100644 --- a/.github/workflows/actions.yml +++ b/.github/workflows/actions.yml @@ -36,6 +36,10 @@ jobs: run: echo "Cache was restored" - name: Install dependencies run: uv sync - - name: Run tests + - name: Run tests with coverage run: | - uv run --frozen pytest + uv run --frozen pytest --cov --cov-branch --cov-report=xml + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/README.md b/README.md index fd46938..5f527d8 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # BeanBot: Smart Predictor for Beancount Transactions ![Action dispatcher](https://github.com/cvcore/beanbot/actions/workflows/actions-dispatcher.yml/badge.svg) +[![codecov](https://codecov.io/github/cvcore/beanbot/graph/badge.svg?token=PNRQGLUL7B)](https://codecov.io/github/cvcore/beanbot) This branch contains the v2 version of BeanBot, which is a complete rewrite of the original project. The new version is designed to be more efficient, modular, and easier to maintain. diff --git a/pyproject.toml b/pyproject.toml index 785af69..2fe1071 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,7 @@ readme = "README.md" requires-python = ">=3.12" dependencies = [ "beancount>=3.1.0", + "recordclass>=0.23.1", ] authors = [ { name = "Chengxin Wang", email = "w@hxdl.org" }, @@ -27,4 +28,5 @@ version-file = "src/beanbot/_version.py" dev = [ "ipython>=9.3.0", "pytest>=8.4.0", + "pytest-cov>=6.2.1", ] diff --git a/src/beanbot/data/constants.py b/src/beanbot/data/constants.py new file mode 100644 index 0000000..6d0b77d --- /dev/null +++ b/src/beanbot/data/constants.py @@ -0,0 +1 @@ +METADATA_BBID = "bbid" diff --git a/src/beanbot/data/directive.py b/src/beanbot/data/directive.py index 032c04e..7924c65 100644 --- a/src/beanbot/data/directive.py +++ b/src/beanbot/data/directive.py @@ -1,11 +1,275 @@ +from copy import deepcopy +from typing import Any, TypeVar, Union + from beancount import Directive -import os +from beancount.core.data import ( + Balance, + Close, + Commodity, + Custom, + Document, + Event, + Note, + Open, + Pad, + Posting, + Price, + Query, + Transaction, +) +from recordclass import recordclass + +from beanbot.data.constants import METADATA_BBID + +# Type variable for beancount directives +T = TypeVar("T", bound=Directive) + +# Mapping dictionaries for conversion between mutable and immutable types +_MAP_TO_MUTABLE_DIRECTIVE = {} +_MAP_TO_IMMUTABLE_DIRECTIVE = {} + + +def _from_immutable(cls: type, obj: Directive) -> "_MutableDirectiveImpl": + """Convert an immutable object to its mutable counterpart recursively""" + cls_mutable = _MAP_TO_MUTABLE_DIRECTIVE[cls] + fields_dict = dict() + for key, value in obj._asdict().items(): + if type(value) in _MAP_TO_MUTABLE_DIRECTIVE: + fields_dict[key] = _from_immutable(type(value), value) + elif isinstance(value, list): + value = [ + ( + v + if type(v) not in _MAP_TO_MUTABLE_DIRECTIVE + else _from_immutable(type(v), v) + ) + for v in value + ] + fields_dict[key] = value + else: + fields_dict[key] = value + return cls_mutable(**fields_dict) + +def _to_immutable(obj: "_MutableDirectiveImpl") -> Directive: + """Convert a mutable object to its immutable counterpart recursively""" + cls = type(obj) + cls_immutable = _MAP_TO_IMMUTABLE_DIRECTIVE[cls] + fields_dict = dict() + for key, value in obj._asdict().items(): + if type(value) in _MAP_TO_IMMUTABLE_DIRECTIVE: + fields_dict[key] = _to_immutable(value) + elif isinstance(value, list): + value = [ + v if type(v) not in _MAP_TO_IMMUTABLE_DIRECTIVE else _to_immutable(v) + for v in value + ] + fields_dict[key] = value + else: + fields_dict[key] = value + return cls_immutable(**fields_dict) -def get_source_file_path(directive: Directive) -> str | None: - """ - Returns the path to the source file of this module. - """ - return ( - os.path.abspath(directive.meta.get("filename", "")) if directive.meta else None + +def _make_mutable_type(immutable_type: type) -> type: + """Create a mutable version of an immutable beancount type""" + mutable_type = recordclass( + "_Mutable" + immutable_type.__name__ + "Impl", immutable_type._fields ) + mutable_type.from_immutable = _from_immutable + mutable_type.to_immutable = _to_immutable + _MAP_TO_MUTABLE_DIRECTIVE[immutable_type] = mutable_type + _MAP_TO_IMMUTABLE_DIRECTIVE[mutable_type] = immutable_type + return mutable_type + + +# Create mutable implementation classes +_MutableOpenImpl = _make_mutable_type(Open) +_MutableCloseImpl = _make_mutable_type(Close) +_MutableCommodityImpl = _make_mutable_type(Commodity) +_MutablePadImpl = _make_mutable_type(Pad) +_MutableBalanceImpl = _make_mutable_type(Balance) +_MutablePostingImpl = _make_mutable_type(Posting) +_MutableTransactionImpl = _make_mutable_type(Transaction) +_MutableNoteImpl = _make_mutable_type(Note) +_MutableEventImpl = _make_mutable_type(Event) +_MutableQueryImpl = _make_mutable_type(Query) +_MutablePriceImpl = _make_mutable_type(Price) +_MutableDocumentImpl = _make_mutable_type(Document) +_MutableCustomImpl = _make_mutable_type(Custom) + +_MutableDirectiveImpl = Union[ + _MutableOpenImpl, + _MutableCloseImpl, + _MutableCommodityImpl, + _MutablePadImpl, + _MutableBalanceImpl, + _MutablePostingImpl, + _MutableTransactionImpl, + _MutableNoteImpl, + _MutableEventImpl, + _MutableQueryImpl, + _MutablePriceImpl, + _MutableDocumentImpl, + _MutableCustomImpl, +] + + +class MutableDirective[T: Directive]: + """A mutable wrapper around beancount directives with change tracking.""" + + _directive_type = Directive + + def __init__( + self, + directive: T, + id: str | None = None, + ): + """Initialize a mutable directive. + + Args: + directive: The beancount directive to wrap + id: Unique identifier for this directive + """ + assert isinstance(directive, self._directive_type) + + # Convert to mutable implementation and create backup + self._mutable_directive = _from_immutable(type(directive), directive) + self._original_directive = deepcopy(directive) + self._id = id + + @property + def id(self) -> str | None: + """Get the unique identifier for this directive.""" + return self._id + + @id.setter + def id(self, value: str) -> None: + """Set the unique identifier for this directive.""" + self._id = value + + @property + def original(self) -> T: + """Get the wrapped beancount directive.""" + return self._original_directive + + def __getattr__(self, name: str) -> Any: + """Get an attribute from the mutable implementation.""" + return getattr(self._mutable_directive, name) + + def __setattr__(self, name: str, value: Any) -> None: + """Set an attribute on the mutable implementation.""" + if name.startswith("_") or name in ("directive", "id"): + super().__setattr__(name, value) + return + + # Set attribute on mutable implementation + setattr(self._mutable_directive, name, value) + + def to_immutable(self) -> T: + """Convert this mutable directive back to an immutable beancount directive.""" + updated_directive = _to_immutable(self._mutable_directive) + if updated_directive.meta is None: + updated_directive = updated_directive._replace(meta={}) + if self._id is not None: + meta_copy = dict(updated_directive.meta) + meta_copy[METADATA_BBID] = self._id + updated_directive = updated_directive._replace(meta=meta_copy) + return updated_directive + + def __repr__(self) -> str: + """String representation of the mutable directive.""" + dirty_info = " (dirty)" if self.dirty() else "" + return f"{type(self._mutable_directive).__name__}(id={self._id}{dirty_info})" + + def dirty(self) -> bool: + """Check if there are any changes made to this directive.""" + return self._mutable_directive.to_immutable() != self._original_directive + + def reset(self) -> None: + """Reset the changes made to this directive.""" + self._mutable_directive = _from_immutable( + type(self._original_directive), self._original_directive + ) + + +class MutableTransaction(MutableDirective[Transaction]): + _directive_type = Transaction + + +class MutableOpen(MutableDirective[Open]): + _directive_type = Open + + +class MutableClose(MutableDirective[Close]): + _directive_type = Close + + +class MutableBalance(MutableDirective[Balance]): + _directive_type = Balance + + +class MutablePad(MutableDirective[Pad]): + _directive_type = Pad + + +class MutableNote(MutableDirective[Note]): + _directive_type = Note + + +class MutableEvent(MutableDirective[Event]): + _directive_type = Event + + +class MutableQuery(MutableDirective[Query]): + _directive_type = Query + + +class MutablePrice(MutableDirective[Price]): + _directive_type = Price + + +class MutableDocument(MutableDirective[Document]): + _directive_type = Document + + +class MutableCustom(MutableDirective[Custom]): + _directive_type = Custom + + +class MutableCommodity(MutableDirective[Commodity]): + _directive_type = Commodity + + +def to_mutable(directive: Directive) -> MutableDirective[Directive]: + """Convert a beancount directive to a mutable directive.""" + if isinstance(directive, Transaction): + return MutableTransaction(directive) + elif isinstance(directive, Open): + return MutableOpen(directive) + elif isinstance(directive, Close): + return MutableClose(directive) + elif isinstance(directive, Balance): + return MutableBalance(directive) + elif isinstance(directive, Pad): + return MutablePad(directive) + elif isinstance(directive, Note): + return MutableNote(directive) + elif isinstance(directive, Event): + return MutableEvent(directive) + elif isinstance(directive, Query): + return MutableQuery(directive) + elif isinstance(directive, Price): + return MutablePrice(directive) + elif isinstance(directive, Document): + return MutableDocument(directive) + elif isinstance(directive, Custom): + return MutableCustom(directive) + elif isinstance(directive, Commodity): + return MutableCommodity(directive) + + raise TypeError(f"Unsupported directive type: {type(directive).__name__}") + + +def make_mutable(obj: Directive) -> _MutableDirectiveImpl: + """Convert an immutable directive to its mutable counterpart""" + return _from_immutable(type(obj), obj) diff --git a/src/beanbot/data/directive_test.py b/src/beanbot/data/directive_test.py new file mode 100644 index 0000000..157717b --- /dev/null +++ b/src/beanbot/data/directive_test.py @@ -0,0 +1,645 @@ +from datetime import date +from decimal import Decimal + +import pytest +from beancount.core.amount import Amount +from beancount.core.data import ( + EMPTY_SET, + Balance, + Close, + Commodity, + Custom, + Document, + Event, + Note, + Open, + Pad, + Posting, + Price, + Query, + Transaction, +) + +from .directive import ( + MutableBalance, + MutableClose, + MutableCommodity, + MutableCustom, + MutableDocument, + MutableEvent, + MutableNote, + MutableOpen, + MutablePad, + MutablePrice, + MutableQuery, + MutableTransaction, +) + + +@pytest.fixture(name="sample_transaction") +def fixture_sample_transaction(): + return Transaction( + meta={"filename": "test.beancount", "lineno": 1}, + date=date(2024, 1, 1), + flag="*", + payee="Test Payee", + narration="Test transaction", + tags=EMPTY_SET, + links=EMPTY_SET, + postings=[ + Posting( + account="Assets:Cash", + units=Amount(Decimal("100"), "USD"), + cost=None, + price=None, + flag=None, + meta={}, + ) + ], + ) + + +@pytest.fixture(name="sample_open") +def fixture_sample_open(): + return Open( + meta={"filename": "test.beancount", "lineno": 2}, + date=date(2024, 1, 1), + account="Assets:Cash", + currencies=["USD"], + booking=None, + ) + + +@pytest.fixture(name="sample_close") +def fixture_sample_close(): + return Close( + meta={"filename": "test.beancount", "lineno": 3}, + date=date(2024, 12, 31), + account="Assets:Cash", + ) + + +@pytest.fixture(name="sample_balance") +def fixture_sample_balance(): + return Balance( + meta={"filename": "test.beancount", "lineno": 4}, + date=date(2024, 1, 1), + account="Assets:Cash", + amount=Amount(Decimal("1000"), "USD"), + tolerance=None, + diff_amount=None, + ) + + +@pytest.fixture(name="sample_pad") +def fixture_sample_pad(): + return Pad( + meta={"filename": "test.beancount", "lineno": 5}, + date=date(2024, 1, 1), + account="Assets:Cash", + source_account="Equity:Opening-Balances", + ) + + +@pytest.fixture(name="sample_note") +def fixture_sample_note(): + return Note( + tags=None, + links=None, + meta={"filename": "test.beancount", "lineno": 6}, + date=date(2024, 1, 1), + account="Assets:Cash", + comment="Test note", + ) + + +@pytest.fixture(name="sample_event") +def fixture_sample_event(): + return Event( + meta={"filename": "test.beancount", "lineno": 7}, + date=date(2024, 1, 1), + type="location", + description="New York", + ) + + +@pytest.fixture(name="sample_query") +def fixture_sample_query(): + return Query( + meta={"filename": "test.beancount", "lineno": 8}, + date=date(2024, 1, 1), + name="test_query", + query_string="SELECT account, sum(position) GROUP BY account", + ) + + +@pytest.fixture(name="sample_price") +def fixture_sample_price(): + return Price( + meta={"filename": "test.beancount", "lineno": 9}, + date=date(2024, 1, 1), + currency="AAPL", + amount=Amount(Decimal("150.00"), "USD"), + ) + + +@pytest.fixture(name="sample_document") +def fixture_sample_document(): + return Document( + meta={"filename": "test.beancount", "lineno": 10}, + date=date(2024, 1, 1), + account="Assets:Cash", + filename="receipt.pdf", + tags=EMPTY_SET, + links=EMPTY_SET, + ) + + +@pytest.fixture(name="sample_custom") +def fixture_sample_custom(): + return Custom( + meta={"filename": "test.beancount", "lineno": 11}, + date=date(2024, 1, 1), + type="budget", + values=["Assets:Cash", Amount(Decimal("1000"), "USD")], + ) + + +@pytest.fixture(name="sample_commodity") +def fixture_sample_commodity(): + return Commodity( + meta={"filename": "test.beancount", "lineno": 12}, + date=date(2024, 1, 1), + currency="USD", + ) + + +class TestMutableTransaction: + def test_construction(self, sample_transaction): + mutable = MutableTransaction(sample_transaction, id="txn_1") + assert mutable.id == "txn_1" + assert mutable.original == sample_transaction + assert not mutable.dirty() + + def test_attribute_access(self, sample_transaction): + mutable = MutableTransaction(sample_transaction) + assert mutable.date == date(2024, 1, 1) + assert mutable.flag == "*" + assert mutable.payee == "Test Payee" + assert mutable.narration == "Test transaction" + + def test_attribute_modification(self, sample_transaction): + mutable = MutableTransaction(sample_transaction) + mutable.narration = "Modified narration" + assert mutable.narration == "Modified narration" + assert mutable.dirty() + + def test_round_trip_conversion(self, sample_transaction): + mutable = MutableTransaction(sample_transaction) + converted_back = mutable.to_immutable() + assert converted_back == sample_transaction + + def test_reset_method(self, sample_transaction): + mutable = MutableTransaction(sample_transaction) + + # Modify some attributes + mutable.narration = "Modified narration" + mutable.payee = "Modified payee" + assert mutable.dirty() + + # Reset changes + mutable.reset() + assert not mutable.dirty() + assert mutable.narration == sample_transaction.narration + assert mutable.payee == sample_transaction.payee + + +class TestMutableOpen: + def test_construction(self, sample_open): + mutable = MutableOpen(sample_open, id="open_1") + assert mutable.id == "open_1" + assert mutable.original == sample_open + assert mutable.account == "Assets:Cash" + + def test_attribute_modification(self, sample_open): + mutable = MutableOpen(sample_open) + mutable.account = "Assets:Bank" + assert mutable.account == "Assets:Bank" + assert mutable.dirty() + + def test_round_trip_conversion(self, sample_open): + mutable = MutableOpen(sample_open) + converted_back = mutable.to_immutable() + assert converted_back == sample_open + + def test_reset_method(self, sample_open): + mutable = MutableOpen(sample_open) + + # Modify attribute + mutable.account = "Assets:Bank" + assert mutable.dirty() + + # Reset changes + mutable.reset() + assert not mutable.dirty() + assert mutable.account == sample_open.account + + +class TestMutableClose: + def test_construction(self, sample_close): + mutable = MutableClose(sample_close, id="close_1") + assert mutable.id == "close_1" + assert mutable.original == sample_close + assert mutable.account == "Assets:Cash" + + def test_attribute_modification(self, sample_close): + mutable = MutableClose(sample_close) + mutable.date = date(2024, 6, 30) + assert mutable.date == date(2024, 6, 30) + assert mutable.dirty() + + def test_round_trip_conversion(self, sample_close): + mutable = MutableClose(sample_close) + converted_back = mutable.to_immutable() + assert converted_back == sample_close + + def test_reset_method(self, sample_close): + mutable = MutableClose(sample_close) + + # Modify attribute + mutable.date = date(2024, 6, 30) + assert mutable.dirty() + + # Reset changes + mutable.reset() + assert not mutable.dirty() + assert mutable.date == sample_close.date + + +class TestMutableBalance: + def test_construction(self, sample_balance): + mutable = MutableBalance(sample_balance, id="balance_1") + assert mutable.id == "balance_1" + assert mutable.original == sample_balance + assert mutable.account == "Assets:Cash" + + def test_attribute_modification(self, sample_balance): + mutable = MutableBalance(sample_balance) + new_amount = Amount(Decimal("2000"), "USD") + mutable.amount = new_amount + assert mutable.amount == new_amount + assert mutable.dirty() + + def test_round_trip_conversion(self, sample_balance): + mutable = MutableBalance(sample_balance) + converted_back = mutable.to_immutable() + assert converted_back == sample_balance + + def test_reset_method(self, sample_balance): + mutable = MutableBalance(sample_balance) + + # Modify attribute + new_amount = Amount(Decimal("2000"), "USD") + mutable.amount = new_amount + assert mutable.dirty() + + # Reset changes + mutable.reset() + assert not mutable.dirty() + assert mutable.amount == sample_balance.amount + + +class TestMutablePad: + def test_construction(self, sample_pad): + mutable = MutablePad(sample_pad, id="pad_1") + assert mutable.id == "pad_1" + assert mutable.original == sample_pad + assert mutable.account == "Assets:Cash" + + def test_attribute_modification(self, sample_pad): + mutable = MutablePad(sample_pad) + mutable.source_account = "Equity:Opening-Balance" + assert mutable.source_account == "Equity:Opening-Balance" + assert mutable.dirty() + + def test_round_trip_conversion(self, sample_pad): + mutable = MutablePad(sample_pad) + converted_back = mutable.to_immutable() + assert converted_back == sample_pad + + def test_reset_method(self, sample_pad): + mutable = MutablePad(sample_pad) + + # Modify attribute + mutable.source_account = "Equity:Opening-Balance" + assert mutable.dirty() + + # Reset changes + mutable.reset() + assert not mutable.dirty() + assert mutable.source_account == sample_pad.source_account + + +class TestMutableNote: + def test_construction(self, sample_note): + mutable = MutableNote(sample_note, id="note_1") + assert mutable.id == "note_1" + assert mutable.original == sample_note + assert mutable.comment == "Test note" + + def test_attribute_modification(self, sample_note): + mutable = MutableNote(sample_note) + mutable.comment = "Modified note" + assert mutable.comment == "Modified note" + assert mutable.dirty() + + def test_round_trip_conversion(self, sample_note): + mutable = MutableNote(sample_note) + converted_back = mutable.to_immutable() + assert converted_back == sample_note + + def test_reset_method(self, sample_note): + mutable = MutableNote(sample_note) + + # Modify attribute + mutable.comment = "Modified note" + assert mutable.dirty() + + # Reset changes + mutable.reset() + assert not mutable.dirty() + assert mutable.comment == sample_note.comment + + +class TestMutableEvent: + def test_construction(self, sample_event): + mutable = MutableEvent(sample_event, id="event_1") + assert mutable.id == "event_1" + assert mutable.original == sample_event + assert mutable.type == "location" + + def test_attribute_modification(self, sample_event): + mutable = MutableEvent(sample_event) + mutable.description = "San Francisco" + assert mutable.description == "San Francisco" + assert mutable.dirty() + + def test_round_trip_conversion(self, sample_event): + mutable = MutableEvent(sample_event) + converted_back = mutable.to_immutable() + assert converted_back == sample_event + + def test_reset_method(self, sample_event): + mutable = MutableEvent(sample_event) + + # Modify attribute + mutable.description = "San Francisco" + assert mutable.dirty() + + # Reset changes + mutable.reset() + assert not mutable.dirty() + assert mutable.description == sample_event.description + + +class TestMutableQuery: + def test_construction(self, sample_query): + mutable = MutableQuery(sample_query, id="query_1") + assert mutable.id == "query_1" + assert mutable.original == sample_query + assert mutable.name == "test_query" + + def test_attribute_modification(self, sample_query): + mutable = MutableQuery(sample_query) + mutable.name = "modified_query" + assert mutable.name == "modified_query" + assert mutable.dirty() + + def test_round_trip_conversion(self, sample_query): + mutable = MutableQuery(sample_query) + converted_back = mutable.to_immutable() + assert converted_back == sample_query + + def test_reset_method(self, sample_query): + mutable = MutableQuery(sample_query) + + # Modify attribute + mutable.name = "modified_query" + assert mutable.dirty() + + # Reset changes + mutable.reset() + assert not mutable.dirty() + assert mutable.name == sample_query.name + + +class TestMutablePrice: + def test_construction(self, sample_price): + mutable = MutablePrice(sample_price, id="price_1") + assert mutable.id == "price_1" + assert mutable.original == sample_price + assert mutable.currency == "AAPL" + + def test_attribute_modification(self, sample_price): + mutable = MutablePrice(sample_price) + new_amount = Amount(Decimal("155.00"), "USD") + mutable.amount = new_amount + assert mutable.amount == new_amount + assert mutable.dirty() + + def test_round_trip_conversion(self, sample_price): + mutable = MutablePrice(sample_price) + converted_back = mutable.to_immutable() + assert converted_back == sample_price + + def test_reset_method(self, sample_price): + mutable = MutablePrice(sample_price) + + # Modify attribute + new_amount = Amount(Decimal("155.00"), "USD") + mutable.amount = new_amount + assert mutable.dirty() + + # Reset changes + mutable.reset() + assert not mutable.dirty() + assert mutable.amount == sample_price.amount + + +class TestMutableDocument: + def test_construction(self, sample_document): + mutable = MutableDocument(sample_document, id="doc_1") + assert mutable.id == "doc_1" + assert mutable.original == sample_document + assert mutable.filename == "receipt.pdf" + + def test_attribute_modification(self, sample_document): + mutable = MutableDocument(sample_document) + mutable.filename = "invoice.pdf" + assert mutable.filename == "invoice.pdf" + assert mutable.dirty() + + def test_round_trip_conversion(self, sample_document): + mutable = MutableDocument(sample_document) + converted_back = mutable.to_immutable() + assert converted_back == sample_document + + def test_reset_method(self, sample_document): + mutable = MutableDocument(sample_document) + + # Modify attribute + mutable.filename = "invoice.pdf" + assert mutable.dirty() + + # Reset changes + mutable.reset() + assert not mutable.dirty() + assert mutable.filename == sample_document.filename + + +class TestMutableCustom: + def test_construction(self, sample_custom): + mutable = MutableCustom(sample_custom, id="custom_1") + assert mutable.id == "custom_1" + assert mutable.original == sample_custom + assert mutable.type == "budget" + + def test_attribute_modification(self, sample_custom): + mutable = MutableCustom(sample_custom) + mutable.type = "forecast" + assert mutable.type == "forecast" + assert mutable.dirty() + + def test_round_trip_conversion(self, sample_custom): + mutable = MutableCustom(sample_custom) + converted_back = mutable.to_immutable() + assert converted_back == sample_custom + + def test_reset_method(self, sample_custom): + mutable = MutableCustom(sample_custom) + + # Modify attribute + mutable.type = "forecast" + assert mutable.dirty() + + # Reset changes + mutable.reset() + assert not mutable.dirty() + assert mutable.type == sample_custom.type + + +class TestMutableCommodity: + def test_construction(self, sample_commodity): + mutable = MutableCommodity(sample_commodity, id="commodity_1") + assert mutable.id == "commodity_1" + assert mutable.original == sample_commodity + assert mutable.currency == "USD" + + def test_attribute_modification(self, sample_commodity): + mutable = MutableCommodity(sample_commodity) + mutable.currency = "EUR" + assert mutable.currency == "EUR" + assert mutable.dirty() + + def test_round_trip_conversion(self, sample_commodity): + mutable = MutableCommodity(sample_commodity) + converted_back = mutable.to_immutable() + assert converted_back == sample_commodity + + def test_reset_method(self, sample_commodity): + mutable = MutableCommodity(sample_commodity) + + # Modify attribute + mutable.currency = "EUR" + assert mutable.dirty() + + # Reset changes + mutable.reset() + assert not mutable.dirty() + assert mutable.currency == sample_commodity.currency + + +class TestMutableDirectiveDirty: + def test_initially_not_dirty(self, sample_transaction): + mutable = MutableTransaction(sample_transaction) + assert not mutable.dirty() + + def test_dirty_after_modification(self, sample_transaction): + mutable = MutableTransaction(sample_transaction) + mutable.narration = "Modified narration" + assert mutable.dirty() + + def test_not_dirty_after_reverting_to_original(self, sample_transaction): + mutable = MutableTransaction(sample_transaction) + original_narration = mutable.narration + + # Modify and verify it's dirty + mutable.narration = "Modified" + assert mutable.dirty() + + # Revert to original value + mutable.narration = original_narration + assert not mutable.dirty() + + def test_dirty_with_multiple_changes(self, sample_transaction): + mutable = MutableTransaction(sample_transaction) + mutable.narration = "Modified narration" + mutable.payee = "Modified payee" + assert mutable.dirty() + + def test_partially_reverted_still_dirty(self, sample_transaction): + mutable = MutableTransaction(sample_transaction) + original_narration = mutable.narration + + # Make multiple changes + mutable.narration = "Modified narration" + mutable.payee = "Modified payee" + assert mutable.dirty() + + # Revert one change, should still be dirty + mutable.narration = original_narration + assert mutable.dirty() + + +class TestMutableDirectiveBase: + def test_invalid_attribute_access(self, sample_transaction): + mutable = MutableTransaction(sample_transaction) + with pytest.raises(AttributeError): + _ = mutable.nonexistent_attribute + + def test_invalid_attribute_modification(self, sample_transaction): + mutable = MutableTransaction(sample_transaction) + with pytest.raises(AttributeError): + mutable.nonexistent_attribute = "value" + + def test_revert_to_original_value(self, sample_transaction): + mutable = MutableTransaction(sample_transaction) + original_narration = mutable.narration + + # Modify the attribute + mutable.narration = "Modified" + assert mutable.dirty() + + # Verify change is reflected in conversion + converted = mutable.to_immutable() + assert converted.narration == "Modified" + + # Revert to original value + mutable.narration = original_narration + assert not mutable.dirty() + + # Verify revert is reflected in conversion + converted = mutable.to_immutable() + assert converted.narration == original_narration + + def test_directive_property_readonly(self, sample_transaction): + mutable = MutableTransaction(sample_transaction) + + # Modify attribute and verify directive property reflects the change + mutable.narration = "Changed" + directive = mutable.original + assert directive.narration != "Changed" + assert directive == sample_transaction # Should not be different from original + + # Verify original directive is unchanged + assert sample_transaction.narration == "Test transaction" diff --git a/src/beanbot/ledger/ledger.py b/src/beanbot/ledger/ledger.py index 29b419d..dffebab 100644 --- a/src/beanbot/ledger/ledger.py +++ b/src/beanbot/ledger/ledger.py @@ -7,6 +7,7 @@ from beancount import Directive, load_file from beancount.parser.printer import EntryPrinter +from beanbot.data.constants import METADATA_BBID from beanbot.ledger.text_editor import ChangeSet, ChangeType, TextEditor from beanbot.utils import logger from beanbot.utils.id_generator import IDGenerator @@ -20,8 +21,6 @@ class Ledger: mechanism to ensure that entries can be uniquely identified and tracked across reloads.""" - HASH_ATTR = "bbid" - def __init__(self, main_file: str) -> None: self._main_file = main_file @@ -38,7 +37,7 @@ def _init_state(self) -> None: self._changed_entries = {} # Maps existing entry IDs to new entries self._deleted_entries = {} # Maps existing entry IDs to the original entries - self._id_generator = IDGenerator() + self.id_generator = IDGenerator() def add(self, entry: Directive) -> str: """Add a new entry to the ledger. @@ -53,8 +52,10 @@ def add(self, entry: Directive) -> str: self._new_entries[entry_id] = entry return entry_id - def remove(self, entry_id: str) -> bool: - """Remove an entry from the ledger. + def delete(self, entry_id: str) -> bool: + """Mark an entry for deletion. + + The entry will be effectively removed when `save()` is called. Returns: True if the entry was found and removed, False otherwise.""" @@ -73,7 +74,7 @@ def remove(self, entry_id: str) -> bool: found = True del self._changed_entries[entry_id] - self._id_generator.unregister(entry_id) + self.id_generator.unregister(entry_id) if not found: logger.warning( @@ -83,7 +84,9 @@ def remove(self, entry_id: str) -> bool: return found def replace(self, entry_id: str, entry_new: Directive) -> str | None: - """Replace an existing entry with a new one. + """Mark an existing entry for replacement. + + The entry will be effectively replaced when `save()` is called. Returns: If there is no entry found with `entry_id`, returns None. @@ -270,16 +273,15 @@ def _get_or_load_entry_id(self, entry: Directive) -> tuple[str, bool]: """ assert isinstance(entry, Directive), "Entry must be a Beancount directive" - meta_bbid = entry.meta.get(self.HASH_ATTR, None) + meta_bbid = entry.meta.get(METADATA_BBID, None) if meta_bbid is not None: - self._id_generator.register(meta_bbid) + self.id_generator.register(meta_bbid) return meta_bbid, False - new_id = self._id_generator.generate() - entry.meta[self.HASH_ATTR] = new_id + new_id = self.id_generator.generate() + entry.meta[METADATA_BBID] = new_id return new_id, True - @property def dirty(self) -> bool: """Check if the ledger has unsaved changes.""" return ( @@ -287,3 +289,21 @@ def dirty(self) -> bool: or len(self._changed_entries) > 0 or len(self._deleted_entries) > 0 ) + + def has_entry(self, id: str) -> bool: + """Check if an entry with the given ID exists in the ledger. + + Args: + id: The ID of the entry to check. + + Returns: + True if the entry exists, False otherwise. + """ + if id in self._deleted_entries: + return False + + return ( + id in self._existing_entries + or id in self._new_entries + or id in self._changed_entries + ) diff --git a/src/beanbot/ledger/ledger_test.py b/src/beanbot/ledger/ledger_test.py index 23f4c0e..46da2ad 100644 --- a/src/beanbot/ledger/ledger_test.py +++ b/src/beanbot/ledger/ledger_test.py @@ -12,6 +12,7 @@ from beancount.core.number import D from beancount.loader import load_file +from beanbot.data.constants import METADATA_BBID from beanbot.ledger.ledger import Ledger @@ -98,7 +99,7 @@ def test_init_loads_ledger(ledger): # Check that entries were loaded assert len(ledger._existing_entries) > 0 assert ledger._options_map is not None - assert not ledger.dirty + assert not ledger.dirty() def test_add_entry(ledger, sample_transaction): @@ -110,7 +111,7 @@ def test_add_entry(ledger, sample_transaction): # Check that entry was added assert isinstance(entry_id, str) assert entry_id in ledger._new_entries - assert ledger.dirty + assert ledger.dirty() # Check that existing entries count hasn't changed assert len(ledger._existing_entries) == initial_count @@ -122,13 +123,13 @@ def test_remove_existing_entry(ledger): existing_entry_id = next(iter(ledger._existing_entries.keys())) original_entry = ledger._existing_entries[existing_entry_id] - result = ledger.remove(existing_entry_id) + result = ledger.delete(existing_entry_id) # Check that entry was removed assert result is True assert existing_entry_id in ledger._deleted_entries assert ledger._deleted_entries[existing_entry_id] == original_entry - assert ledger.dirty + assert ledger.dirty() def test_remove_new_entry(ledger, sample_transaction): @@ -141,20 +142,20 @@ def test_remove_new_entry(ledger, sample_transaction): existing_entries_before = ledger._existing_entries.copy() # Remove the new entry - result = ledger.remove(entry_id) + result = ledger.delete(entry_id) # Check that entry was removed assert result is True assert entry_id not in ledger._new_entries assert entry_id not in ledger._deleted_entries - assert ledger.dirty is False + assert ledger.dirty() is False # Ensure existing entries are untouched assert ledger._existing_entries == existing_entries_before def test_remove_nonexistent_entry(ledger): """Test removing a non-existent entry.""" - result = ledger.remove("nonexistent_id") + result = ledger.delete("nonexistent_id") # Check that removal failed gracefully assert result is False @@ -170,7 +171,7 @@ def test_replace_existing_entry(ledger, sample_transaction): # Check that entry was replaced assert isinstance(new_entry_id, str) assert existing_entry_id in ledger._changed_entries - assert ledger.dirty + assert ledger.dirty() def test_replace_nonexistent_entry(ledger, sample_transaction): @@ -185,11 +186,11 @@ def test_replace_nonexistent_entry(ledger, sample_transaction): def test_dirty_property(ledger, sample_transaction): """Test the dirty property.""" # Initially should not be dirty - assert not ledger.dirty + assert not ledger.dirty() # Adding an entry should make it dirty ledger.add(sample_transaction) - assert ledger.dirty + assert ledger.dirty() @patch("beanbot.ledger.ledger.TextEditor") @@ -201,7 +202,7 @@ def test_save_with_changes(mock_text_editor, ledger, sample_transaction): # Add a new entry entry_id = ledger.add(sample_transaction) - assert ledger.dirty + assert ledger.dirty() # Save changes ledger.save() @@ -236,7 +237,7 @@ def test_save_with_changes(mock_text_editor, ledger, sample_transaction): mock_editor_instance.save_changes.assert_called() # Check that state was updated - assert not ledger.dirty + assert not ledger.dirty() assert entry_id in ledger._existing_entries assert len(ledger._new_entries) == 0 @@ -276,13 +277,13 @@ def test_reload_ledger(ledger, sample_transaction): # Add some changes to make it dirty ledger.add(sample_transaction) - assert ledger.dirty + assert ledger.dirty() # Reload ledger.load() # Check that state was reset - assert not ledger.dirty + assert not ledger.dirty() assert len(ledger._existing_entries) == initial_entries_count assert len(ledger._new_entries) == 0 assert len(ledger._changed_entries) == 0 @@ -306,7 +307,7 @@ def test_full_workflow(self, ledger, sample_transaction): # Add entry entry_id = ledger.add(sample_transaction) - assert ledger.dirty + assert ledger.dirty() assert entry_id in ledger._new_entries # Replace entry @@ -322,16 +323,16 @@ def test_full_workflow(self, ledger, sample_transaction): ) # Since it's a new entry, we need to remove and add again - ledger.remove(entry_id) + ledger.delete(entry_id) new_entry_id = ledger.add(modified_transaction) assert new_entry_id in ledger._new_entries assert ledger._new_entries[new_entry_id].payee == "Modified Payee" # Remove the entry - ledger.remove(new_entry_id) + ledger.delete(new_entry_id) assert new_entry_id not in ledger._new_entries - assert not ledger.dirty + assert not ledger.dirty() # Final state should be the same as initial assert len(ledger._existing_entries) == initial_count @@ -349,7 +350,7 @@ def test_real_file_add_transaction(self, main_file, sample_transaction): # Add a new transaction entry_id = ledger.add(sample_transaction) - assert ledger.dirty + assert ledger.dirty() from beancount.parser.printer import EntryPrinter @@ -363,7 +364,7 @@ def test_real_file_add_transaction(self, main_file, sample_transaction): ledger.save() # Verify state after save - assert not ledger.dirty + assert not ledger.dirty() assert entry_id in ledger._existing_entries assert len(ledger._new_entries) == 0 assert len(ledger._existing_entries) == initial_count + 1 @@ -449,14 +450,14 @@ def test_real_file_modify_transaction(self, main_file, sample_transaction): # Replace the entry new_entry_id = ledger.replace(existing_entry_id, modified_transaction) - assert ledger.dirty + assert ledger.dirty() assert new_entry_id is not None # Save changes (this uses real TextEditor) ledger.save() # Verify state after save - assert not ledger.dirty + assert not ledger.dirty() assert new_entry_id in ledger._existing_entries assert new_entry_id == existing_entry_id assert len(ledger._changed_entries) == 0 @@ -520,16 +521,16 @@ def test_real_file_delete_transaction(self, main_file): ) # Remove the entry - result = ledger.remove(existing_entry_id) + result = ledger.delete(existing_entry_id) assert result is True - assert ledger.dirty + assert ledger.dirty() assert existing_entry_id in ledger._deleted_entries # Save changes (this uses real TextEditor) ledger.save() # Verify state after save - assert not ledger.dirty + assert not ledger.dirty() assert existing_entry_id not in ledger._existing_entries assert len(ledger._deleted_entries) == 0 assert len(ledger._existing_entries) == initial_count - 1 @@ -554,16 +555,16 @@ def test_load_file_without_bbids(self, main_file_noid): ledger = Ledger(main_file_noid) # Should be dirty because IDs were automatically allocated - assert ledger.dirty + assert ledger.dirty() # Should have existing entries assert len(ledger._existing_entries) > 0 # All entries should have bbid metadata for entry in ledger._changed_entries.values(): - assert Ledger.HASH_ATTR in entry.meta - assert isinstance(entry.meta[Ledger.HASH_ATTR], str) - assert len(entry.meta[Ledger.HASH_ATTR]) > 0 + assert METADATA_BBID in entry.meta + assert isinstance(entry.meta[METADATA_BBID], str) + assert len(entry.meta[METADATA_BBID]) > 0 # Changed entries should contain all entries (due to ID allocation) assert len(ledger._changed_entries) == len(ledger._existing_entries) @@ -577,7 +578,7 @@ def test_save_and_reload_preserves_allocated_ids(self, main_file_noid): # Load the file (should be dirty due to ID allocation) ledger = Ledger(str(temp_file)) - assert ledger.dirty + assert ledger.dirty() initial_entry_count = len(ledger._existing_entries) @@ -585,13 +586,13 @@ def test_save_and_reload_preserves_allocated_ids(self, main_file_noid): ledger.save() # After saving, should not be dirty - assert not ledger.dirty + assert not ledger.dirty() # Reload the file ledger_reloaded = Ledger(str(temp_file)) # After reload, should not be dirty (IDs are now in the file) - assert not ledger_reloaded.dirty + assert not ledger_reloaded.dirty() # Should have the same number of entries assert len(ledger_reloaded._existing_entries) == initial_entry_count @@ -614,11 +615,11 @@ def test_id_allocation_stability_across_multiple_loads(self, main_file_noid): # IDs should be identical assert first_load_ids == second_load_ids - assert not ledger2.dirty + assert not ledger2.dirty() # Third load to be extra sure ledger3 = Ledger(str(temp_file)) third_load_ids = set(ledger3._existing_entries.keys()) assert first_load_ids == third_load_ids - assert not ledger3.dirty + assert not ledger3.dirty() diff --git a/src/beanbot/query/__init__.py b/src/beanbot/query/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/beanbot/query/condition.py b/src/beanbot/query/condition.py new file mode 100644 index 0000000..4032958 --- /dev/null +++ b/src/beanbot/query/condition.py @@ -0,0 +1,307 @@ +import re +from abc import ABC, abstractmethod +from typing import Any, Callable + + +class Condition(ABC): + """Abstract base class for query conditions.""" + + @abstractmethod + def evaluate(self, entry: Any) -> bool: + """Evaluate the condition against an entry. + + Args: + entry: The entry to evaluate the condition against. + + Returns: + bool: True if the condition is satisfied, False otherwise. + """ + pass + + @abstractmethod + def explain(self, entry: Any) -> str: + """Explain why the condition evaluates to true or false. + + Args: + entry: The entry to evaluate the condition against. + + Returns: + str: A human-readable explanation of the evaluation. + """ + pass + + @abstractmethod + def __str__(self) -> str: + """Return a human-readable string representation of the condition.""" + pass + + def __repr__(self) -> str: + """Return a detailed string representation of the condition.""" + return str(self) + + def __and__(self, other: "Condition") -> "AndCondition": + """Combine conditions with logical AND (&).""" + return AndCondition(self, other) + + def __or__(self, other: "Condition") -> "OrCondition": + """Combine conditions with logical OR (|).""" + return OrCondition(self, other) + + def __invert__(self) -> "NotCondition": + """Negate condition with logical NOT (~).""" + return NotCondition(self) + + +class FieldCondition(Condition): + """Condition that evaluates a field against a value using an operator.""" + + def __init__(self, field: str, operator: str, value: Any): + """Initialize a field condition. + + Args: + field: The field name to evaluate. + operator: The comparison operator ('eq', 'ne', 'lt', 'le', 'gt', 'ge', 'in', 'contains', 'regex'). + value: The value to compare against. + """ + self.field = field + self.operator = operator + self.value = value + + # Compile regex pattern if using regex operator + if self.operator == "regex": + self.pattern = re.compile(self.value) + + def evaluate(self, entry: Any) -> bool: + """Evaluate the field condition against an entry.""" + field_value = getattr(entry, self.field, None) + + if self.operator == "eq": + return field_value == self.value + elif self.operator == "ne": + return field_value != self.value + elif self.operator == "lt": + return field_value < self.value + elif self.operator == "le": + return field_value <= self.value + elif self.operator == "gt": + return field_value > self.value + elif self.operator == "ge": + return field_value >= self.value + elif self.operator == "in": + return field_value in self.value + elif self.operator == "contains": + return self.value in field_value + elif self.operator == "regex": + return bool(self.pattern.search(str(field_value))) + else: + raise ValueError(f"Unsupported operator: {self.operator}") + + def explain(self, entry: Any) -> str: + """Explain why the field condition evaluates to true or false.""" + field_value = getattr(entry, self.field, None) + result = self.evaluate(entry) + + operator_symbols = { + "eq": "==", + "ne": "!=", + "lt": "<", + "le": "<=", + "gt": ">", + "ge": ">=", + "in": "in", + "contains": "contains", + "regex": "matches", + } + + symbol = operator_symbols.get(self.operator, self.operator) + + if self.operator == "contains": + explanation = f"'{self.value}' {symbol} '{field_value}'" + elif self.operator == "regex": + explanation = f"'{field_value}' {symbol} pattern '{self.value}'" + else: + explanation = f"'{field_value}' {symbol} '{self.value}'" + + return f"{self.field}: {explanation} → {result}" + + def __str__(self) -> str: + """Return a human-readable string representation.""" + operator_symbols = { + "eq": "==", + "ne": "!=", + "lt": "<", + "le": "<=", + "gt": ">", + "ge": ">=", + "in": "in", + "contains": "contains", + "regex": "~", + } + + symbol = operator_symbols.get(self.operator, self.operator) + + if self.operator == "contains": + return f"'{self.value}' {symbol} {self.field}" + elif self.operator == "regex": + return f"{self.field} {symbol} /{self.value}/" + else: + return f"{self.field} {symbol} '{self.value}'" + + +class CustomCondition(Condition): + """Condition that uses a custom evaluation function.""" + + def __init__(self, func: Callable[[Any], bool], description: str = None): + """Initialize a custom condition. + + Args: + func: A function that takes an entry and returns a boolean. + description: Optional description of what the function does. + """ + self.func = func + self.description = description or "custom function" + + def evaluate(self, entry: Any) -> bool: + """Evaluate the custom condition against an entry.""" + return self.func(entry) + + def explain(self, entry: Any) -> str: + """Explain why the custom condition evaluates to true or false.""" + result = self.evaluate(entry) + return f"{self.description} → {result}" + + def __str__(self) -> str: + """Return a human-readable string representation.""" + return f"custom({self.description})" + + +class AndCondition(Condition): + """Condition that combines two conditions with logical AND.""" + + def __init__(self, left: Condition, right: Condition): + self.left = left + self.right = right + + def evaluate(self, entry: Any) -> bool: + """Evaluate both conditions and return True if both are True.""" + return self.left.evaluate(entry) and self.right.evaluate(entry) + + def explain(self, entry: Any) -> str: + """Explain why the AND condition evaluates to true or false.""" + left_result = self.left.evaluate(entry) + right_result = self.right.evaluate(entry) + result = left_result and right_result + + left_explain = self.left.explain(entry) + right_explain = self.right.explain(entry) + + return ( + f"({left_explain}) AND ({right_explain}) → " + f"{left_result} AND {right_result} → {result}" + ) + + def __str__(self) -> str: + """Return a human-readable string representation.""" + return f"({self.left}) AND ({self.right})" + + +class OrCondition(Condition): + """Condition that combines two conditions with logical OR.""" + + def __init__(self, left: Condition, right: Condition): + self.left = left + self.right = right + + def evaluate(self, entry: Any) -> bool: + """Evaluate both conditions and return True if either is True.""" + return self.left.evaluate(entry) or self.right.evaluate(entry) + + def explain(self, entry: Any) -> str: + """Explain why the OR condition evaluates to true or false.""" + left_result = self.left.evaluate(entry) + right_result = self.right.evaluate(entry) + result = left_result or right_result + + left_explain = self.left.explain(entry) + right_explain = self.right.explain(entry) + + return ( + f"({left_explain}) OR ({right_explain}) → " + f"{left_result} OR {right_result} → {result}" + ) + + def __str__(self) -> str: + """Return a human-readable string representation.""" + return f"({self.left}) OR ({self.right})" + + +class NotCondition(Condition): + """Condition that negates another condition.""" + + def __init__(self, condition: Condition): + self.condition = condition + + def evaluate(self, entry: Any) -> bool: + """Evaluate the condition and return its negation.""" + return not self.condition.evaluate(entry) + + def explain(self, entry: Any) -> str: + """Explain why the NOT condition evaluates to true or false.""" + inner_result = self.condition.evaluate(entry) + result = not inner_result + inner_explain = self.condition.explain(entry) + + return f"NOT ({inner_explain}) → NOT {inner_result} → {result}" + + def __str__(self) -> str: + """Return a human-readable string representation.""" + return f"NOT ({self.condition})" + + +# Convenience functions for creating conditions +def field(name: str) -> "FieldConditionBuilder": + """Create a field condition builder.""" + return FieldConditionBuilder(name) + + +class FieldConditionBuilder: + """Builder class for creating field conditions with fluent syntax.""" + + def __init__(self, field_name: str): + self.field_name = field_name + + def eq(self, value: Any) -> FieldCondition: + """Create an equality condition.""" + return FieldCondition(self.field_name, "eq", value) + + def ne(self, value: Any) -> FieldCondition: + """Create a not-equal condition.""" + return FieldCondition(self.field_name, "ne", value) + + def lt(self, value: Any) -> FieldCondition: + """Create a less-than condition.""" + return FieldCondition(self.field_name, "lt", value) + + def le(self, value: Any) -> FieldCondition: + """Create a less-than-or-equal condition.""" + return FieldCondition(self.field_name, "le", value) + + def gt(self, value: Any) -> FieldCondition: + """Create a greater-than condition.""" + return FieldCondition(self.field_name, "gt", value) + + def ge(self, value: Any) -> FieldCondition: + """Create a greater-than-or-equal condition.""" + return FieldCondition(self.field_name, "ge", value) + + def in_(self, values: list) -> FieldCondition: + """Create an 'in' condition.""" + return FieldCondition(self.field_name, "in", values) + + def contains(self, value: Any) -> FieldCondition: + """Create a 'contains' condition.""" + return FieldCondition(self.field_name, "contains", value) + + def regex(self, pattern: str) -> FieldCondition: + """Create a regex condition.""" + return FieldCondition(self.field_name, "regex", pattern) diff --git a/src/beanbot/query/condition_test.py b/src/beanbot/query/condition_test.py new file mode 100644 index 0000000..67efb7b --- /dev/null +++ b/src/beanbot/query/condition_test.py @@ -0,0 +1,545 @@ +import re +from dataclasses import dataclass + +import pytest + +from .condition import ( + AndCondition, + CustomCondition, + FieldCondition, + FieldConditionBuilder, + NotCondition, + OrCondition, + field, +) + + +@dataclass +class MockEntry: + """Mock entry for testing conditions.""" + + amount: float + account: str + description: str + payee: str + year: int + + +class TestFieldCondition: + """Test FieldCondition class.""" + + def setup_method(self): + """Set up test data.""" + self.entry = MockEntry( + amount=150.50, + account="Assets:Cash", + description="Coffee transfer payment", + payee="john doe", + year=2024, + ) + + def test_eq_operator_true(self): + condition = FieldCondition("amount", "eq", 150.50) + assert condition.evaluate(self.entry) is True + + def test_eq_operator_false(self): + condition = FieldCondition("amount", "eq", 100.00) + assert condition.evaluate(self.entry) is False + + def test_ne_operator_true(self): + condition = FieldCondition("amount", "ne", 100.00) + assert condition.evaluate(self.entry) is True + + def test_ne_operator_false(self): + condition = FieldCondition("amount", "ne", 150.50) + assert condition.evaluate(self.entry) is False + + def test_lt_operator_true(self): + condition = FieldCondition("amount", "lt", 200.00) + assert condition.evaluate(self.entry) is True + + def test_lt_operator_false(self): + condition = FieldCondition("amount", "lt", 100.00) + assert condition.evaluate(self.entry) is False + + def test_le_operator_true(self): + condition = FieldCondition("amount", "le", 150.50) + assert condition.evaluate(self.entry) is True + + def test_le_operator_false(self): + condition = FieldCondition("amount", "le", 100.00) + assert condition.evaluate(self.entry) is False + + def test_gt_operator_true(self): + condition = FieldCondition("amount", "gt", 100.00) + assert condition.evaluate(self.entry) is True + + def test_gt_operator_false(self): + condition = FieldCondition("amount", "gt", 200.00) + assert condition.evaluate(self.entry) is False + + def test_ge_operator_true(self): + condition = FieldCondition("amount", "ge", 150.50) + assert condition.evaluate(self.entry) is True + + def test_ge_operator_false(self): + condition = FieldCondition("amount", "ge", 200.00) + assert condition.evaluate(self.entry) is False + + def test_in_operator_true(self): + condition = FieldCondition("account", "in", ["Assets:Cash", "Assets:Bank"]) + assert condition.evaluate(self.entry) is True + + def test_in_operator_false(self): + condition = FieldCondition("account", "in", ["Liabilities:CreditCard"]) + assert condition.evaluate(self.entry) is False + + def test_contains_operator_true(self): + condition = FieldCondition("description", "contains", "transfer") + assert condition.evaluate(self.entry) is True + + def test_contains_operator_false(self): + condition = FieldCondition("description", "contains", "salary") + assert condition.evaluate(self.entry) is False + + def test_regex_operator_true(self): + condition = FieldCondition("description", "regex", r"transfer") + assert condition.evaluate(self.entry) is True + + def test_regex_operator_false(self): + condition = FieldCondition("description", "regex", r"^salary") + assert condition.evaluate(self.entry) is False + + def test_regex_pattern_compilation(self): + condition = FieldCondition("payee", "regex", r"\b(john|jane)\b") + assert hasattr(condition, "pattern") + assert isinstance(condition.pattern, re.Pattern) + + def test_regex_case_sensitive(self): + condition = FieldCondition("payee", "regex", r"John") + assert condition.evaluate(self.entry) is False + + def test_regex_case_insensitive(self): + condition = FieldCondition("payee", "regex", r"(?i)John") + assert condition.evaluate(self.entry) is True + + def test_unsupported_operator(self): + with pytest.raises(ValueError, match="Unsupported operator: invalid"): + condition = FieldCondition("amount", "invalid", 100) + condition.evaluate(self.entry) + + def test_missing_field(self): + condition = FieldCondition("nonexistent", "eq", "value") + assert condition.evaluate(self.entry) is False + + +class TestCustomCondition: + """Test CustomCondition class.""" + + def setup_method(self): + self.entry = MockEntry( + amount=150.50, + account="Assets:Cash", + description="Coffee transfer payment", + payee="john doe", + year=2024, + ) + + def test_custom_function_true(self): + condition = CustomCondition(lambda entry: entry.year == 2024) + assert condition.evaluate(self.entry) is True + + def test_custom_function_false(self): + condition = CustomCondition(lambda entry: entry.year == 2023) + assert condition.evaluate(self.entry) is False + + def test_complex_custom_function(self): + condition = CustomCondition( + lambda entry: entry.amount > 100 and "Assets" in entry.account + ) + assert condition.evaluate(self.entry) is True + + +class TestLogicalConditions: + """Test logical combination conditions.""" + + def setup_method(self): + self.entry = MockEntry( + amount=150.50, + account="Assets:Cash", + description="Coffee transfer payment", + payee="john doe", + year=2024, + ) + + def test_and_condition_both_true(self): + left = FieldCondition("amount", "gt", 100) + right = FieldCondition("account", "contains", "Assets") + condition = AndCondition(left, right) + assert condition.evaluate(self.entry) is True + + def test_and_condition_one_false(self): + left = FieldCondition("amount", "gt", 200) + right = FieldCondition("account", "contains", "Assets") + condition = AndCondition(left, right) + assert condition.evaluate(self.entry) is False + + def test_or_condition_both_true(self): + left = FieldCondition("amount", "gt", 100) + right = FieldCondition("account", "contains", "Assets") + condition = OrCondition(left, right) + assert condition.evaluate(self.entry) is True + + def test_or_condition_one_true(self): + left = FieldCondition("amount", "gt", 200) + right = FieldCondition("account", "contains", "Assets") + condition = OrCondition(left, right) + assert condition.evaluate(self.entry) is True + + def test_or_condition_both_false(self): + left = FieldCondition("amount", "gt", 200) + right = FieldCondition("account", "contains", "Liabilities") + condition = OrCondition(left, right) + assert condition.evaluate(self.entry) is False + + def test_not_condition_true(self): + inner = FieldCondition("amount", "gt", 200) + condition = NotCondition(inner) + assert condition.evaluate(self.entry) is True + + def test_not_condition_false(self): + inner = FieldCondition("amount", "gt", 100) + condition = NotCondition(inner) + assert condition.evaluate(self.entry) is False + + +class TestOperatorOverloading: + """Test operator overloading (&, |, ~).""" + + def setup_method(self): + self.entry = MockEntry( + amount=150.50, + account="Assets:Cash", + description="Coffee transfer payment", + payee="john doe", + year=2024, + ) + + def test_and_operator(self): + condition1 = FieldCondition("amount", "gt", 100) + condition2 = FieldCondition("account", "contains", "Assets") + combined = condition1 & condition2 + + assert isinstance(combined, AndCondition) + assert combined.evaluate(self.entry) is True + + def test_or_operator(self): + condition1 = FieldCondition("amount", "gt", 200) + condition2 = FieldCondition("account", "contains", "Assets") + combined = condition1 | condition2 + + assert isinstance(combined, OrCondition) + assert combined.evaluate(self.entry) is True + + def test_not_operator(self): + condition = FieldCondition("amount", "gt", 200) + negated = ~condition + + assert isinstance(negated, NotCondition) + assert negated.evaluate(self.entry) is True + + def test_complex_combination(self): + condition1 = FieldCondition("amount", "gt", 100) + condition2 = FieldCondition("account", "contains", "Assets") + condition3 = FieldCondition("description", "in", ["salary", "bonus"]) + + # (amount > 100 & account contains "Assets") | description in ["salary", "bonus"] + combined = (condition1 & condition2) | condition3 + assert combined.evaluate(self.entry) is True + + +class TestFieldConditionBuilder: + """Test FieldConditionBuilder class.""" + + def setup_method(self): + self.entry = MockEntry( + amount=150.50, + account="Assets:Cash", + description="Coffee transfer payment", + payee="john doe", + year=2024, + ) + + def test_field_function(self): + builder = field("amount") + assert isinstance(builder, FieldConditionBuilder) + assert builder.field_name == "amount" + + def test_builder_eq(self): + condition = field("amount").eq(150.50) + assert isinstance(condition, FieldCondition) + assert condition.evaluate(self.entry) is True + + def test_builder_ne(self): + condition = field("amount").ne(100.00) + assert condition.evaluate(self.entry) is True + + def test_builder_lt(self): + condition = field("amount").lt(200.00) + assert condition.evaluate(self.entry) is True + + def test_builder_le(self): + condition = field("amount").le(150.50) + assert condition.evaluate(self.entry) is True + + def test_builder_gt(self): + condition = field("amount").gt(100.00) + assert condition.evaluate(self.entry) is True + + def test_builder_ge(self): + condition = field("amount").ge(150.50) + assert condition.evaluate(self.entry) is True + + def test_builder_in(self): + condition = field("account").in_(["Assets:Cash", "Assets:Bank"]) + assert condition.evaluate(self.entry) is True + + def test_builder_contains(self): + condition = field("description").contains("transfer") + assert condition.evaluate(self.entry) is True + + def test_builder_regex(self): + condition = field("description").regex(r"transfer") + assert condition.evaluate(self.entry) is True + + +class TestUsageExamples: + """Test the usage examples from the documentation.""" + + def setup_method(self): + self.entry1 = MockEntry( + amount=150.50, + account="Assets:Cash", + description="salary payment", + payee="employer", + year=2024, + ) + self.entry2 = MockEntry( + amount=50.00, + account="Liabilities:CreditCard", + description="bonus transfer", + payee="john smith", + year=2024, + ) + + def test_simple_field_conditions(self): + condition1 = field("amount").gt(100) + condition2 = field("account").eq("Assets:Cash") + + assert condition1.evaluate(self.entry1) is True + assert condition1.evaluate(self.entry2) is False + assert condition2.evaluate(self.entry1) is True + assert condition2.evaluate(self.entry2) is False + + def test_combining_conditions(self): + condition1 = field("amount").gt(100) + condition2 = field("account").eq("Assets:Cash") + + # AND + combined = condition1 & condition2 + assert combined.evaluate(self.entry1) is True + assert combined.evaluate(self.entry2) is False + + # OR + either = condition1 | condition2 + assert either.evaluate(self.entry1) is True + assert either.evaluate(self.entry2) is False + + # NOT + negated = ~condition1 + assert negated.evaluate(self.entry1) is False + assert negated.evaluate(self.entry2) is True + + def test_complex_combinations(self): + complex_condition = ( + field("amount").gt(100) & field("account").contains("Assets") + ) | field("description").in_(["salary", "bonus transfer"]) + + assert complex_condition.evaluate(self.entry1) is True # Both sides true + assert complex_condition.evaluate(self.entry2) is True # Right side true + + def test_custom_condition_example(self): + custom = CustomCondition(lambda entry: entry.year == 2024) + assert custom.evaluate(self.entry1) is True + assert custom.evaluate(self.entry2) is True + + def test_regex_examples(self): + # Match description containing "transfer" (case-sensitive) + condition1 = field("description").regex(r"transfer") + assert condition1.evaluate(self.entry2) is True + assert condition1.evaluate(self.entry1) is False + + # Match account names starting with "Assets" or "Liabilities" + condition2 = field("account").regex(r"^(Assets|Liabilities)") + assert condition2.evaluate(self.entry1) is True + assert condition2.evaluate(self.entry2) is True + + # Complex regex with word boundaries + condition4 = field("payee").regex(r"\b(john|jane)\b") + assert condition4.evaluate(self.entry2) is True + assert condition4.evaluate(self.entry1) is False + + +class TestExplainAndStringRepresentation: + """Test explain method and string representations.""" + + def setup_method(self): + self.entry = MockEntry( + amount=150.50, + account="Assets:Cash", + description="Coffee transfer payment", + payee="john doe", + year=2024, + ) + + def test_field_condition_str(self): + condition = field("amount").gt(100) + assert str(condition) == "amount > '100'" + + def test_field_condition_eq_str(self): + condition = field("account").eq("Assets:Cash") + assert str(condition) == "account == 'Assets:Cash'" + + def test_field_condition_contains_str(self): + condition = field("description").contains("transfer") + assert str(condition) == "'transfer' contains description" + + def test_field_condition_regex_str(self): + condition = field("description").regex(r"transfer") + assert str(condition) == "description ~ /transfer/" + + def test_field_condition_in_str(self): + condition = field("account").in_(["Assets:Cash", "Assets:Bank"]) + assert str(condition) == "account in '['Assets:Cash', 'Assets:Bank']'" + + def test_custom_condition_str(self): + condition = CustomCondition(lambda r: r.year == 2024, "year is 2024") + assert str(condition) == "custom(year is 2024)" + + def test_custom_condition_default_str(self): + condition = CustomCondition(lambda r: r.year == 2024) + assert str(condition) == "custom(custom function)" + + def test_and_condition_str(self): + left = field("amount").gt(100) + right = field("account").contains("Assets") + condition = left & right + assert str(condition) == "(amount > '100') AND ('Assets' contains account)" + + def test_or_condition_str(self): + left = field("amount").gt(100) + right = field("account").contains("Assets") + condition = left | right + assert str(condition) == "(amount > '100') OR ('Assets' contains account)" + + def test_not_condition_str(self): + inner = field("amount").gt(100) + condition = ~inner + assert str(condition) == "NOT (amount > '100')" + + def test_field_condition_explain_true(self): + condition = field("amount").gt(100) + explanation = condition.explain(self.entry) + assert explanation == "amount: '150.5' > '100' → True" + + def test_field_condition_explain_false(self): + condition = field("amount").gt(200) + explanation = condition.explain(self.entry) + assert explanation == "amount: '150.5' > '200' → False" + + def test_field_condition_explain_eq(self): + condition = field("account").eq("Assets:Cash") + explanation = condition.explain(self.entry) + assert explanation == "account: 'Assets:Cash' == 'Assets:Cash' → True" + + def test_field_condition_explain_contains(self): + condition = field("description").contains("transfer") + explanation = condition.explain(self.entry) + assert ( + explanation + == "description: 'transfer' contains 'Coffee transfer payment' → True" + ) + + def test_field_condition_explain_regex(self): + condition = field("description").regex(r"transfer") + explanation = condition.explain(self.entry) + assert ( + explanation + == "description: 'Coffee transfer payment' matches pattern 'transfer' → True" + ) + + def test_custom_condition_explain(self): + condition = CustomCondition(lambda r: r.year == 2024, "year is 2024") + explanation = condition.explain(self.entry) + assert explanation == "year is 2024 → True" + + def test_and_condition_explain_true(self): + left = field("amount").gt(100) + right = field("account").contains("Assets") + condition = left & right + explanation = condition.explain(self.entry) + expected = ( + "(amount: '150.5' > '100' → True) AND " + "(account: 'Assets' contains 'Assets:Cash' → True) → " + "True AND True → True" + ) + assert explanation == expected + + def test_and_condition_explain_false(self): + left = field("amount").gt(200) + right = field("account").contains("Assets") + condition = left & right + explanation = condition.explain(self.entry) + expected = ( + "(amount: '150.5' > '200' → False) AND " + "(account: 'Assets' contains 'Assets:Cash' → True) → " + "False AND True → False" + ) + assert explanation == expected + + def test_or_condition_explain_true(self): + left = field("amount").gt(200) + right = field("account").contains("Assets") + condition = left | right + explanation = condition.explain(self.entry) + expected = ( + "(amount: '150.5' > '200' → False) OR " + "(account: 'Assets' contains 'Assets:Cash' → True) → " + "False OR True → True" + ) + assert explanation == expected + + def test_not_condition_explain(self): + inner = field("amount").gt(200) + condition = ~inner + explanation = condition.explain(self.entry) + expected = "NOT (amount: '150.5' > '200' → False) → NOT False → True" + assert explanation == expected + + def test_complex_condition_explain(self): + condition = ( + field("amount").gt(100) & field("account").contains("Assets") + ) | field("year").eq(2023) + explanation = condition.explain(self.entry) + # This will be a complex nested explanation + assert "→" in explanation + assert "AND" in explanation + assert "OR" in explanation + + def test_repr_equals_str(self): + condition = field("amount").gt(100) + assert repr(condition) == str(condition) + + +if __name__ == "__main__": + pytest.main([__file__]) diff --git a/src/beanbot/session/__init__.py b/src/beanbot/session/__init__.py new file mode 100644 index 0000000..414557f --- /dev/null +++ b/src/beanbot/session/__init__.py @@ -0,0 +1,3 @@ +from .session import Session + +__all__ = ["Session"] diff --git a/src/beanbot/session/session.py b/src/beanbot/session/session.py new file mode 100644 index 0000000..618168c --- /dev/null +++ b/src/beanbot/session/session.py @@ -0,0 +1,178 @@ +from beancount import Directive + +from beanbot.data.directive import MutableDirective, to_mutable +from beanbot.ledger.ledger import Ledger + + +# TODO: Remove forward declarations when QueryExecutor & Query is implemented +class Query: + pass + + +class QueryExecutor: + pass + + +class Session: + """Session tracks in-memory state of entries in a ledger. It allows for adding, deleting, and modifying entries + without immediately committing changes to the ledger. This is useful for batch processing or undo/redo + operations.""" + + def __init__(self, ledger: Ledger) -> None: + self.new_entries: dict[str, MutableDirective] = {} + self.existing_entries: dict[str, MutableDirective] = {} + self.deleted_entries: set[str] = set() + + self.ledger = ledger + self.id_generator = ledger.id_generator + assert self.id_generator is not None, ( + "Session requires a shared ID generator from the ledger." + ) + + self.executor: "QueryExecutor | None" = None # TODO: remove forward declaration + + def add(self, entry: MutableDirective | Directive) -> str: + """Add a new entry to the session. + + Args: + entry: The entry to add, either a MutableDirective or a Directive. + When using MutableDirective with an `id` specified, you must ensure that the `id` + is unique within the session. Otherwise, it will raise an error. + When `id` is not specified or an immutable `Directive` is provided, a new unique ID will be generated. + Returns: + The unique identifier for the added entry. + Raises: + ValueError: If the entry already exists in the session with the same ID. + """ + # Handle MutableDirective with existing ID + if isinstance(entry, MutableDirective) and entry.id is not None: + entry_id = entry.id + # Check if ID already exists in session + if self.id_generator.exists(entry_id): + raise ValueError( + f"Entry with ID '{entry_id}' already exists in session" + ) + mutable_entry = entry + else: + # Generate new ID for Directive or MutableDirective without ID + entry_id = self.id_generator.generate() + + # Convert Directive to MutableDirective if needed + if isinstance(entry, MutableDirective): + mutable_entry = entry + mutable_entry.id = entry_id + else: + mutable_entry = to_mutable(entry) + mutable_entry.id = entry_id + + # Add to new entries tracking + self.new_entries[entry_id] = mutable_entry + + return entry_id + + def delete(self, id: str) -> bool: + """Mark an entry for deletion in the session. + + Args: + id: The unique identifier of the entry to delete. + Returns: + bool: True if the entry was found, False if it did not exist. + """ + # Check if the ID is already marked for deletion + if id in self.deleted_entries: + return True + + # Check if it's a new entry that hasn't been committed yet + if id in self.new_entries: + del self.new_entries[id] + return True + + # Check if it's an existing entry that's been loaded into session + if id in self.existing_entries: + del self.existing_entries[id] + self.deleted_entries.add(id) + return True + + # Check if it exists in the ledger but hasn't been loaded into session + if self.ledger.has_entry(id): + self.deleted_entries.add(id) + return True + + # Entry doesn't exist anywhere + return False + + def commit(self) -> None: + """Commit all changes in the session to the ledger and save to disk.""" + # Add new entries to the ledger + for entry_id, entry in self.new_entries.items(): + # Convert MutableDirective back to Directive for ledger storage + directive = entry.to_immutable() + self.ledger.add(directive) + + # Delete entries from the ledger + for entry_id in self.deleted_entries: + self.ledger.delete(entry_id) + + # Update existing entries in the ledger + for entry_id, entry in self.existing_entries.items(): + if entry.dirty(): + # Convert MutableDirective back to Directive for ledger storage + directive = entry.to_immutable() + self.ledger.replace(entry_id, directive) + + # Save all changes to disk + self.ledger.save() + self.ledger.load() + + assert not self.ledger.dirty(), ( + "Ledger should not be dirty after committing session changes" + ) + + # Clear session state after successful commit + self.new_entries.clear() + self.deleted_entries.clear() + self.existing_entries.clear() + + def rollback(self) -> None: + """Discard all uncommitted changes in the session, reverting to the last committed state.""" + # Clear all new entries that haven't been committed + self.new_entries.clear() + + # Clear all deletion marks + self.deleted_entries.clear() + + # Reset all existing entries to their original state + for entry in self.existing_entries.values(): + entry.reset() + + def query(self, query_string: str | None) -> "Query": + pass + + def dirty(self) -> bool: + """Check if there are any unsaved changes in the session. + + Returns: + bool: True if there are unsaved changes, False otherwise. + """ + return ( + len(self.new_entries) > 0 + or len(self.deleted_entries) > 0 + or any(entry.dirty() for entry in self.existing_entries.values()) + ) + + def has_entry(self, id: str) -> bool: + """Check if an ID exists in the session. + + Args: + id: The unique identifier to check. + Returns: + bool: True if the ID exists in the session, False otherwise. + """ + if id in self.deleted_entries: + return False + + return ( + id in self.new_entries + or id in self.existing_entries + or self.ledger.has_entry(id) + ) diff --git a/src/beanbot/session/session_test.py b/src/beanbot/session/session_test.py new file mode 100644 index 0000000..21e8832 --- /dev/null +++ b/src/beanbot/session/session_test.py @@ -0,0 +1,349 @@ +from datetime import date +from decimal import Decimal +from unittest.mock import Mock + +import pytest +from beancount.core.amount import Amount +from beancount.core.data import EMPTY_SET, Posting, Transaction + +from beanbot.data.directive import MutableDirective, to_mutable +from beanbot.ledger.ledger import Ledger +from beanbot.session.session import Session +from beanbot.utils.id_generator import IDGenerator + + +@pytest.fixture(name="mock_ledger") +def fixture_mock_ledger(): + """Create a mock ledger for testing.""" + ledger = Mock(spec=Ledger) + ledger.id_generator = IDGenerator() + ledger.has_entry = Mock(return_value=False) + ledger.add = Mock() + ledger.delete = Mock(return_value=True) + ledger.replace = Mock(return_value="test_id") + ledger.save = Mock() + ledger.load = Mock() + ledger.dirty = Mock(return_value=False) + return ledger + + +@pytest.fixture(name="session") +def fixture_session(mock_ledger): + """Create a session with mock ledger.""" + return Session(mock_ledger) + + +@pytest.fixture(name="sample_transaction") +def fixture_sample_transaction(): + """Create a sample transaction for testing.""" + return Transaction( + meta={}, + date=date(2023, 1, 1), + flag="*", + payee="Test Payee", + narration="Test Transaction", + tags=EMPTY_SET, + links=EMPTY_SET, + postings=[ + Posting( + account="Assets:Checking", + units=Amount(Decimal("100.00"), "USD"), + cost=None, + price=None, + flag=None, + meta={}, + ), + Posting( + account="Expenses:Food", + units=Amount(Decimal("-100.00"), "USD"), + cost=None, + price=None, + flag=None, + meta={}, + ), + ], + ) + + +class TestSessionAdd: + """Test the add method.""" + + def test_add_directive_generates_id(self, session, sample_transaction): + """Test adding a Directive generates a new ID.""" + entry_id = session.add(sample_transaction) + + assert entry_id is not None + assert entry_id in session.new_entries + assert isinstance(session.new_entries[entry_id], MutableDirective) + assert session.new_entries[entry_id].id == entry_id + + def test_add_mutable_directive_without_id(self, session, sample_transaction): + """Test adding a MutableDirective without ID generates a new ID.""" + mutable_entry = to_mutable(sample_transaction) + entry_id = session.add(mutable_entry) + + assert entry_id is not None + assert entry_id in session.new_entries + assert session.new_entries[entry_id].id == entry_id + + def test_add_mutable_directive_with_unique_id(self, session, sample_transaction): + """Test adding a MutableDirective with unique ID uses that ID.""" + mutable_entry = to_mutable(sample_transaction) + custom_id = "custom_test_id" + mutable_entry.id = custom_id + + entry_id = session.add(mutable_entry) + + assert entry_id == custom_id + assert custom_id in session.new_entries + assert session.new_entries[custom_id].id == custom_id + + def test_add_mutable_directive_with_existing_id_raises_error( + self, session, sample_transaction + ): + """Test adding a MutableDirective with existing ID raises ValueError.""" + # First add an entry to register the ID + first_entry = to_mutable(sample_transaction) + first_id = session.add(first_entry) + + # Try to add another entry with the same ID + second_entry = to_mutable(sample_transaction) + second_entry.id = first_id + + with pytest.raises( + ValueError, match=f"Entry with ID '{first_id}' already exists" + ): + session.add(second_entry) + + def test_session_dirty_after_add(self, session, sample_transaction): + """Test session becomes dirty after adding an entry.""" + assert not session.dirty() + + session.add(sample_transaction) + + assert session.dirty() + + +class TestSessionDelete: + """Test the delete method.""" + + def test_delete_new_entry(self, session, sample_transaction): + """Test deleting a newly added entry removes it from new_entries.""" + entry_id = session.add(sample_transaction) + assert entry_id in session.new_entries + + result = session.delete(entry_id) + + assert result is True + assert entry_id not in session.new_entries + + def test_delete_existing_entry_in_session(self, session, sample_transaction): + """Test deleting an existing entry marks it for deletion.""" + # Simulate an existing entry loaded into session + mutable_entry = to_mutable(sample_transaction) + entry_id = "existing_id" + mutable_entry.id = entry_id + session.existing_entries[entry_id] = mutable_entry + + result = session.delete(entry_id) + + assert result is True + assert entry_id not in session.existing_entries + assert entry_id in session.deleted_entries + + def test_delete_entry_in_ledger_only(self, session): + """Test deleting an entry that exists only in ledger marks it for deletion.""" + entry_id = "ledger_only_id" + session.ledger.has_entry.return_value = True + + result = session.delete(entry_id) + + assert result is True + assert entry_id in session.deleted_entries + session.ledger.has_entry.assert_called_with(entry_id) + + def test_delete_nonexistent_entry(self, session): + """Test deleting a nonexistent entry returns False.""" + session.ledger.has_entry.return_value = False + + result = session.delete("nonexistent_id") + + assert result is False + assert "nonexistent_id" not in session.deleted_entries + + def test_delete_already_deleted_entry(self, session): + """Test deleting an already deleted entry returns True.""" + entry_id = "already_deleted" + session.deleted_entries.add(entry_id) + + result = session.delete(entry_id) + + assert result is True + assert entry_id in session.deleted_entries + + +class TestSessionCommit: + """Test the commit method.""" + + def test_commit_new_entries(self, session, sample_transaction): + """Test committing new entries calls ledger.add.""" + _ = session.add(sample_transaction) + + session.commit() + + session.ledger.add.assert_called_once() + assert len(session.new_entries) == 0 + + def test_commit_deleted_entries(self, session): + """Test committing deleted entries calls ledger.delete.""" + entry_id = "to_delete" + session.deleted_entries.add(entry_id) + + session.commit() + + session.ledger.delete.assert_called_once_with(entry_id) + assert len(session.deleted_entries) == 0 + + def test_commit_modified_existing_entries(self, session, sample_transaction): + """Test committing modified existing entries calls ledger.replace.""" + # Create a dirty existing entry + mutable_entry = to_mutable(sample_transaction) + entry_id = "existing_modified" + mutable_entry.id = entry_id + mutable_entry.narration = "Dummy modification narration" + assert mutable_entry.dirty() + session.existing_entries[entry_id] = mutable_entry + + session.commit() + + session.ledger.replace.assert_called_once_with( + entry_id, mutable_entry.to_immutable() + ) + assert len(session.existing_entries) == 0 + + def test_commit_calls_ledger_save_and_load(self, session): + """Test commit calls ledger.save() and ledger.load().""" + session.commit() + + session.ledger.save.assert_called_once() + session.ledger.load.assert_called_once() + + def test_commit_clears_session_state(self, session, sample_transaction): + """Test commit clears all session state.""" + # Add some state + session.add(sample_transaction) + session.deleted_entries.add("deleted_id") + mutable_entry = to_mutable(sample_transaction) + session.existing_entries["existing_id"] = mutable_entry + + session.commit() + + assert len(session.new_entries) == 0 + assert len(session.deleted_entries) == 0 + assert len(session.existing_entries) == 0 + + +class TestSessionModifyNewDirective: + """Test modifying a newly added directive before commit.""" + + def test_modify_new_directive_and_commit(self, session, sample_transaction): + """Test modifying a newly added directive and then committing.""" + # Add a new entry + entry_id = session.add(sample_transaction) + mutable_entry = session.new_entries[entry_id] + assert isinstance(mutable_entry, MutableDirective) + + # Modify the entry + original_narration = mutable_entry.narration + new_narration = "Modified narration" + mutable_entry.narration = new_narration + + # Verify modification + assert mutable_entry.narration == new_narration + assert mutable_entry.narration != original_narration + assert mutable_entry.dirty() + assert session.dirty() + + # Commit the changes + session.commit() + + # Verify the modified entry was passed to ledger + session.ledger.add.assert_called_once() + committed_directive = session.ledger.add.call_args[0][0] + assert committed_directive.narration == new_narration + + # Verify session is clean + assert not session.dirty() + assert len(session.new_entries) == 0 + + def test_modify_new_directive_multiple_times(self, session, sample_transaction): + """Test multiple modifications to a new directive before commit.""" + # Add a new entry + entry_id = session.add(sample_transaction) + mutable_entry = session.new_entries[entry_id] + + # Make multiple modifications + mutable_entry.narration = "First modification" + mutable_entry.payee = "Modified Payee" + + # Verify all modifications + assert mutable_entry.narration == "First modification" + assert mutable_entry.payee == "Modified Payee" + + # Commit and verify + session.commit() + + committed_directive = session.ledger.add.call_args[0][0] + assert committed_directive.narration == "First modification" + assert committed_directive.payee == "Modified Payee" + + def test_modify_new_directive_postings(self, session, sample_transaction): + """Test modifying postings of a newly added directive.""" + # Add a new entry + entry_id = session.add(sample_transaction) + mutable_entry = session.new_entries[entry_id] + + # Modify a posting amount + original_amount = mutable_entry.postings[0].units.number + new_amount = Decimal("200.00") + mutable_entry.postings[0].units = Amount(new_amount, "USD") + mutable_entry.postings[1].units = Amount(-new_amount, "USD") + + # Verify modification + assert mutable_entry.postings[0].units.number == new_amount + assert mutable_entry.postings[0].units.number != original_amount + + # Commit and verify + session.commit() + + committed_directive = session.ledger.add.call_args[0][0] + assert committed_directive.postings[0].units.number == new_amount + + +class TestSessionDirtyState: + """Test dirty state tracking.""" + + def test_clean_session_not_dirty(self, session): + """Test a clean session is not dirty.""" + assert not session.dirty() + + def test_session_dirty_with_new_entries(self, session, sample_transaction): + """Test session is dirty when it has new entries.""" + session.add(sample_transaction) + assert session.dirty() + + def test_session_dirty_with_deleted_entries(self, session): + """Test session is dirty when it has deleted entries.""" + session.deleted_entries.add("deleted_id") + assert session.dirty() + + def test_session_dirty_with_modified_existing_entries( + self, session, sample_transaction + ): + """Test session is dirty when existing entries are modified.""" + mutable_entry = to_mutable(sample_transaction) + mutable_entry.narration = "Dummy modification narration" + assert mutable_entry.dirty() + session.existing_entries["existing_id"] = mutable_entry + + assert session.dirty() diff --git a/src/beanbot/utils/id_generator.py b/src/beanbot/utils/id_generator.py index f6ab98f..a517b5e 100644 --- a/src/beanbot/utils/id_generator.py +++ b/src/beanbot/utils/id_generator.py @@ -93,3 +93,15 @@ def unregister(self, id_value: str) -> bool: self._generated_ids.remove(id_value) return True return False + + def exists(self, id_value: str) -> bool: + """Check if an ID exists in this generator. + + Args: + id_value: ID string to check + + Returns: + bool: True if ID exists, False otherwise + """ + with self._lock: + return id_value in self._generated_ids diff --git a/uv.lock b/uv.lock index 4276342..348860d 100644 --- a/uv.lock +++ b/uv.lock @@ -16,21 +16,27 @@ name = "beanbot" source = { editable = "." } dependencies = [ { name = "beancount" }, + { name = "recordclass" }, ] [package.dev-dependencies] dev = [ { name = "ipython" }, { name = "pytest" }, + { name = "pytest-cov" }, ] [package.metadata] -requires-dist = [{ name = "beancount", specifier = ">=3.1.0" }] +requires-dist = [ + { name = "beancount", specifier = ">=3.1.0" }, + { name = "recordclass", specifier = ">=0.23.1" }, +] [package.metadata.requires-dev] dev = [ { name = "ipython", specifier = ">=9.3.0" }, { name = "pytest", specifier = ">=8.4.0" }, + { name = "pytest-cov", specifier = ">=6.2.1" }, ] [[package]] @@ -79,6 +85,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coverage" +version = "7.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/e0/98670a80884f64578f0c22cd70c5e81a6e07b08167721c7487b4d70a7ca0/coverage-7.9.1.tar.gz", hash = "sha256:6cf43c78c4282708a28e466316935ec7489a9c487518a77fa68f716c67909cec", size = 813650, upload-time = "2025-06-13T13:02:28.627Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/d9/7f66eb0a8f2fce222de7bdc2046ec41cb31fe33fb55a330037833fb88afc/coverage-7.9.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8de12b4b87c20de895f10567639c0797b621b22897b0af3ce4b4e204a743626", size = 212336, upload-time = "2025-06-13T13:01:10.909Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/e07cb920ef3addf20f052ee3d54906e57407b6aeee3227a9c91eea38a665/coverage-7.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5add197315a054e92cee1b5f686a2bcba60c4c3e66ee3de77ace6c867bdee7cb", size = 212571, upload-time = "2025-06-13T13:01:12.518Z" }, + { url = "https://files.pythonhosted.org/packages/78/f8/96f155de7e9e248ca9c8ff1a40a521d944ba48bec65352da9be2463745bf/coverage-7.9.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:600a1d4106fe66f41e5d0136dfbc68fe7200a5cbe85610ddf094f8f22e1b0300", size = 246377, upload-time = "2025-06-13T13:01:14.87Z" }, + { url = "https://files.pythonhosted.org/packages/3e/cf/1d783bd05b7bca5c10ded5f946068909372e94615a4416afadfe3f63492d/coverage-7.9.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a876e4c3e5a2a1715a6608906aa5a2e0475b9c0f68343c2ada98110512ab1d8", size = 243394, upload-time = "2025-06-13T13:01:16.23Z" }, + { url = "https://files.pythonhosted.org/packages/02/dd/e7b20afd35b0a1abea09fb3998e1abc9f9bd953bee548f235aebd2b11401/coverage-7.9.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81f34346dd63010453922c8e628a52ea2d2ccd73cb2487f7700ac531b247c8a5", size = 245586, upload-time = "2025-06-13T13:01:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/4e/38/b30b0006fea9d617d1cb8e43b1bc9a96af11eff42b87eb8c716cf4d37469/coverage-7.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:888f8eee13f2377ce86d44f338968eedec3291876b0b8a7289247ba52cb984cd", size = 245396, upload-time = "2025-06-13T13:01:19.164Z" }, + { url = "https://files.pythonhosted.org/packages/31/e4/4d8ec1dc826e16791f3daf1b50943e8e7e1eb70e8efa7abb03936ff48418/coverage-7.9.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9969ef1e69b8c8e1e70d591f91bbc37fc9a3621e447525d1602801a24ceda898", size = 243577, upload-time = "2025-06-13T13:01:22.433Z" }, + { url = "https://files.pythonhosted.org/packages/25/f4/b0e96c5c38e6e40ef465c4bc7f138863e2909c00e54a331da335faf0d81a/coverage-7.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:60c458224331ee3f1a5b472773e4a085cc27a86a0b48205409d364272d67140d", size = 244809, upload-time = "2025-06-13T13:01:24.143Z" }, + { url = "https://files.pythonhosted.org/packages/8a/65/27e0a1fa5e2e5079bdca4521be2f5dabf516f94e29a0defed35ac2382eb2/coverage-7.9.1-cp312-cp312-win32.whl", hash = "sha256:5f646a99a8c2b3ff4c6a6e081f78fad0dde275cd59f8f49dc4eab2e394332e74", size = 214724, upload-time = "2025-06-13T13:01:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/9b/a8/d5b128633fd1a5e0401a4160d02fa15986209a9e47717174f99dc2f7166d/coverage-7.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:30f445f85c353090b83e552dcbbdad3ec84c7967e108c3ae54556ca69955563e", size = 215535, upload-time = "2025-06-13T13:01:27.861Z" }, + { url = "https://files.pythonhosted.org/packages/a3/37/84bba9d2afabc3611f3e4325ee2c6a47cd449b580d4a606b240ce5a6f9bf/coverage-7.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:af41da5dca398d3474129c58cb2b106a5d93bbb196be0d307ac82311ca234342", size = 213904, upload-time = "2025-06-13T13:01:29.202Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a7/a027970c991ca90f24e968999f7d509332daf6b8c3533d68633930aaebac/coverage-7.9.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:31324f18d5969feef7344a932c32428a2d1a3e50b15a6404e97cba1cc9b2c631", size = 212358, upload-time = "2025-06-13T13:01:30.909Z" }, + { url = "https://files.pythonhosted.org/packages/f2/48/6aaed3651ae83b231556750280682528fea8ac7f1232834573472d83e459/coverage-7.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0c804506d624e8a20fb3108764c52e0eef664e29d21692afa375e0dd98dc384f", size = 212620, upload-time = "2025-06-13T13:01:32.256Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2a/f4b613f3b44d8b9f144847c89151992b2b6b79cbc506dee89ad0c35f209d/coverage-7.9.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef64c27bc40189f36fcc50c3fb8f16ccda73b6a0b80d9bd6e6ce4cffcd810bbd", size = 245788, upload-time = "2025-06-13T13:01:33.948Z" }, + { url = "https://files.pythonhosted.org/packages/04/d2/de4fdc03af5e4e035ef420ed26a703c6ad3d7a07aff2e959eb84e3b19ca8/coverage-7.9.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d4fe2348cc6ec372e25adec0219ee2334a68d2f5222e0cba9c0d613394e12d86", size = 243001, upload-time = "2025-06-13T13:01:35.285Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e8/eed18aa5583b0423ab7f04e34659e51101135c41cd1dcb33ac1d7013a6d6/coverage-7.9.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:34ed2186fe52fcc24d4561041979a0dec69adae7bce2ae8d1c49eace13e55c43", size = 244985, upload-time = "2025-06-13T13:01:36.712Z" }, + { url = "https://files.pythonhosted.org/packages/17/f8/ae9e5cce8885728c934eaa58ebfa8281d488ef2afa81c3dbc8ee9e6d80db/coverage-7.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:25308bd3d00d5eedd5ae7d4357161f4df743e3c0240fa773ee1b0f75e6c7c0f1", size = 245152, upload-time = "2025-06-13T13:01:39.303Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c8/272c01ae792bb3af9b30fac14d71d63371db227980682836ec388e2c57c0/coverage-7.9.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:73e9439310f65d55a5a1e0564b48e34f5369bee943d72c88378f2d576f5a5751", size = 243123, upload-time = "2025-06-13T13:01:40.727Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d0/2819a1e3086143c094ab446e3bdf07138527a7b88cb235c488e78150ba7a/coverage-7.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:37ab6be0859141b53aa89412a82454b482c81cf750de4f29223d52268a86de67", size = 244506, upload-time = "2025-06-13T13:01:42.184Z" }, + { url = "https://files.pythonhosted.org/packages/8b/4e/9f6117b89152df7b6112f65c7a4ed1f2f5ec8e60c4be8f351d91e7acc848/coverage-7.9.1-cp313-cp313-win32.whl", hash = "sha256:64bdd969456e2d02a8b08aa047a92d269c7ac1f47e0c977675d550c9a0863643", size = 214766, upload-time = "2025-06-13T13:01:44.482Z" }, + { url = "https://files.pythonhosted.org/packages/27/0f/4b59f7c93b52c2c4ce7387c5a4e135e49891bb3b7408dcc98fe44033bbe0/coverage-7.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:be9e3f68ca9edb897c2184ad0eee815c635565dbe7a0e7e814dc1f7cbab92c0a", size = 215568, upload-time = "2025-06-13T13:01:45.772Z" }, + { url = "https://files.pythonhosted.org/packages/09/1e/9679826336f8c67b9c39a359352882b24a8a7aee48d4c9cad08d38d7510f/coverage-7.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:1c503289ffef1d5105d91bbb4d62cbe4b14bec4d13ca225f9c73cde9bb46207d", size = 213939, upload-time = "2025-06-13T13:01:47.087Z" }, + { url = "https://files.pythonhosted.org/packages/bb/5b/5c6b4e7a407359a2e3b27bf9c8a7b658127975def62077d441b93a30dbe8/coverage-7.9.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0b3496922cb5f4215bf5caaef4cf12364a26b0be82e9ed6d050f3352cf2d7ef0", size = 213079, upload-time = "2025-06-13T13:01:48.554Z" }, + { url = "https://files.pythonhosted.org/packages/a2/22/1e2e07279fd2fd97ae26c01cc2186e2258850e9ec125ae87184225662e89/coverage-7.9.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9565c3ab1c93310569ec0d86b017f128f027cab0b622b7af288696d7ed43a16d", size = 213299, upload-time = "2025-06-13T13:01:49.997Z" }, + { url = "https://files.pythonhosted.org/packages/14/c0/4c5125a4b69d66b8c85986d3321520f628756cf524af810baab0790c7647/coverage-7.9.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2241ad5dbf79ae1d9c08fe52b36d03ca122fb9ac6bca0f34439e99f8327ac89f", size = 256535, upload-time = "2025-06-13T13:01:51.314Z" }, + { url = "https://files.pythonhosted.org/packages/81/8b/e36a04889dda9960be4263e95e777e7b46f1bb4fc32202612c130a20c4da/coverage-7.9.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bb5838701ca68b10ebc0937dbd0eb81974bac54447c55cd58dea5bca8451029", size = 252756, upload-time = "2025-06-13T13:01:54.403Z" }, + { url = "https://files.pythonhosted.org/packages/98/82/be04eff8083a09a4622ecd0e1f31a2c563dbea3ed848069e7b0445043a70/coverage-7.9.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b30a25f814591a8c0c5372c11ac8967f669b97444c47fd794926e175c4047ece", size = 254912, upload-time = "2025-06-13T13:01:56.769Z" }, + { url = "https://files.pythonhosted.org/packages/0f/25/c26610a2c7f018508a5ab958e5b3202d900422cf7cdca7670b6b8ca4e8df/coverage-7.9.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2d04b16a6062516df97969f1ae7efd0de9c31eb6ebdceaa0d213b21c0ca1a683", size = 256144, upload-time = "2025-06-13T13:01:58.19Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8b/fb9425c4684066c79e863f1e6e7ecebb49e3a64d9f7f7860ef1688c56f4a/coverage-7.9.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7931b9e249edefb07cd6ae10c702788546341d5fe44db5b6108a25da4dca513f", size = 254257, upload-time = "2025-06-13T13:01:59.645Z" }, + { url = "https://files.pythonhosted.org/packages/93/df/27b882f54157fc1131e0e215b0da3b8d608d9b8ef79a045280118a8f98fe/coverage-7.9.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:52e92b01041151bf607ee858e5a56c62d4b70f4dac85b8c8cb7fb8a351ab2c10", size = 255094, upload-time = "2025-06-13T13:02:01.37Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/cad1c3dbed8b3ee9e16fa832afe365b4e3eeab1fb6edb65ebbf745eabc92/coverage-7.9.1-cp313-cp313t-win32.whl", hash = "sha256:684e2110ed84fd1ca5f40e89aa44adf1729dc85444004111aa01866507adf363", size = 215437, upload-time = "2025-06-13T13:02:02.905Z" }, + { url = "https://files.pythonhosted.org/packages/99/4d/fad293bf081c0e43331ca745ff63673badc20afea2104b431cdd8c278b4c/coverage-7.9.1-cp313-cp313t-win_amd64.whl", hash = "sha256:437c576979e4db840539674e68c84b3cda82bc824dd138d56bead1435f1cb5d7", size = 216605, upload-time = "2025-06-13T13:02:05.638Z" }, + { url = "https://files.pythonhosted.org/packages/1f/56/4ee027d5965fc7fc126d7ec1187529cc30cc7d740846e1ecb5e92d31b224/coverage-7.9.1-cp313-cp313t-win_arm64.whl", hash = "sha256:18a0912944d70aaf5f399e350445738a1a20b50fbea788f640751c2ed9208b6c", size = 214392, upload-time = "2025-06-13T13:02:07.642Z" }, + { url = "https://files.pythonhosted.org/packages/08/b8/7ddd1e8ba9701dea08ce22029917140e6f66a859427406579fd8d0ca7274/coverage-7.9.1-py3-none-any.whl", hash = "sha256:66b974b145aa189516b6bf2d8423e888b742517d37872f6ee4c5be0073bd9a3c", size = 204000, upload-time = "2025-06-13T13:02:27.173Z" }, +] + [[package]] name = "decorator" version = "5.2.1" @@ -257,6 +305,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2f/de/afa024cbe022b1b318a3d224125aa24939e99b4ff6f22e0ba639a2eaee47/pytest-8.4.0-py3-none-any.whl", hash = "sha256:f40f825768ad76c0977cbacdf1fd37c6f7a468e460ea6a0636078f8972d4517e", size = 363797, upload-time = "2025-06-02T17:36:27.859Z" }, ] +[[package]] +name = "pytest-cov" +version = "6.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/99/668cade231f434aaa59bbfbf49469068d2ddd945000621d3d165d2e7dd7b/pytest_cov-6.2.1.tar.gz", hash = "sha256:25cc6cc0a5358204b8108ecedc51a9b57b34cc6b8c967cc2c01a4e00d8a67da2", size = 69432, upload-time = "2025-06-12T10:47:47.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/16/4ea354101abb1287856baa4af2732be351c7bee728065aed451b678153fd/pytest_cov-6.2.1-py3-none-any.whl", hash = "sha256:f5bc4c23f42f1cdd23c70b1dab1bbaef4fc505ba950d53e0081d0730dd7e86d5", size = 24644, upload-time = "2025-06-12T10:47:45.932Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -269,6 +331,16 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "recordclass" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/d0/32de6ae6d05ad21233b77e567d8c5d32af9be1b14c4f924e86059c8152b2/recordclass-0.23.1.tar.gz", hash = "sha256:cd6391030709d1cb42c6a901aa1053411ae5baf8762a5eb763e220be2489b3c9", size = 1252339, upload-time = "2025-04-14T20:14:48.523Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/43/6f2693abb1a566c517dfc57f888a282476d80bc0e04deac0c11162ed4224/recordclass-0.23.1-cp312-cp312-win_amd64.whl", hash = "sha256:03bef1c740f16fa2088e2eaec1c4d4d1fbafc6aae22682f974060560c7bf957b", size = 249234, upload-time = "2025-04-19T06:13:56.879Z" }, + { url = "https://files.pythonhosted.org/packages/8b/5b/ce7b011b480a82651b108e6fb3d60d3d8d964ef8b0e51ec73ddc8cfbf83f/recordclass-0.23.1-cp313-cp313-win_amd64.whl", hash = "sha256:9b879f1763a295a80003ec4cf71bdbad77d3d185f3040bfe6ddcf5555b9908ac", size = 248953, upload-time = "2025-04-19T06:13:59.267Z" }, +] + [[package]] name = "regex" version = "2024.11.6"