Skip to content
Open
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
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,11 @@ source = [
[tool.ruff]
line-length = 79

[tool.ruff.format]
exclude = [
"src/fava/help/import.md",
]

[tool.ruff.lint]
extend-select = [
"ALL",
Expand Down
31 changes: 31 additions & 0 deletions src/fava/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

import logging
import mimetypes
import re
from datetime import date
from datetime import datetime
from datetime import timezone
Expand Down Expand Up @@ -422,6 +423,10 @@ def help_page(page_slug: str) -> str:
contents,
extras=["fenced-code-blocks", "tables", "header-ids"],
)
# Convert git describe output into something to put in a GitHub URL
# remove dirty suffix
github_version = _get_github_version(fava_version)
github_url = f"https://github.com/beancount/fava/tree/{github_version}/tests/data/import_example_for_docs.py"
return render_template(
"help.html",
page_slug=page_slug,
Expand All @@ -430,6 +435,7 @@ def help_page(page_slug: str) -> str:
html,
beancount_version=beancount_version,
fava_version=fava_version,
github_url=github_url,
),
),
HELP_PAGES=HELP_PAGES,
Expand Down Expand Up @@ -471,6 +477,31 @@ def _get_locale() -> str | None:
Babel(fava_app, locale_selector=_get_locale) # type: ignore[no-untyped-call]


def _get_github_version(version_string: str) -> str:
"""Convert a version string into a GitHub-compatible URL segment.

Args:
version_string: The version string, from setuptools_scm

Returns:
A string representing the commit hash, tag name, or 'main' as a
fallback.
"""
# Remove the dirty suffix if present
cleaned_version = re.sub(r"\.g[0-9]+$", "", version_string)
cleaned_version = re.sub(r"^v", "", version_string)

# Extract commit hash or tag name
commit_hash = re.search(r"\+g([0-9a-f]+)", cleaned_version)
tag_name = re.search(r"^[0-9]+(\.[0-9]+)*", cleaned_version)

if commit_hash:
return commit_hash.group(1)
if tag_name:
return tag_name.group(0)
return "main" # Fallback to main branch


def create_app(
files: Iterable[Path | str],
*,
Expand Down
10 changes: 6 additions & 4 deletions src/fava/help/beancount_syntax.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,16 @@ Beancount defines a language in which financial transactions are entered into a
text-file, which then can be processed by Beancount. There are a few building
blocks that are important to understand Beancount's syntax:

- Commodities,
- Commodities / Currencies,
- Accounts,
- Directives.

## Commodities
## Commodities / Currencies

All in CAPS: `USD`, `EUR`, `CAD`, `GOOG`, `AAPL`, `RBF1005`, `HOME_MAYST`,
`AIRMILES`, `HOURS`.
Anything that you want to track in some account is called a commodity in
Beancount. We sometimes also use the word "Currency" interchangeably in the
documentation. Write them in ALL-CAPS: `USD`, `EUR`, `CAD`, `GOOG`, `AAPL`,
`RBF1005`, `HOME_MAYST`, `AIRMILES`, `HOURS`.

## Accounts

Expand Down
116 changes: 95 additions & 21 deletions src/fava/help/import.md
Original file line number Diff line number Diff line change
@@ -1,27 +1,101 @@
# Import

Fava can use Beancount's import system to semi-automatically import entries into
your Beancount ledger. See
[Importing External Data in Beancount](http://furius.ca/beancount/doc/ingest)
for more information on how to write importers.

You can override the hooks that should be run for your importers by specifying a
variable `HOOKS` with the a list of hooks to apply to the list of
`(filename: str, entries: list[Directive])` tuples. On Beancount version 2 the
duplicates detector hook will be run by default and no hooks will run by default
on Beancount version 3. If you want to use beangulp-style hooks that take list
of
`(filename: str, entries: list[Directive], account: str, importer: Importer)`-tuples,
you can annotate them with the appropriate Python types which Fava will detect
and call with these 4-tuples.

Set the `import-config` option to point to your import config and set
`import-dirs` to the directories that contain the files that you want to import.
And if you wish to save new entries elsewhere than main file - use
`insert-entry` option.
# Importing entries into Fava

Fava integrates with Beancount's import system to automatically generate
transaction entries for your Beancount file from account statements. After
setting up your import process as explained below, the *Import* page in Fava
will show an upload button that lets you upload files into your *import folder*.
If you can access the import folder (e.g., if Fava is running on your local
machine), you can also just place your account statement files there directly.

To import the file contents into your Beancount ledger, you must set up an
*Importer* that can process the file format. Fava lists each file in the import
folder. If any Importer matches the file, a button "Extract" will be visible.
This will import its contents into your ledger. Files without a matching
Importer appear as "Non-importable file".

You can move any file into your
[documents folder](https://beancount.github.io/docs/beancount_language_syntax.html#documents-from-a-directory)
by clicking the button "Move". If you do not use the document folder feature of
Beancount, you can delete the file from the import folder after the import is
complete by clicking the button with the cross at the top right.

## Define the import configuration in your Beancount ledger

Add these lines to your Beancount file:

<pre><textarea is="beancount-textarea">
2021-01-01 custom "fava-option" "import-dirs" "folder/path/"
2021-01-01 custom "fava-option" "import-config" "my-import.py"
; ...
; ... Beancount entries ...
; ...
; Optional: New entries of all accounts (.*) that date earlier than 2022-01-01
; will be inserted before this line. See the Fava help, section "Options".
; 2022-01-01 custom "fava-option" "insert-entry" ".*"
</textarea></pre>

The first line specifies the import folder location. The second line defines the
import configuration - a Python script that handles the format of your account
statements. Fava interprets relative paths relative to your main Beancount
ledger file location. You can also use absolute paths.

## Write your import configuration

Your import configuration must be a Python file that defines:

- `CONFIG`: A list of Importers that process your account statement files.
Create subclasses of `beangulp.importers.Importer` with parsing logic for your
specific account statement formats, then add instances of each class to this
list. For CSV files, subclassing `beangulp.importers.csvbase.Importer` is
recommended.

- `HOOKS`: A list of functions to apply to all directives (e.g., transactions)
after generation by any Importer.

For more information on how to write importers, see

- [Importing External Data in Beancount](http://furius.ca/beancount/doc/ingest)
in the beancount manual.
- The <a href="{{ github_url }}">Example input configuration file</a>

Hook functions are explained in more detail further down on this page.

Fava currently only supports entries of type `Transaction`, `Balance`, and
`Note`. Set the special metadata key `__source__` to display the corresponding
text (CSV-row, XML-fragment, etc.) for the entry in the list of entries to
import. Note that this metadata (and all other metadata keys starting with an
underscore) will be stripped before saving the entry to file.

## Hook functions

Hook functions are applied to all imported transactions. A hook function
receives the parameters `hook_fn(new_entries_list, existing_entries)` and
returns the modified `new_entries_list`.

The argument `new_entries_list` is itself a list of tuples. As Fava imports each
file individually (in contrast to the CLI of Beangulp), this list will always be
of length 1.

The tuples can can have two (old style, default) or four elements
(beangulp-style). The type signature of the tuples is either

- `(filename: str, entries: list[Directive])` or
- `(filename: str, entries: list[Directive], account: str, importer: Importer)`.

You can annotate the hook function with the appropriate Python types and Fava
will detect and call it with these 4-tuples. Types `Directive` and `Importer`
are `beancount.core.data.Directive` and `beangulp.Importer`, respectively.

So, in summary, the type signature of a hook function is either:

```
hook_fn(new_entries_list: List[str, List[Directive]],
existing_entries: Sequence[Directive]) -> List[str, List[Directive]]
```

or

```
hook_fn(new_entries_list: List[str, list[Directive], str, Importer],
existing_entries: Sequence[Directive]) -> List[str, List[Directive]]
```
4 changes: 3 additions & 1 deletion src/fava/help/options.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ following to your Beancount file.
2016-04-14 custom "fava-option" "auto-reload" "true"
2016-04-14 custom "fava-option" "currency-column" "100"</textarea></pre>

Below is a list of all possible options for Fava.
Below is a list of all possible options for Fava. In the Beancount documentation
you can find the
[list of Beancount options](https://beancount.github.io/docs/beancount_options_reference.html)

______________________________________________________________________

Expand Down
21 changes: 21 additions & 0 deletions stubs/beangulp/__init__.pyi
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import datetime
from abc import ABC
from abc import abstractmethod
from collections.abc import Callable
from collections.abc import Sequence

from fava.beans.abc import Account
Expand All @@ -24,3 +25,23 @@ class Importer(ABC):
def sort(
self, entries: list[Directive], reverse: bool = False
) -> None: ...

class Ingest:
def __init__(
self,
importer: Sequence[Importer],
hooks: Sequence[
Callable[
[
Sequence[
tuple[str, Sequence[Directive], str, Importer]
], # new_entries
Sequence[Directive], # existing_entries
],
Sequence[
tuple[str, Sequence[Directive], str, Importer]
], # (return value)
]
],
) -> None: ...
def __call__(self) -> None: ...
1 change: 1 addition & 0 deletions stubs/beangulp/importers/__init__.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# This file is intentionally left empty to make the directory a package
75 changes: 75 additions & 0 deletions stubs/beangulp/importers/csvbase.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import csv
import datetime
import enum
from collections.abc import Sequence
from typing import Any
from typing import NamedTuple
from typing import TypeAlias

import beancount.core.amount
import beangulp
from beancount.core import data

from fava.beans.abc import Directive

Row: TypeAlias = NamedTuple

class Order(enum.Enum):
ASCENDING = ...
DESCENDING = ...

class Column:
name: str
def __init__(self, name: str) -> None: ...
def parse(self, value: str) -> Any: ...

class Date(Column):
format: str
def __init__(self, name: str, frmt: str) -> None: ...
def parse(self, value: str) -> datetime.date: ...

class Amount(Column):
subs: dict[str, str]
def __init__(
self, name: str, subs: dict[str, str] | None = None
) -> None: ...
def parse(self, value: str) -> beancount.core.amount.Amount: ...

class Columns(Column):
columns: list[str]
sep: str
def __init__(self, *columns: str, sep: str = " ") -> None: ...
def parse(self, value: str) -> str: ...

class CreditOrDebit(Column):
credit_name: str
debit_name: str
def __init__(self, credit_name: str, debit_name: str) -> None: ...
def parse(self, value: str) -> beancount.core.amount.Amount: ...

class CSVMeta(type): ...

class CSVReader:
encoding: str
skiplines: int
names: bool
dialect: str | csv.Dialect
comments: str
order: Order | None

def read(self, filepath: str) -> list[Row]: ...

class Importer(beangulp.Importer, CSVReader):
def __init__(
self, account: str, currency: str, flag: str = "*"
) -> None: ...
def account(self, filepath: str) -> str: ...
def date(self, filepath: str) -> datetime.date: ...
def extract(
self, filepath: str, existing: Sequence[Directive]
) -> list[Directive]: ...
def finalize(
self, txn: data.Transaction, row: Row
) -> data.Transaction | None: ...
def identify(self, filepath: str) -> bool: ...
def metadata(self, filepath: str, lineno: int, row: Row) -> data.Meta: ...
10 changes: 10 additions & 0 deletions tests/__snapshots__/test_json_api-test_api_imports.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,16 @@
"importers": [],
"name": "TEST_DATA_DIR/import_config.py"
},
{
"basename": "import_example_for_docs.py",
"importers": [],
"name": "TEST_DATA_DIR/import_example_for_docs.py"
},
{
"basename": "import_for_docs.beancount",
"importers": [],
"name": "TEST_DATA_DIR/import_for_docs.beancount"
},
{
"basename": "invalid-unicode.beancount",
"importers": [],
Expand Down
Loading