util: Add parse_currency_dec.
The current importers trim lots of extraneous symbols and whitespace from currency strings before passing them to Decimal(). This function takes care of all that in a single place.
This commit is contained in:
		
							parent
							
								
									0734b6f7a5
								
							
						
					
					
						commit
						6ea28c2c89
					
				
					 6 changed files with 54 additions and 9 deletions
				
			
		| 
						 | 
				
			
			@ -88,7 +88,7 @@ class Invoice2017:
 | 
			
		|||
            elif description.startswith('Early Bird ('):
 | 
			
		||||
                self.ticket_rate = self.DISCOUNT_TICKET_RATE
 | 
			
		||||
            if qty:
 | 
			
		||||
                self.amount += decimal.Decimal(total.lstrip('$'))
 | 
			
		||||
                self.amount += util.parse_currency_dec(total)
 | 
			
		||||
 | 
			
		||||
    def _read_invoice_activity(self, table, first_row_text, rows_text):
 | 
			
		||||
        self.actions = [{
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -11,6 +11,9 @@ class IncomeImporter(_csv.CSVImporterBase):
 | 
			
		|||
        'Pledge',
 | 
			
		||||
        'Status',
 | 
			
		||||
    ])
 | 
			
		||||
    COPIED_FIELDS = {
 | 
			
		||||
        'Pledge': 'amount',
 | 
			
		||||
    }
 | 
			
		||||
    ENTRY_SEED = {
 | 
			
		||||
        'currency': 'USD',
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			@ -28,7 +31,6 @@ class IncomeImporter(_csv.CSVImporterBase):
 | 
			
		|||
            return None
 | 
			
		||||
        else:
 | 
			
		||||
            return {
 | 
			
		||||
                'amount': row['Pledge'].replace(',', ''),
 | 
			
		||||
                'payee': '{0[FirstName]} {0[LastName]}'.format(row),
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -41,7 +43,7 @@ class FeeImporterBase(_csv.CSVImporterBase):
 | 
			
		|||
 | 
			
		||||
    def _read_row(self, row):
 | 
			
		||||
        return {
 | 
			
		||||
            'amount': row[self.AMOUNT_FIELD].lstrip('$'),
 | 
			
		||||
            'amount': row[self.AMOUNT_FIELD],
 | 
			
		||||
            'date': util.strpdate(row['Month'], '%Y-%m'),
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -27,6 +27,6 @@ class PaymentImporter(_csv.CSVImporterBase):
 | 
			
		|||
            return {
 | 
			
		||||
                'currency': row['Converted Currency'].upper(),
 | 
			
		||||
                'date': util.strpdate(row['Created (UTC)'].split(None, 1)[0], self.DATE_FMT),
 | 
			
		||||
                'fee': decimal.Decimal(row['Fee']),
 | 
			
		||||
                'tax': decimal.Decimal(row['Tax']),
 | 
			
		||||
                'fee': util.parse_currency_dec(row['Fee']),
 | 
			
		||||
                'tax': util.parse_currency_dec(row['Tax']),
 | 
			
		||||
            }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -9,7 +9,7 @@ import tokenize
 | 
			
		|||
 | 
			
		||||
import babel.numbers
 | 
			
		||||
 | 
			
		||||
from . import errors
 | 
			
		||||
from . import errors, util
 | 
			
		||||
 | 
			
		||||
class TokenTransformer:
 | 
			
		||||
    def __init__(self, source):
 | 
			
		||||
| 
						 | 
				
			
			@ -253,7 +253,7 @@ class Template:
 | 
			
		|||
        template_vars.update(
 | 
			
		||||
            date=date.strftime(self.date_fmt),
 | 
			
		||||
            payee=payee,
 | 
			
		||||
            amount=decimal.Decimal(amount),
 | 
			
		||||
            amount=util.parse_currency_dec(amount),
 | 
			
		||||
            currency=currency,
 | 
			
		||||
        )
 | 
			
		||||
        for key, value in template_vars.items():
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,5 +1,48 @@
 | 
			
		|||
import datetime
 | 
			
		||||
import decimal
 | 
			
		||||
import functools
 | 
			
		||||
import re
 | 
			
		||||
import unicodedata
 | 
			
		||||
 | 
			
		||||
import babel.numbers
 | 
			
		||||
 | 
			
		||||
@functools.lru_cache()
 | 
			
		||||
def _currency_pattern(locale):
 | 
			
		||||
    minus = babel.numbers.get_minus_sign_symbol(locale)
 | 
			
		||||
    plus = babel.numbers.get_plus_sign_symbol(locale)
 | 
			
		||||
    dec_sym = babel.numbers.get_decimal_symbol(locale)
 | 
			
		||||
    sep_sym = '.' if dec_sym == ',' else ','
 | 
			
		||||
    return r'([{}{}]?)\s*(\W?)\s*(\d+(?:{}\d+)*(?:{}\d*)?)'.format(
 | 
			
		||||
        minus,
 | 
			
		||||
        plus,
 | 
			
		||||
        re.escape(sep_sym),
 | 
			
		||||
        re.escape(dec_sym),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
def parse_currency_dec(s, locale='en_US_POSIX'):
 | 
			
		||||
    try:
 | 
			
		||||
        match = re.search(_currency_pattern(locale), s)
 | 
			
		||||
    except TypeError:
 | 
			
		||||
        return decimal.Decimal(s)
 | 
			
		||||
    if not match:
 | 
			
		||||
        raise ValueError("no decimal found in {!r}".format(s))
 | 
			
		||||
    # There may be extra symbols/text before the number, after the number,
 | 
			
		||||
    # or between the number and its sign—but only in one of those places.
 | 
			
		||||
    extra = None
 | 
			
		||||
    for extra_s in [s[:match.start()], match.group(2), s[match.end():]]:
 | 
			
		||||
        extra_s = extra_s.strip()
 | 
			
		||||
        if extra and extra_s:
 | 
			
		||||
            raise ValueError("too much extraneous text in {!r}".format(s))
 | 
			
		||||
        extra = extra_s
 | 
			
		||||
    # The only extra text allowed is currency specifiers like plain symbols,
 | 
			
		||||
    # 'A$', 'US$', 'CAD', 'USD $', etc.
 | 
			
		||||
    # Trim any currency symbol.
 | 
			
		||||
    if extra and unicodedata.category(extra[-1]) == 'Sc':
 | 
			
		||||
        extra = extra[:-1].strip()
 | 
			
		||||
    # Anything remaining should look like currency specifier text.
 | 
			
		||||
    if extra and ((len(extra) > 3) or (not extra.isalpha())):
 | 
			
		||||
        raise ValueError("non-currency text in {!r}: {!r}".format(s, extra))
 | 
			
		||||
    return babel.numbers.parse_decimal(match.group(1) + match.group(3), locale)
 | 
			
		||||
 | 
			
		||||
def _rejoin_slice_words(method_name, source, wordslice, sep=None, limit=None, joiner=None):
 | 
			
		||||
    if joiner is None:
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -7,7 +7,7 @@ import re
 | 
			
		|||
 | 
			
		||||
import pytest
 | 
			
		||||
import yaml
 | 
			
		||||
from import2ledger import importers
 | 
			
		||||
from import2ledger import importers, util
 | 
			
		||||
 | 
			
		||||
from . import DATA_DIR
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -35,7 +35,7 @@ class TestImporters:
 | 
			
		|||
        with source_path.open() as source_file:
 | 
			
		||||
            importer = import_class(source_file)
 | 
			
		||||
            for actual, expected in itertools.zip_longest(importer, expect_results):
 | 
			
		||||
                actual['amount'] = decimal.Decimal(actual['amount'])
 | 
			
		||||
                actual['amount'] = util.parse_currency_dec(actual['amount'])
 | 
			
		||||
                assert actual == expected
 | 
			
		||||
 | 
			
		||||
    def test_loader(self):
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		
		Reference in a new issue