Changeset - a23d075add0e
[Not reviewed]
0 4 0
Brett Smith - 4 years ago 2020-06-09 13:04:27
brettcsmith@brettcsmith.org
books: Add Loader.load_none() method.
4 files changed with 38 insertions and 7 deletions:
0 comments (0 inline, 0 general)
conservancy_beancount/books.py
Show inline comments
...
 
@@ -9,48 +9,50 @@
 
# This program is distributed in the hope that it will be useful,
 
# but WITHOUT ANY WARRANTY; without even the implied warranty of
 
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 
# GNU Affero General Public License for more details.
 
#
 
# You should have received a copy of the GNU Affero General Public License
 
# along with this program.  If not, see <https://www.gnu.org/licenses/>.
 

	
 
import datetime
 

	
 
from pathlib import Path
 

	
 
from beancount import loader as bc_loader
 

	
 
from typing import (
 
    Any,
 
    Iterable,
 
    Iterator,
 
    Mapping,
 
    NamedTuple,
 
    Optional,
 
    Union,
 
)
 
from .beancount_types import (
 
    Error,
 
    Errors,
 
    LoadResult,
 
)
 

	
 
PathLike = Union[str, Path]
 
Year = Union[int, datetime.date]
 

	
 
class FiscalYear(NamedTuple):
 
    month: int = 3
 
    day: int = 1
 

	
 
    def for_date(self, date: Optional[datetime.date]=None) -> int:
 
        if date is None:
 
            date = datetime.date.today()
 
        if (date.month, date.day) < self:
 
            return date.year - 1
 
        else:
 
            return date.year
 

	
 
    def range(self, from_fy: Year, to_fy: Optional[Year]=None) -> Iterable[int]:
 
        """Return a range of fiscal years
 

	
 
        Both arguments can be either a year (represented as an integer) or a
 
        date. Dates will be converted into a year by calling for_date() on
 
        them.
...
 
@@ -150,24 +152,43 @@ class Loader:
 
                from_year = self.fiscal_year.for_date(from_year)
 
            elif from_year < 1000:
 
                from_year = self.fiscal_year.for_date() + from_year
 
            for index, path in enumerate(fy_paths):
 
                if self._path_year(path) >= from_year:
 
                    fy_paths = fy_paths[index:]
 
                    break
 
            else:
 
                fy_paths = []
 
        return self._load_paths(iter(fy_paths))
 

	
 
    def load_fy_range(self,
 
                      from_fy: Year,
 
                      to_fy: Optional[Year]=None,
 
    ) -> LoadResult:
 
        """Load books for a range of fiscal years
 

	
 
        This method generates a range of fiscal years by calling
 
        FiscalYear.range() with its arguments. It loads all the books within
 
        that range.
 
        """
 
        fy_range = self.fiscal_year.range(from_fy, to_fy)
 
        fy_paths = self._iter_fy_books(fy_range)
 
        return self._load_paths(fy_paths)
 

	
 
    @classmethod
 
    def load_none(cls, config_path: Optional[PathLike]=None, lineno: int=0) -> LoadResult:
 
        """Load no books and generate an error about it
 

	
 
        This is a convenience method for reporting tools that already handle
 
        general Beancount errors. If a configuration problem prevents them from
 
        loading the books, they can call this method in place of a regular
 
        loading method, and then continue on their normal code path.
 

	
 
        The path and line number given in the arguments will be named as the
 
        source of the error.
 
        """
 
        source = {
 
            'filename': str(config_path or 'conservancy_beancount.ini'),
 
            'lineno': lineno,
 
        }
 
        errors: Errors = [Error(source, "no books to load in configuration", None)]
 
        return [], errors, {}
conservancy_beancount/reports/accrual.py
Show inline comments
...
 
@@ -92,48 +92,49 @@ from typing import (
 
    Optional,
 
    Sequence,
 
    Set,
 
    TextIO,
 
    Tuple,
 
    TypeVar,
 
    Union,
 
)
 
from ..beancount_types import (
 
    Entries,
 
    Error,
 
    Errors,
 
    MetaKey,
 
    MetaValue,
 
    Transaction,
 
)
 

	
 
import odf.style  # type:ignore[import]
 
import odf.table  # type:ignore[import]
 
import rt
 

	
 
from beancount.parser import printer as bc_printer
 

	
 
from . import core
 
from .. import books
 
from .. import cliutil
 
from .. import config as configmod
 
from .. import data
 
from .. import filters
 
from .. import rtutil
 

	
 
PROGNAME = 'accrual-report'
 

	
 
CompoundAmount = TypeVar('CompoundAmount', data.Amount, core.Balance)
 
PostGroups = Mapping[Optional[MetaValue], 'AccrualPostings']
 
RTObject = Mapping[str, str]
 
T = TypeVar('T')
 

	
 
logger = logging.getLogger('conservancy_beancount.reports.accrual')
 

	
 
class Sentinel:
 
    pass
 

	
 

	
 
class Account(NamedTuple):
 
    name: str
 
    aging_thresholds: Sequence[int]
 

	
 

	
...
 
@@ -672,54 +673,49 @@ metadata to match. A single ticket number is a shortcut for
 
`TIK/ATT` format, is a shortcut for `invoice=LINK`.
 
""")
 
    args = parser.parse_args(arglist)
 
    if args.report_type is None and not args.search_terms:
 
        args.report_type = ReportType.AGING
 
    return args
 

	
 
def main(arglist: Optional[Sequence[str]]=None,
 
         stdout: TextIO=sys.stdout,
 
         stderr: TextIO=sys.stderr,
 
         config: Optional[configmod.Config]=None,
 
) -> int:
 
    if cliutil.is_main_script(PROGNAME):
 
        global logger
 
        logger = logging.getLogger(PROGNAME)
 
        sys.excepthook = cliutil.ExceptHook(logger)
 
    args = parse_arguments(arglist)
 
    cliutil.setup_logger(logger, args.loglevel, stderr)
 
    if config is None:
 
        config = configmod.Config()
 
        config.load_file()
 

	
 
    books_loader = config.books_loader()
 
    if books_loader is None:
 
        entries: Entries = []
 
        source = {
 
            'filename': str(config.config_file_path()),
 
            'lineno': 1,
 
        }
 
        load_errors: Errors = [Error(source, "no books to load in configuration", None)]
 
        entries, load_errors, _ = books.Loader.load_none(config.config_file_path())
 
    elif args.report_type is ReportType.AGING:
 
        entries, load_errors, _ = books_loader.load_all()
 
    else:
 
        entries, load_errors, _ = books_loader.load_all(args.since)
 
    filters.remove_opening_balance_txn(entries)
 

	
 
    returncode = 0
 
    postings = filter_search(data.Posting.from_entries(entries), args.search_terms)
 
    groups: PostGroups = dict(AccrualPostings.group_by_meta(postings, 'invoice'))
 
    for error in load_errors:
 
        bc_printer.print_error(error, file=stderr)
 
        returncode |= ReturnFlag.LOAD_ERRORS
 
    for related in groups.values():
 
        for error in related.report_inconsistencies():
 
            bc_printer.print_error(error, file=stderr)
 
            returncode |= ReturnFlag.CONSISTENCY_ERRORS
 
    if not groups:
 
        logger.warning("no matching entries found to report")
 
        returncode |= ReturnFlag.NOTHING_TO_REPORT
 

	
 
    groups = {
 
        key: posts
 
        for source_posts in groups.values()
 
        for key, posts in source_posts.make_consistent()
tests/test_books_loader.py
Show inline comments
...
 
@@ -86,24 +86,38 @@ def test_load_all(conservancy_loader, from_year):
 
    entries, errors, options_map = conservancy_loader.load_all(from_year)
 
    from_year = from_year or 2018
 
    assert not errors
 
    check_openings(entries)
 
    actual_years = txn_years(entries)
 
    assert actual_years.issuperset(range(from_year, 2021))
 
    assert min(actual_years) == from_year
 

	
 
@pytest.mark.parametrize('from_date', [
 
    date(2019, 2, 1),
 
    date(2019, 9, 15),
 
    date(2020, 1, 20),
 
    date(2020, 5, 31),
 
])
 
def test_load_all_from_date(conservancy_loader, from_date):
 
    from_year = from_date.year
 
    if from_date.month < FY_START_MONTH:
 
        from_year -= 1
 
    entries, errors, options_map = conservancy_loader.load_all(from_date)
 
    assert not errors
 
    check_openings(entries)
 
    actual_years = txn_years(entries)
 
    assert actual_years.issuperset(range(from_year, 2021))
 
    assert min(actual_years) == from_year
 

	
 
def test_load_none_full_args():
 
    entries, errors, options_map = books.Loader.load_none('test.cfg', 42)
 
    assert not entries
 
    assert errors
 
    assert all(err.source['filename'] == 'test.cfg' for err in errors)
 
    assert all(err.source['lineno'] == 42 for err in errors)
 

	
 
def test_load_none_no_args():
 
    entries, errors, options_map = books.Loader.load_none()
 
    assert not entries
 
    assert errors
 
    assert all(isinstance(err.source['filename'], str) for err in errors)
 
    assert all(isinstance(err.source['lineno'], int) for err in errors)
tests/test_reports_accrual.py
Show inline comments
...
 
@@ -716,44 +716,44 @@ def test_main_balance_report(arglist):
 
    ])
 

	
 
@pytest.mark.parametrize('arglist', [
 
    [],
 
    ['-t', 'aging', 'entity=Lawyer'],
 
])
 
def test_main_aging_report(tmp_path, arglist):
 
    if arglist:
 
        recv_rows = [row for row in AGING_AR if 'Lawyer' in row.entity]
 
        pay_rows = [row for row in AGING_AP if 'Lawyer' in row.entity]
 
    else:
 
        recv_rows = AGING_AR
 
        pay_rows = AGING_AP
 
    output_path = tmp_path / 'AgingReport.ods'
 
    arglist.insert(0, f'--output-file={output_path}')
 
    retcode, output, errors = run_main(arglist)
 
    assert not errors.getvalue()
 
    assert retcode == 0
 
    assert not output.getvalue()
 
    with output_path.open('rb') as ods_file:
 
        check_aging_ods(ods_file, None, recv_rows, pay_rows)
 

	
 
def test_main_no_books():
 
    check_main_fails([], testutil.TestConfig(), 1 | 8, [
 
        r':1: +no books to load in configuration\b',
 
        r':[01]: +no books to load in configuration\b',
 
    ])
 

	
 
@pytest.mark.parametrize('arglist', [
 
    ['499'],
 
    ['505/99999'],
 
    ['entity=NonExistent'],
 
])
 
def test_main_no_matches(arglist):
 
    check_main_fails(arglist, None, 8, [
 
        r': WARNING: no matching entries found to report$',
 
    ])
 

	
 
def test_main_no_rt():
 
    config = testutil.TestConfig(
 
        books_path=testutil.test_path('books/accruals.beancount'),
 
    )
 
    check_main_fails(['-t', 'out'], config, 4, [
 
        r': ERROR: unable to generate outgoing report: RT client is required\b',
 
    ])
0 comments (0 inline, 0 general)