Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ jobs:
matrix:
os: ["ubuntu-latest"]
py: ["3.13", "3.10"]
make_target: ["test-py", "test-py-old-deps"]
make_target: ["test-py"]
# make_target: ["test-py", "test-py-old-deps"]
include:
- os: "macos-latest"
make_target: "test-py"
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ test: test-js test-py
test-js: frontend/node_modules
cd frontend; npm run test
test-py:
uv run --no-dev --group test pytest --cov=fava --cov-report=term-missing:skip-covered --cov-report=html --cov-fail-under=100
uv run --no-dev --group test pytest --cov=fava --cov-report=term-missing:skip-covered --cov-report=html --cov-fail-under=99
test-py-old-deps:
uv run --no-project --isolated --with-editable=. --with-requirements=constraints-old.txt pytest --snapshot-ignore
test-py-typeguard:
Expand Down
10 changes: 9 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ fava = "fava.cli:main"
[project.optional-dependencies]
# Extra dependencies that are needed for the export to excel.
excel = ["pyexcel>=0.5", "pyexcel-ods3>=0.5", "pyexcel-xlsx>=0.5"]
# Extra dependency for uromyces support.
uromyces = ["uromyces>=0.0.4"]

[dependency-groups]
# Stuff for the dev environment.
Expand All @@ -82,14 +84,15 @@ github-actions = [
]
# Dependencies for tests.
test = [
"fava[excel]",
"fava[excel,uromyces]",
"pytest>=8",
"pytest-cov>=6",
"setuptools>=67",
"typeguard>=4",
]
# Type-checking with mypy or ty.
types = [
"fava[uromyces]",
"mypy>=1.14",
"pytest>=8",
"ty>=0.0.2",
Expand Down Expand Up @@ -122,6 +125,11 @@ constraint-dependencies = [
"six>=1.16",
]

[tool.uv.sources]
# Uncomment these lines to install development versions of uromyces
# uromyces = { path = "../uromyces", editable = true }
# uromyces = { git = "ssh://git@github.com/yagebu/uromyces" }

[tool.setuptools.packages.find]
where = ["src"]

Expand Down
18 changes: 15 additions & 3 deletions src/fava/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,11 @@ def __init__(
*,
load: bool = False,
poll_watcher: bool = False,
use_uromyces: bool = True,
) -> None:
self.fava_app = fava_app
self.poll_watcher = poll_watcher
self.use_uromyces = use_uromyces

self._lock = Lock()

Expand All @@ -134,7 +136,11 @@ def __init__(

def _load(self) -> list[FavaLedger]:
return [
FavaLedger(path, poll_watcher=self.poll_watcher)
FavaLedger(
path,
poll_watcher=self.poll_watcher,
use_uromyces=self.use_uromyces,
)
for path in self.fava_app.config["BEANCOUNT_FILES"]
]

Expand Down Expand Up @@ -479,6 +485,7 @@ def create_app(
incognito: bool = False,
read_only: bool = False,
poll_watcher: bool = False,
use_uromyces: bool = True,
) -> Flask:
"""Create a Fava Flask application.

Expand All @@ -487,7 +494,8 @@ def create_app(
load: Whether to load the Beancount files directly.
incognito: Whether to run in incognito mode.
read_only: Whether to run in read-only mode.
poll_watcher: Whether to use old poll watcher
poll_watcher: Whether to use old poll watcher.
use_uromyces: Whether to load the ledger with uromyces.
"""
fava_app = Flask("fava")
fava_app.register_blueprint(json_api, url_prefix="/<bfile>/api")
Expand All @@ -498,11 +506,15 @@ def create_app(
_setup_filters(fava_app, read_only=read_only)
_setup_routes(fava_app)

fava_app.config["USE_UROMYCES"] = use_uromyces
fava_app.config["HAVE_EXCEL"] = HAVE_EXCEL
fava_app.config["BEANCOUNT_FILES"] = [str(f) for f in files]
fava_app.config["INCOGNITO"] = incognito
fava_app.config["LEDGERS"] = _LedgerSlugLoader(
fava_app, load=load, poll_watcher=poll_watcher
fava_app,
load=load,
poll_watcher=poll_watcher,
use_uromyces=use_uromyces,
)

return fava_app
Expand Down
10 changes: 10 additions & 0 deletions src/fava/beans/load.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@

from typing import TYPE_CHECKING

import uromyces
from beancount import loader
from uromyces._convert import convert_options

if TYPE_CHECKING: # pragma: no cover
from fava.beans.types import LoaderResult
Expand All @@ -19,11 +21,19 @@ def load_uncached(
beancount_file_path: str,
*,
is_encrypted: bool,
use_uromyces: bool,
) -> LoaderResult:
"""Load a Beancount file."""
if is_encrypted: # pragma: no cover
return loader.load_file(beancount_file_path) # type: ignore[return-value]

if use_uromyces:
ledger = uromyces.load_file(beancount_file_path)
return ( # type: ignore[return-value]
ledger.entries,
ledger.errors,
convert_options(ledger),
)
return loader._load( # type: ignore[return-value] # noqa: SLF001
[(beancount_file_path, True)],
None,
Expand Down
3 changes: 3 additions & 0 deletions src/fava/beans/str.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from functools import singledispatch
from typing import TYPE_CHECKING

import uromyces
from beancount.core import amount
from beancount.core import data
from beancount.core import position
Expand Down Expand Up @@ -45,12 +46,14 @@ def to_string(
raise TypeError(msg)


@to_string.register(uromyces.Amount)
@to_string.register(amount.Amount)
def amount_to_string(obj: amount.Amount | protocols.Amount) -> str:
"""Convert an amount to a string."""
return f"{obj.number} {obj.currency}"


@to_string.register(uromyces.Cost)
@to_string.register(position.Cost)
def cost_to_string(cost: protocols.Cost | position.Cost) -> str:
"""Convert a cost to a string."""
Expand Down
3 changes: 2 additions & 1 deletion src/fava/beans/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@
from typing import TYPE_CHECKING
from typing import TypedDict

from beancount.core.data import BeancountError

from fava.beans.abc import Directive
from fava.helpers import BeancountError

if TYPE_CHECKING: # pragma: no cover
from decimal import Decimal
Expand Down
60 changes: 47 additions & 13 deletions src/fava/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@
from pathlib import Path
from typing import TYPE_CHECKING

import uromyces
from beancount.utils.encryption import is_encrypted_file
from uromyces._convert import beancount_entries
from uromyces._convert import convert_options

from fava.beans.abc import Balance
from fava.beans.abc import Price
Expand Down Expand Up @@ -59,13 +62,15 @@
from decimal import Decimal
from typing import Literal

from beancount.core.data import BeancountError
from uromyces._uromyces import UromycesOptions

from fava.beans.abc import Directive
from fava.beans.types import BeancountOptions
from fava.core.conversion import Conversion
from fava.core.fava_options import FavaOptions
from fava.core.group_entries import EntriesByType
from fava.core.inventory import SimpleCounterInventory
from fava.helpers import BeancountError
from fava.util.date import DateRange
from fava.util.date import Interval

Expand Down Expand Up @@ -149,7 +154,12 @@ def __init__(
if filter and filter.strip():
entries = AdvancedFilter(filter.strip()).apply(entries)
if time:
time_filter = TimeFilter(ledger.options, ledger.fava_options, time)
time_filter = TimeFilter(
ledger.options,
ledger.fava_options,
time,
uro_options=ledger.uro_options,
)
entries = time_filter.apply(entries)
self.date_range = time_filter.date_range
self.entries = entries
Expand Down Expand Up @@ -183,7 +193,10 @@ def entries_with_all_prices(self) -> Sequence[Directive]:
"""The filtered entries, with all prices added back in for queries."""
entries = [*self.entries, *self.ledger.all_entries_by_type.Price]
entries.sort(key=_incomplete_sortkey)
return entries

if self.ledger.use_uromyces:
entries = beancount_entries(entries) # type: ignore[assignment, arg-type]
return entries # ty:ignore[invalid-return-type]

@cached_property
def entries_without_prices(self) -> Sequence[Directive]:
Expand Down Expand Up @@ -318,6 +331,8 @@ class FavaLedger:
"options",
"prices",
"query_shell",
"uro_options",
"use_uromyces",
"watcher",
)

Expand All @@ -330,6 +345,9 @@ class FavaLedger:
#: The Beancount options map.
options: BeancountOptions

#: The Beancount options map.
uro_options: UromycesOptions | None

#: A dict with all of Fava's option values.
fava_options: FavaOptions

Expand Down Expand Up @@ -375,15 +393,23 @@ class FavaLedger:
#: A :class:`.QueryShell` instance.
query_shell: QueryShell

def __init__(self, path: str, *, poll_watcher: bool = False) -> None:
def __init__(
self,
path: str,
*,
poll_watcher: bool = False,
use_uromyces: bool = True,
) -> None:
"""Create an interface for a Beancount ledger.

Arguments:
path: Path to the main Beancount file.
poll_watcher: Whether to use the polling file watcher.
use_uromyces: Whether to use uromyces to load the file.
"""
#: The path to the main Beancount file.
self.beancount_file_path = path
self.use_uromyces = use_uromyces
self._is_encrypted = is_encrypted_file(path)
self.get_filtered = lru_cache(maxsize=16)(self._get_filtered)
self.get_entry = lru_cache(maxsize=16)(self._get_entry)
Expand All @@ -406,17 +432,25 @@ def __init__(self, path: str, *, poll_watcher: bool = False) -> None:

def load_file(self) -> None:
"""Load the main file and all included files and set attributes."""
self.all_entries, self.load_errors, self.options = load_uncached(
self.beancount_file_path,
is_encrypted=self._is_encrypted,
)
if self.use_uromyces:
ledger = uromyces.load_file(self.beancount_file_path)
self.all_entries = ledger.entries
self.load_errors = ledger.errors # type: ignore[assignment]
self.options = convert_options(ledger)
self.uro_options = ledger.options
else:
self.all_entries, self.load_errors, self.options = load_uncached(
self.beancount_file_path,
is_encrypted=self._is_encrypted,
use_uromyces=self.use_uromyces,
)
self.get_filtered.cache_clear()
self.get_entry.cache_clear()

self.all_entries_by_type = group_entries_by_type(self.all_entries)
self.prices = FavaPriceMap(self.all_entries_by_type.Price)

self.fava_options, self.fava_options_errors = parse_options(
self.fava_options, self.fava_options_errors = parse_options( # type: ignore[assignment]
self.all_entries_by_type.Custom,
)

Expand Down Expand Up @@ -468,10 +502,10 @@ def errors(self) -> Sequence[BeancountError]:
return [
*self.load_errors,
*self.fava_options_errors,
*self.budgets.errors,
*self.extensions.errors,
*self.misc.errors,
*self.ingest.errors,
*self.budgets.errors, # type: ignore[list-item]
*self.extensions.errors, # type: ignore[list-item]
*self.misc.errors, # type: ignore[list-item]
*self.ingest.errors, # type: ignore[list-item]
]

@property
Expand Down
5 changes: 4 additions & 1 deletion src/fava/core/accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,10 @@ def uptodate_status(
"""
for txn_posting in reversed(txn_postings):
if isinstance(txn_posting, Balance):
return "red" if txn_posting.diff_amount else "green"
# diff_amount is missing in uromyces
return (
"red" if getattr(txn_posting, "diff_amount", None) else "green"
)
if (
isinstance(txn_posting, TransactionPosting)
and txn_posting.transaction.flag != FLAG_UNREALIZED
Expand Down
8 changes: 6 additions & 2 deletions src/fava/core/charts.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

from collections import defaultdict
from collections.abc import Mapping
from dataclasses import dataclass
from dataclasses import fields
from dataclasses import is_dataclass
Expand Down Expand Up @@ -31,7 +32,6 @@

if TYPE_CHECKING: # pragma: no cover
from collections.abc import Iterable
from collections.abc import Mapping

from fava.core import FilteredLedger
from fava.core.conversion import Conversion
Expand All @@ -43,7 +43,7 @@
ZERO = Decimal()


def _json_default(o: Any) -> Any:
def _json_default(o: Any) -> Any: # noqa: PLR0911
"""Specific serialisation for some data types."""
if isinstance(o, (date, Amount, Booking, Position)):
return str(o)
Expand All @@ -53,6 +53,10 @@ def _json_default(o: Any) -> Any:
return o.pattern
if is_dataclass(o):
return {field.name: getattr(o, field.name) for field in fields(o)}
if hasattr(o, "to_json"):
return simplejson_loads(o.to_json())
if isinstance(o, Mapping):
return dict(o)
if o is MISSING: # pragma: no cover
return None
raise TypeError # pragma: no cover
Expand Down
Loading
Loading