Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Include option to stream print to stderr #18

Merged
merged 1 commit into from
Jun 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .pylintrc
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@
# usually to register additional checkers.
load-plugins=pylint.extensions.broad_try_clause,
pylint.extensions.confusing_elif,
pylint.extensions.comparetozero,
pylint.extensions.bad_builtin,
pylint.extensions.mccabe,
pylint.extensions.docstyle,
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ subcommands:
* [Injection](#injection)
* [Expired expected transactions](#expired-expected-transactions)
* [Worth remembering](#worth-remembering)
* [Developers](#developers)
* [Alternative packages](#alternative-packages)
* [beancount recommendations](#beancount-recommendations)
* [Licence](#license)
Expand Down Expand Up @@ -375,6 +376,19 @@ With a bit of luck and perhaps a tweak or two to your ledger, your `bean-check`
## Worth remembering
> :warning: Whenever an expected transactions ledger or the regular expected transaction definition files are updated the entries are resorted and the file is overwritten - anything that is not a directive (e.g. comments) will be lost.

## Developers
If you are employing a workflow that directs output through `sys.stdout` then this will merge with Beanahead's own output to this stream. To avoid such conflicts `beanahead` provides for directing its output to `sys.stderr`. This can be set from the command line by preceeding any subcommand with --print_stderr, for example...
```
$ beanahead --print_stderr exp rx x
```
The same effect can be achieved with the flag -p.
```
$ beanahead -p exp rx x
```
If using the underlying functions directly in the codebase then the print stream can be set via the following methods of the `beanahead.config` module:
- `set_print_stderr()`
- `set_print_stdout()`

## Alternative packages
The beancount community offers a considerable array of add-on packages, many of which are well-rated and maintained. Below I've noted those I know of with functionality that includes some of what `beanahead` offers. Which package you're likely to find most useful will come down to your specific circumstances and requirements - horses for courses.
* [beancount-import](https://github.com/jbms/beancount-import) - an importer interface. Functionality provides for adding expected transactions directly to the main ledger and later merging these with imported transactions via a web-based UI. It requires implementing the importer interface and doesn't directly provide for regular expected transactions. But, if that import interface works for you then you'll probably want to be using `beancount-import`. (If you need the regular trasactions functionality provided by `beanahead`, just use `beanahead` to generate the transactions, copy them over to your ledger and let `beancount-import` handle the subsequent reconcilation.)
Expand Down
22 changes: 22 additions & 0 deletions src/beanahead/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""Configuration file for options."""

import sys

_print_stdout = True


def set_print_stdout():
"""Set output stream for print to stdout."""
global _print_stdout
_print_stdout = True


def set_print_stderr():
"""Set output stream for print to stderr."""
global _print_stdout
_print_stdout = False


def get_print_file():
"""Get stream to print to."""
return sys.stdout if _print_stdout else sys.stderr
8 changes: 4 additions & 4 deletions src/beanahead/expired.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ def _update_txn(txn: Transaction, path: Path) -> Transaction | None:
None if user choose to remove.
Otherwise a new Transaction object with revised date.
"""
print(
utils.print_it(
f"{utils.SEPARATOR_LINE}\nThe following transaction has expired."
f"\n\n{utils.compose_entries_content(txn)}"
f"\n0 Move transaction forwards to tomorrow ({TOMORROW})."
Expand Down Expand Up @@ -245,7 +245,7 @@ def admin_expired_txns(ledgers: list[str]):

if no_expired_txns:
paths_string = "\n".join([str(path) for path in paths])
print(
utils.print_it(
"There are no expired transactions on any of the following"
f" ledgers:\n{paths_string}"
)
Expand All @@ -254,7 +254,7 @@ def admin_expired_txns(ledgers: list[str]):
updated_paths = [path for path in paths if ledger_updated[path]]
paths_string = "\n".join([str(path) for path in updated_paths])
if not updated_paths:
print(
utils.print_it(
"\nYou have not choosen to modify any expired transactions."
"\nNo ledger has been altered."
)
Expand All @@ -266,4 +266,4 @@ def admin_expired_txns(ledgers: list[str]):
updated_contents[path] = content

overwrite_ledgers(updated_contents)
print(f"\nThe following ledgers have been updated:\n{paths_string}")
utils.print_it(f"\nThe following ledgers have been updated:\n{paths_string}")
19 changes: 13 additions & 6 deletions src/beanahead/reconcile.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,9 @@ def get_pattern(x_txn: Transaction) -> re.Pattern:
def get_payee_matches(txns: list[Transaction], x_txn: Transaction) -> list[Transaction]:
"""Return transactions matching an Expected Transaction's payee."""
pattern = get_pattern(x_txn)
return [txn for txn in txns if pattern.search(txn.payee) is not None]
return [
txn for txn in txns if (txn.payee and pattern.search(txn.payee) is not None)
]


def get_common_accounts(a: Transaction, b: Transaction) -> set[str]:
Expand Down Expand Up @@ -360,7 +362,7 @@ def confirm_single(
Matched transaction, if user confirm match.
None if user rejects match.
"""
print(
utils.print_it(
f"{utils.SEPARATOR_LINE}Expected Transaction:\n"
f"{utils.compose_entries_content(x_txn)}\n"
f"Incoming Transaction:\n{utils.compose_entries_content(matches[0])}"
Expand Down Expand Up @@ -390,13 +392,13 @@ def get_mult_match(
Matched transaction, as choosen by user.
None if user rejects all matches.
"""
print(
utils.print_it(
f"{utils.SEPARATOR_LINE}Expected Transaction:\n"
f"{utils.compose_entries_content(x_txn)}\n\n"
f"Incoming Transactions:\n"
)
for i, match in enumerate(matches):
print(f"{i}\n{utils.compose_entries_content(match)}")
utils.print_it(f"{i}\n{utils.compose_entries_content(match)}")

max_value = len(matches) - 1
options = f"[0-{max_value}]/n"
Expand Down Expand Up @@ -522,7 +524,12 @@ def update_new_txn(new_txn: Transaction, x_txn: Transaction) -> Transaction:
new_txn_posting = get_posting_to_account(new_txn, account)

# carry over any meta not otherwise defined on new_txn
updated_posting = new_txn_posting._replace(meta=new_txn_posting.meta.copy())
if new_txn_posting.meta:
updated_posting = new_txn_posting._replace(
meta=new_txn_posting.meta.copy()
)
else:
updated_posting = new_txn_posting._replace(meta={})
for k, v in posting.meta.items():
updated_posting.meta.setdefault(k, v)

Expand Down Expand Up @@ -730,4 +737,4 @@ def reconcile_new_txns(
for path, txns in x_txns_to_remove.items():
msg += f"\n{len(txns)} transactions have been removed from ledger {path}."

print(msg)
utils.print_it(msg)
6 changes: 4 additions & 2 deletions src/beanahead/rx_txns.py
Original file line number Diff line number Diff line change
Expand Up @@ -651,7 +651,9 @@ def add_txns(self, end: str | pd.Timestamp = END_DFLT):

new_txns, new_defs = self._get_new_txns_data(end)
if not new_txns:
print(f"There are no new Regular Expected Transactions to add with {end=}.")
utils.print_it(
f"There are no new Regular Expected Transactions to add with {end=}."
)
return

ledger_txns = self.rx_txns + new_txns
Expand All @@ -664,7 +666,7 @@ def add_txns(self, end: str | pd.Timestamp = END_DFLT):
also_revert = [self.path_ledger]
self._overwrite_beancount_file(self.path_defs, content_defs, also_revert)
self._validate_main_ledger(self.rx_files)
print(
utils.print_it(
f"{len(new_txns)} transactions have been added to the ledger"
f" '{self.path_ledger.stem}'.\nDefinitions on '{self.path_defs.stem}' have"
f" been updated to reflect the most recent transactions."
Expand Down
17 changes: 16 additions & 1 deletion src/beanahead/scripts/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import datetime

import beanahead
from beanahead import utils, rx_txns, reconcile, expired
from beanahead import utils, rx_txns, reconcile, expired, config


def make_file(args: argparse.Namespace):
Expand Down Expand Up @@ -61,6 +61,16 @@ def main():
"--version", "-V", action="version", version=beanahead.__version__
)

parser.add_argument(
*["-p", "--print_stderr"],
action="store_true",
help=(
"send any print to stderr rather than stdoud (this can"
"\nbe useful if the client wishes to use stdout for another"
"\npurpose)"
),
)

subparsers = parser.add_subparsers(
title="subcommands",
dest="subcmd",
Expand Down Expand Up @@ -259,7 +269,12 @@ def main():

# Call pass-through function corresponding with subcommand
args = parser.parse_args()

# Set print stream and revert to stdout
if args.print_stderr:
config.set_print_stderr()
args.func(args)
config.set_print_stdout()


if __name__ == "__main__":
Expand Down
23 changes: 20 additions & 3 deletions src/beanahead/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from beangulp.extract import HEADER
from beancount.parser import parser, printer

from . import config
from .errors import (
BeancountFileExistsError,
BeanaheadFileExistsError,
Expand Down Expand Up @@ -68,6 +69,21 @@
LEDGER_FILE_KEYS = ["x", "rx"]


def print_it(text: str, **kwargs):
"""Print to the selected stream.

Parameters
----------
text
Text to print.

kwargs
Kwargs to pass to 'print' function.
"""
kwargs["file"] = config.get_print_file()
print(text, **kwargs)


def validate_file_key(file_key: str):
"""Validate a file_key.

Expand Down Expand Up @@ -320,8 +336,8 @@ def get_verified_file_key(path: Path) -> str:
opts = get_options(path)
title = opts["title"]
all_titles = []
for file_key, config in FILE_CONFIG.items():
if title == (config_title := config["title"]):
for file_key, config_ in FILE_CONFIG.items():
if title == (config_title := config_["title"]):
return file_key
all_titles.append(config_title)
titles_string = "\n".join(all_titles)
Expand Down Expand Up @@ -845,7 +861,8 @@ def get_input(text: str) -> str:
-----
Function included to facilitate mocking user input when testing.
"""
return input(text)
print_it(text, end=": ")
return input()


def response_is_valid_number(response: str, max_value: int) -> bool:
Expand Down
19 changes: 14 additions & 5 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from beancount.core import data
import pytest

from beanahead import utils

# pylint: disable=missing-function-docstring, missing-type-doc
# pylint: disable=missing-param-doc, missing-any-param-doc, redefined-outer-name
Expand Down Expand Up @@ -71,11 +72,19 @@ def get_expected_output(string: str):


def also_get_stdout(f: abc.Callable, *args, **kwargs) -> tuple[typing.Any, str]:
"""Return a function's return together with output to stdout."""
stdout = io.StringIO()
with contextlib.redirect_stdout(stdout):
"""Return a function's return together with print to stdout."""
prnt = io.StringIO()
with contextlib.redirect_stdout(prnt):
rtrn = f(*args, **kwargs)
return rtrn, stdout.getvalue()
return rtrn, prnt.getvalue()


def also_get_stderr(f: abc.Callable, *args, **kwargs) -> tuple[typing.Any, str]:
"""Return a function's return together with print to stderr."""
prnt = io.StringIO()
with contextlib.redirect_stderr(prnt):
rtrn = f(*args, **kwargs)
return rtrn, prnt.getvalue()


@pytest.fixture
Expand Down Expand Up @@ -132,7 +141,7 @@ def __init__(self, responses: abc.Generator[str]):
monkeypatch.setattr("beanahead.utils.get_input", self.input)

def input(self, string: str) -> str:
print(string)
utils.print_it(string)
return next(self.responses)

yield MockInput
Expand Down
47 changes: 47 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Tests for `config` module."""

import sys

from beanahead import config as m

from .conftest import also_get_stdout, also_get_stderr

# pylint: disable=missing-function-docstring, missing-type-doc, missing-class-docstring
# pylint: disable=missing-param-doc, missing-any-param-doc, redefined-outer-name
# pylint: disable=too-many-public-methods, too-many-arguments, too-many-locals
# pylint: disable=too-many-statements
# pylint: disable=protected-access, line-too-long, unused-argument, invalid-name
# missing-fuction-docstring: doc not required for all tests
# protected-access: not required for tests
# not compatible with use of fixtures to parameterize tests:
# too-many-arguments, too-many-public-methods
# not compatible with pytest fixtures:
# redefined-outer-name, missing-any-param-doc, missing-type-doc
# unused-argument: not compatible with pytest fixtures, caught by pylance anyway.
# invalid-name: names in tests not expected to strictly conform with snake_case.


def test_print_stream():
"""Tests output of print stream between stdout and stderr."""
assert m._print_stdout is True
assert m.get_print_file() is sys.stdout

def print_something():
print("something", file=m.get_print_file())

_, prnt = also_get_stdout(print_something)
assert prnt == "something\n"
_, prnt = also_get_stderr(print_something)
assert not prnt

m.set_print_stderr()
_, prnt = also_get_stdout(print_something)
assert not prnt
_, prnt = also_get_stderr(print_something)
assert prnt == "something\n"

m.set_print_stdout()
_, prnt = also_get_stdout(print_something)
assert prnt == "something\n"
_, prnt = also_get_stderr(print_something)
assert not prnt
36 changes: 36 additions & 0 deletions tests/test_expired.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
set_cl_args,
get_expected_output,
also_get_stdout,
also_get_stderr,
)

# pylint: disable=missing-function-docstring, missing-type-doc, missing-class-docstring
Expand Down Expand Up @@ -361,3 +362,38 @@ def test_cli_exp(
assert output.endswith(expected_print)
assert filepath_x.read_text(encoding) == expected_x
assert filepath_rx.read_text(encoding) == expected_rx

@pytest.mark.usefixtures("cwd_as_temp_dir")
def test_cli_exp_print_to_stderr(
self,
monkeypatch,
mock_input,
filepaths_copy,
expected_x,
expected_rx,
encoding,
):
"""As `test_cli_exp` with print to stderr.

Serves to test --print_stderr cli arg.
"""
mock_today(datetime.date(2022, 11, 15), monkeypatch)
tomorrow = datetime.date(2022, 11, 16)
mock_tomorrow(tomorrow, monkeypatch)

filepath_x = filepaths_copy["x"]
filepath_rx = filepaths_copy["rx"]

expected_print = get_expected_output(
rf"""
The following ledgers have been updated:
{filepath_x}
{filepath_rx}
"""
)
mock_input((v for v in ["3", "0", "1", "2022-11-20", "2", "0"]))
set_cl_args("--print_stderr exp x rx")
_, output = also_get_stderr(cli.main)
assert output.endswith(expected_print)
assert filepath_x.read_text(encoding) == expected_x
assert filepath_rx.read_text(encoding) == expected_rx
Loading