Changeset - 71d671e493ad
[Not reviewed]
0 3 0
Brett Smith - 4 years ago 2020-06-14 12:53:27
brettcsmith@brettcsmith.org
data: Add Metadata.human_name() classmethod.
3 files changed with 50 insertions and 12 deletions:
0 comments (0 inline, 0 general)
conservancy_beancount/data.py
Show inline comments
...
 
@@ -14,24 +14,25 @@ throughout Conservancy tools.
 
# 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 collections
 
import datetime
 
import decimal
 
import functools
 
import re
 

	
 
from beancount.core import account as bc_account
 
from beancount.core import amount as bc_amount
 
from beancount.core import convert as bc_convert
 
from beancount.core import position as bc_position
 

	
 
from typing import (
 
    cast,
 
    overload,
 
    Callable,
 
    Hashable,
 
    Iterable,
...
 
@@ -204,24 +205,31 @@ class Amount(bc_amount.Amount):
 
    # So we override __new__ just to call _Amount.__new__ this way.
 
    def __new__(cls, number: decimal.Decimal, currency: str) -> 'Amount':
 
        return super(bc_amount.Amount, Amount).__new__(cls, number, currency)
 

	
 

	
 
class Metadata(MutableMapping[MetaKey, MetaValue]):
 
    """Transaction or posting metadata
 

	
 
    This class wraps a Beancount metadata dictionary with additional methods
 
    for common parsing and query tasks.
 
    """
 
    __slots__ = ('meta',)
 
    _HUMAN_NAMES: MutableMapping[MetaKey, str] = {
 
        # Initialize this dict with special cases.
 
        # We use it as a cache for other metadata names as they're queried.
 
        'check-id': 'Check Number',
 
        'paypal-id': 'PayPal ID',
 
        'rt-id': 'Ticket',
 
    }
 

	
 
    def __init__(self, source: MutableMapping[MetaKey, MetaValue]) -> None:
 
        self.meta = source
 

	
 
    def __iter__(self) -> Iterator[MetaKey]:
 
        return iter(self.meta)
 

	
 
    def __len__(self) -> int:
 
        return len(self.meta)
 

	
 
    def __getitem__(self, key: MetaKey) -> MetaValue:
 
        return self.meta[key]
...
 
@@ -247,24 +255,40 @@ class Metadata(MutableMapping[MetaKey, MetaValue]):
 
    @overload
 
    def first_link(self, key: MetaKey, default: None=None) -> Optional[str]: ...
 

	
 
    @overload
 
    def first_link(self, key: MetaKey, default: str) -> str: ...
 

	
 
    def first_link(self, key: MetaKey, default: Optional[str]=None) -> Optional[str]:
 
        try:
 
            return self.get_links(key)[0]
 
        except (IndexError, TypeError):
 
            return default
 

	
 
    @classmethod
 
    def human_name(cls, key: MetaKey) -> str:
 
        """Return the "human" version of a metadata name
 

	
 
        This is usually the metadata key with punctuation replaced with spaces,
 
        and then titlecased, with a few special cases. The return value is
 
        suitable for using in reports.
 
        """
 
        try:
 
            retval = cls._HUMAN_NAMES[key]
 
        except KeyError:
 
            retval = key.replace('-', ' ').title()
 
            retval = re.sub(r'\bId$', 'ID', retval)
 
            cls._HUMAN_NAMES[key] = retval
 
        return retval
 

	
 

	
 
class PostingMeta(Metadata):
 
    """Combined access to posting metadata with its parent transaction metadata
 

	
 
    This lets you access posting metadata through a single dict-like object.
 
    If you try to look up metadata that doesn't exist on the posting, it will
 
    look for the value in the parent transaction metadata instead.
 

	
 
    You can set and delete metadata as well. Changes only affect the metadata
 
    of the posting, never the transaction. Changes are propagated to the
 
    underlying Beancount data structures.
 

	
conservancy_beancount/reports/accrual.py
Show inline comments
...
 
@@ -264,35 +264,38 @@ class BaseReport:
 
        self.logger = logger.getChild(type(self).__name__)
 

	
 
    def _report(self, posts: AccrualPostings, index: int) -> Iterable[str]:
 
        raise NotImplementedError("BaseReport._report")
 

	
 
    def run(self, groups: PostGroups) -> None:
 
        for index, invoice in enumerate(groups):
 
            for line in self._report(groups[invoice], index):
 
                print(line, file=self.out_file)
 

	
 

	
 
class AgingODS(core.BaseODS[AccrualPostings, Optional[data.Account]]):
 
    DOC_COLUMNS = [
 
        'rt-id',
 
        'invoice',
 
        'approval',
 
        'contract',
 
        'purchase-order',
 
    ]
 
    COLUMNS = [
 
        'Date',
 
        'Entity',
 
        data.Metadata.human_name('entity'),
 
        'Invoice Amount',
 
        'Booked Amount',
 
        'Project',
 
        'Ticket',
 
        'Invoice',
 
        'Approval',
 
        'Contract',
 
        'Purchase Order',
 
        data.Metadata.human_name('project'),
 
        *(data.Metadata.human_name(key) for key in DOC_COLUMNS),
 
    ]
 
    COL_COUNT = len(COLUMNS)
 

	
 
    def __init__(self,
 
                 rt_wrapper: rtutil.RT,
 
                 date: datetime.date,
 
                 logger: logging.Logger,
 
    ) -> None:
 
        super().__init__(rt_wrapper)
 
        self.date = date
 
        self.logger = logger
 

	
...
 
@@ -401,29 +404,26 @@ class AgingODS(core.BaseODS[AccrualPostings, Optional[data.Account]]):
 
        if raw_balance == row.end_balance:
 
            amount_cell = odf.table.TableCell()
 
        else:
 
            amount_cell = self.balance_cell(raw_balance)
 
        projects = {post.meta.get('project') or None for post in row}
 
        projects.discard(None)
 
        self.add_row(
 
            self.date_cell(row[0].meta.date),
 
            self.multiline_cell(row.entities()),
 
            amount_cell,
 
            self.balance_cell(row.end_balance),
 
            self.multiline_cell(sorted(projects)),
 
            self.meta_links_cell(row.all_meta_links('rt-id')),
 
            self.meta_links_cell(row.all_meta_links('invoice')),
 
            self.meta_links_cell(row.all_meta_links('approval')),
 
            self.meta_links_cell(row.all_meta_links('contract')),
 
            self.meta_links_cell(row.all_meta_links('purchase-order')),
 
            *(self.meta_links_cell(row.all_meta_links(key))
 
              for key in self.DOC_COLUMNS),
 
        )
 

	
 

	
 
class AgingReport(BaseReport):
 
    def __init__(self,
 
                 rt_wrapper: rtutil.RT,
 
                 out_file: BinaryIO,
 
                 date: Optional[datetime.date]=None,
 
    ) -> None:
 
        if date is None:
 
            date = datetime.date.today()
 
        self.out_bin = out_file
tests/test_data_metadata.py
Show inline comments
...
 
@@ -80,12 +80,26 @@ def test_first_link_nonexistent_default(simple_txn):
 

	
 
@pytest.mark.parametrize('meta_value', testutil.NON_STRING_METADATA_VALUES)
 
def test_first_link_bad_type_metadata(simple_txn, meta_value):
 
    simple_txn.meta['badmeta'] = meta_value
 
    meta = data.PostingMeta(simple_txn, 1)
 
    assert meta.first_link('badmeta') is None
 

	
 
@pytest.mark.parametrize('meta_value', testutil.NON_STRING_METADATA_VALUES)
 
def test_first_link_bad_type_default(simple_txn, meta_value):
 
    simple_txn.meta['badmeta'] = meta_value
 
    meta = data.PostingMeta(simple_txn, 1)
 
    assert meta.first_link('badmeta', '_') == '_'
 

	
 
@pytest.mark.parametrize('meta_name,expected', [
 
    ('approval', 'Approval'),
 
    ('bank-id', 'Bank ID'),
 
    ('bank-statement', 'Bank Statement'),
 
    ('check-id', 'Check Number'),
 
    ('paypal-id', 'PayPal ID'),
 
    ('purchase-order', 'Purchase Order'),
 
    ('receipt', 'Receipt'),
 
    ('rt-id', 'Ticket'),
 
    ('tax-statement', 'Tax Statement'),
 
])
 
def test_human_name(meta_name, expected):
 
    assert data.Metadata.human_name(meta_name) == expected
0 comments (0 inline, 0 general)