diff options
27 files changed, 4201 insertions, 47 deletions
@@ -0,0 +1,16 @@ | |||
1 | [flake8] | ||
2 | exclude = | ||
3 | .git | ||
4 | __pycache__ | ||
5 | setup.py | ||
6 | build | ||
7 | dist | ||
8 | releases | ||
9 | .venv | ||
10 | .tox | ||
11 | .mypy_cache | ||
12 | .pytest_cache | ||
13 | .vscode | ||
14 | .github | ||
15 | beancount | ||
16 | max-line-length = 99 | ||
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dbcfed5 --- /dev/null +++ b/.gitignore | |||
@@ -0,0 +1,33 @@ | |||
1 | *.pyc | ||
2 | |||
3 | # Packages | ||
4 | *.egg | ||
5 | /*.egg-info | ||
6 | /dist/* | ||
7 | build | ||
8 | _build | ||
9 | .cache | ||
10 | *.so | ||
11 | |||
12 | # Installer logs | ||
13 | pip-log.txt | ||
14 | |||
15 | # Unit test / coverage reports | ||
16 | .coverage | ||
17 | .tox | ||
18 | .pytest_cache | ||
19 | |||
20 | .DS_Store | ||
21 | .idea/* | ||
22 | .python-version | ||
23 | .vscode/* | ||
24 | |||
25 | /setup.cfg | ||
26 | MANIFEST.in | ||
27 | /setup.py | ||
28 | .mypy_cache | ||
29 | |||
30 | .venv | ||
31 | /releases/* | ||
32 | pip-wheel-metadata | ||
33 | /poetry.toml | ||
diff --git a/beancount_extras_kris7t/importers/__init__.py b/beancount_extras_kris7t/importers/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/beancount_extras_kris7t/importers/__init__.py | |||
diff --git a/beancount_extras_kris7t/importers/hetzner_pdf.py b/beancount_extras_kris7t/importers/hetzner_pdf.py new file mode 100644 index 0000000..7eb89b6 --- /dev/null +++ b/beancount_extras_kris7t/importers/hetzner_pdf.py | |||
@@ -0,0 +1,136 @@ | |||
1 | ''' | ||
2 | Importer for Hetzner PDF invoices. | ||
3 | ''' | ||
4 | __copyright__ = 'Copyright (c) 2020 Kristóf Marussy <kristof@marussy.com>' | ||
5 | __license__ = 'GNU GPLv2' | ||
6 | |||
7 | import datetime as dt | ||
8 | import logging | ||
9 | import re | ||
10 | from typing import cast, Iterable, Optional | ||
11 | |||
12 | from beancount.core import amount as am, data | ||
13 | from beancount.core.amount import Amount | ||
14 | from beancount.core.flags import FLAG_OKAY, FLAG_WARNING | ||
15 | from beancount.core.number import D | ||
16 | from beancount.ingest.cache import _FileMemo as FileMemo | ||
17 | from beancount.ingest.importer import ImporterProtocol | ||
18 | |||
19 | from pdfminer.high_level import extract_pages | ||
20 | from pdfminer.layout import LTPage, LTTextContainer | ||
21 | |||
22 | from beancount_extras_kris7t.importers.utils import MISSING_AMOUNT | ||
23 | |||
24 | INVOICE_REGEX = re.compile( | ||
25 | r'.*Hetzner_(?P<date>\d{4}-\d{2}-\d{2})_(?P<number>R\d+)\.pdf$', re.IGNORECASE) | ||
26 | AMOUNT_REGEX = re.compile(r'Amount due: € (?P<amount>\d+(\.\d+)?)', re.IGNORECASE) | ||
27 | BALANCE_REGEX = re.compile( | ||
28 | 'The amount has been charged to the credit balance on your client credit account.', | ||
29 | re.IGNORECASE) | ||
30 | CARD_REGEX = re.compile( | ||
31 | 'The invoice amount will soon be debited from your credit card.', | ||
32 | re.IGNORECASE) | ||
33 | MIXED_REGEX = re.compile( | ||
34 | r'The amount of € (?P<balance>\d+(\.\d+)?) has been charged to the credit balance ' + | ||
35 | r'on your client credit account. The remaining amount of € (?P<card>\d(\.\d+)?) ' + | ||
36 | 'will be debited by credit card in the next few days.', | ||
37 | re.IGNORECASE) | ||
38 | |||
39 | |||
40 | def _extract_match(pages: Iterable[LTPage], pattern: re.Pattern) -> Optional[re.Match]: | ||
41 | for page in pages: | ||
42 | for element in page: | ||
43 | if isinstance(element, LTTextContainer): | ||
44 | text = element.get_text().strip().replace('\n', ' ') | ||
45 | if match := pattern.match(text): | ||
46 | return match | ||
47 | return None | ||
48 | |||
49 | |||
50 | class Importer(ImporterProtocol): | ||
51 | ''' | ||
52 | Importer for Hetzner PDF invoices. | ||
53 | ''' | ||
54 | |||
55 | _log: logging.Logger | ||
56 | _liability: str | ||
57 | _credit_balance: str | ||
58 | _expense: str | ||
59 | |||
60 | def __init__(self, liability: str, credit_balance: str, expense: str): | ||
61 | self._log = logging.getLogger(type(self).__qualname__) | ||
62 | self._liability = liability | ||
63 | self._credit_balance = credit_balance | ||
64 | self._expense = expense | ||
65 | |||
66 | def identify(self, file: FileMemo) -> bool: | ||
67 | return INVOICE_REGEX.match(file.name) is not None | ||
68 | |||
69 | def file_name(self, file: FileMemo) -> str: | ||
70 | if match := INVOICE_REGEX.match(file.name): | ||
71 | number = match.group('number') | ||
72 | return f'Hetzner_{number}.pdf' | ||
73 | else: | ||
74 | raise RuntimeError(f'Not an invoice: {file.name}') | ||
75 | |||
76 | def file_account(self, file: FileMemo) -> str: | ||
77 | if INVOICE_REGEX.match(file.name) is None: | ||
78 | raise RuntimeError(f'Not an invoice: {file.name}') | ||
79 | else: | ||
80 | return self._liability | ||
81 | |||
82 | def file_date(self, file: FileMemo) -> dt.date: | ||
83 | if match := INVOICE_REGEX.match(file.name): | ||
84 | date_str = match.group('date') | ||
85 | return dt.datetime.strptime(date_str, '%Y-%m-%d').date() | ||
86 | else: | ||
87 | raise RuntimeError(f'Not an invoice: {file.name}') | ||
88 | |||
89 | def extract(self, file: FileMemo) -> data.Entries: | ||
90 | if match := INVOICE_REGEX.match(file.name): | ||
91 | invoice_number = match.group('number') | ||
92 | else: | ||
93 | self._log.warn('Not an invoice: %s', file.name) | ||
94 | return [] | ||
95 | date = self.file_date(file) | ||
96 | pages = [page for page in cast(Iterable[LTPage], extract_pages(file.name))] | ||
97 | if match := _extract_match(pages, AMOUNT_REGEX): | ||
98 | number = D(match.group('amount')) | ||
99 | assert number is not None | ||
100 | amount = Amount(number, 'EUR') | ||
101 | else: | ||
102 | self._log.warn('Not amount found in %s', file.name) | ||
103 | return [] | ||
104 | postings = [] | ||
105 | flag = FLAG_OKAY | ||
106 | if _extract_match(pages, BALANCE_REGEX) is not None: | ||
107 | postings.append( | ||
108 | data.Posting(self._credit_balance, -amount, None, None, None, None)) | ||
109 | elif _extract_match(pages, CARD_REGEX) is not None: | ||
110 | postings.append( | ||
111 | data.Posting(self._liability, -amount, None, None, None, None)) | ||
112 | elif match := _extract_match(pages, MIXED_REGEX): | ||
113 | balance_number = D(match.group('balance')) | ||
114 | assert balance_number is not None | ||
115 | balance_amount = Amount(balance_number, 'EUR') | ||
116 | postings.append( | ||
117 | data.Posting(self._credit_balance, -balance_amount, None, None, None, None)) | ||
118 | card_number = D(match.group('card')) | ||
119 | assert card_number is not None | ||
120 | card_amount = Amount(card_number, 'EUR') | ||
121 | postings.append( | ||
122 | data.Posting(self._liability, -card_amount, None, None, None, None)) | ||
123 | if am.add(balance_amount, card_amount) != amount: | ||
124 | self._log.warn('Payments do not cover total amount in %s', file.name) | ||
125 | flag = FLAG_WARNING | ||
126 | else: | ||
127 | self._log.warn('Unknown payment method in %s', file.name) | ||
128 | flag = FLAG_WARNING | ||
129 | if flag == FLAG_OKAY: | ||
130 | amount = MISSING_AMOUNT | ||
131 | postings.append( | ||
132 | data.Posting(self._expense, amount, None, None, None, None)) | ||
133 | return [ | ||
134 | data.Transaction(data.new_metadata(file.name, 0), date, flag, 'Hetzner', 'Invoice', | ||
135 | set(), {f'hetzner_{invoice_number}'}, postings) | ||
136 | ] | ||
diff --git a/beancount_extras_kris7t/importers/otpbank/__init__.py b/beancount_extras_kris7t/importers/otpbank/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/beancount_extras_kris7t/importers/otpbank/__init__.py | |||
diff --git a/beancount_extras_kris7t/importers/otpbank/otpbank_csv.py b/beancount_extras_kris7t/importers/otpbank/otpbank_csv.py new file mode 100644 index 0000000..9e12ec3 --- /dev/null +++ b/beancount_extras_kris7t/importers/otpbank/otpbank_csv.py | |||
@@ -0,0 +1,370 @@ | |||
1 | ''' | ||
2 | Importer for OTP Bank CSV transaction history. | ||
3 | ''' | ||
4 | __copyright__ = 'Copyright (c) 2020 Kristóf Marussy <kristof@marussy.com>' | ||
5 | __license__ = 'GNU GPLv2' | ||
6 | |||
7 | import csv | ||
8 | import datetime as dt | ||
9 | from decimal import Decimal | ||
10 | import logging | ||
11 | from typing import Callable, Dict, Iterable, List, NamedTuple, Optional | ||
12 | import re | ||
13 | from os import path | ||
14 | |||
15 | import beancount.core.amount as am | ||
16 | from beancount.core.amount import Amount | ||
17 | from beancount.core import data | ||
18 | from beancount.core.number import ZERO | ||
19 | from beancount.ingest.cache import _FileMemo as FileMemo | ||
20 | from beancount.ingest.importer import ImporterProtocol | ||
21 | |||
22 | from beancount_extras_kris7t.importers import utils | ||
23 | from beancount_extras_kris7t.importers.utils import COMMENT_META, InvalidEntry, PAYEE_META, \ | ||
24 | Posting | ||
25 | from beancount_extras_kris7t.plugins.transfer_accounts import TRANSFER_ACCOUNT_META, \ | ||
26 | TRANSFER_DATE_META | ||
27 | |||
28 | OTP_BANK = 'OTP Bank' | ||
29 | BOOKING_DATE_META = 'booking-date' | ||
30 | ENTRY_TYPE_META = 'otpbank-entry-type' | ||
31 | OTPBANK_CSV_TAG = 'otpbank-csv' | ||
32 | PAYPASS_TAG = 'paypass' | ||
33 | SIMPLE_TAG = 'processor-simple' | ||
34 | CARD_REGEX = re.compile( | ||
35 | r'^(?P<date>\d{4}\.\d{2}\.\d{2})\s+(?P<card_number>\d+)') | ||
36 | PAYPASS_REGEX = re.compile(r'-ÉRINT(Ő|\?)|PPASS$', re.IGNORECASE) | ||
37 | SIMPLE_REGEX = re.compile(r'-SIMPLE$', re.IGNORECASE) | ||
38 | SMS_REGEX = re.compile( | ||
39 | r'^(?P<date>\d{4}\.\d{2}\.\d{2})\s+\((?P<count>\d+) DB SMS\)', | ||
40 | re.IGNORECASE) | ||
41 | |||
42 | |||
43 | def _parse_date(date_str: str) -> dt.date: | ||
44 | return utils.parse_date(date_str, '%Y%m%d') | ||
45 | |||
46 | |||
47 | def _parse_card_date(date_str: str) -> dt.date: | ||
48 | return utils.parse_date(date_str, '%Y.%m.%d') | ||
49 | |||
50 | |||
51 | def _parse_number(amount_str: str) -> Decimal: | ||
52 | cleaned_str = amount_str.replace('.', '').replace(',', '.') | ||
53 | return utils.parse_number(cleaned_str) | ||
54 | |||
55 | |||
56 | def _validate_cd(amount: Amount, cd: str) -> None: | ||
57 | if cd == 'T': | ||
58 | if amount.number >= ZERO: | ||
59 | raise InvalidEntry(f'Invalid debit amount: {amount}') | ||
60 | elif cd == 'J': | ||
61 | if amount.number <= ZERO: | ||
62 | raise InvalidEntry(f'Invalid credit amount: {amount}') | ||
63 | else: | ||
64 | raise InvalidEntry(f'Invalid credit/debit type: {cd}') | ||
65 | |||
66 | |||
67 | class Conversion(NamedTuple): | ||
68 | foreign_amount: Amount | ||
69 | foreign_rate: Amount | ||
70 | conversion_fee: Optional[Posting] | ||
71 | |||
72 | |||
73 | class Card(NamedTuple): | ||
74 | card_account: str | ||
75 | card_date: dt.date | ||
76 | |||
77 | |||
78 | class Row(utils.Row): | ||
79 | account_name: str | ||
80 | cd: str | ||
81 | native_amount: Amount | ||
82 | booking_date: dt.date | ||
83 | value_date: dt.date | ||
84 | _raw_payee: Optional[str] | ||
85 | _raw_comment: str | ||
86 | _conversion: Optional[Conversion] | ||
87 | _card: Optional[Card] | ||
88 | |||
89 | def __init__(self, file_name: str, index: int, row: List[str], accounts: Dict[str, str]): | ||
90 | account_number, cd, amount_str, currency, booking_date_str, \ | ||
91 | value_date_str, _, _, payee, comment1, comment2, comment3, entry_type, _, _ = row | ||
92 | comment = f'{comment1}{comment2}{comment3}'.strip() | ||
93 | super().__init__(file_name, index, entry_type, payee, comment) | ||
94 | if account_name := accounts.get(account_number, None): | ||
95 | self.account_name = account_name | ||
96 | else: | ||
97 | raise InvalidEntry(f'Unknown account number {account_number}') | ||
98 | self.cd = cd | ||
99 | self.native_amount = Amount(_parse_number(amount_str), currency) | ||
100 | _validate_cd(self.native_amount, self.cd) | ||
101 | self.booking_date = _parse_date(booking_date_str) | ||
102 | self.value_date = _parse_date(value_date_str) | ||
103 | self.tags.add(OTPBANK_CSV_TAG) | ||
104 | self._raw_payee = payee | ||
105 | self._raw_comment = comment | ||
106 | self._conversion = None | ||
107 | self._card = None | ||
108 | |||
109 | @property | ||
110 | def conversion(self) -> Optional[Conversion]: | ||
111 | return self._conversion | ||
112 | |||
113 | @conversion.setter | ||
114 | def conversion(self, conversion: Optional[Conversion]) -> None: | ||
115 | if self._conversion: | ||
116 | raise InvalidEntry( | ||
117 | f'Conversion {self._conversion} was already set for row ' + | ||
118 | f' when trying to set it to {conversion}') | ||
119 | self._conversion = conversion | ||
120 | |||
121 | @property | ||
122 | def card(self) -> Optional[Card]: | ||
123 | return self._card | ||
124 | |||
125 | @card.setter | ||
126 | def card(self, card: Optional[Card]) -> None: | ||
127 | if self._card: | ||
128 | raise InvalidEntry( | ||
129 | f'Card {self._card} was already set for row ' + | ||
130 | f' when trying to set it to {card}') | ||
131 | self._card = card | ||
132 | |||
133 | @property | ||
134 | def transacted_amount(self) -> Amount: | ||
135 | if self.conversion: | ||
136 | return self.conversion.foreign_amount | ||
137 | return self.native_amount | ||
138 | |||
139 | @property | ||
140 | def _extended_meta(self) -> data.Meta: | ||
141 | meta = dict(self.meta) | ||
142 | if self.entry_type: | ||
143 | meta[ENTRY_TYPE_META] = self.entry_type | ||
144 | if self.booking_date != self.value_date: | ||
145 | meta[BOOKING_DATE_META] = self.booking_date | ||
146 | if self._raw_payee: | ||
147 | meta[PAYEE_META] = self._raw_payee | ||
148 | if self._raw_comment: | ||
149 | meta[COMMENT_META] = self._raw_comment | ||
150 | return meta | ||
151 | |||
152 | @property | ||
153 | def _card_meta(self) -> Optional[data.Meta]: | ||
154 | if self._card: | ||
155 | card_meta: data.Meta = { | ||
156 | TRANSFER_ACCOUNT_META: self._card.card_account | ||
157 | } | ||
158 | if self._card.card_date != self.value_date: | ||
159 | card_meta[TRANSFER_DATE_META] = self._card.card_date | ||
160 | return card_meta | ||
161 | else: | ||
162 | return None | ||
163 | |||
164 | def _to_transaction(self) -> data.Transaction: | ||
165 | if not self.postings: | ||
166 | raise InvalidEntry('No postings were extracted from this entry') | ||
167 | if self.payee == '': | ||
168 | payee = None | ||
169 | else: | ||
170 | payee = self.payee | ||
171 | if self.comment == '' or self.comment == self.payee: | ||
172 | if payee: | ||
173 | desc = '' | ||
174 | else: | ||
175 | desc = self.entry_type or '' | ||
176 | else: | ||
177 | desc = self.comment | ||
178 | if self._conversion: | ||
179 | foreign_rate = self._conversion.foreign_rate | ||
180 | conversion_fee = self._conversion.conversion_fee | ||
181 | else: | ||
182 | foreign_rate = None | ||
183 | conversion_fee = None | ||
184 | meta = self._extended_meta | ||
185 | card_meta = self._card_meta | ||
186 | postings = [ | ||
187 | data.Posting(self.account_name, self.native_amount, None, None, None, None) | ||
188 | ] | ||
189 | if len(self.postings) == 1 and not foreign_rate: | ||
190 | account, amount = self.postings[0] | ||
191 | postings.append( | ||
192 | data.Posting(account, utils.MISSING_AMOUNT, None, None, None, card_meta)) | ||
193 | else: | ||
194 | for account, amount in self.postings: | ||
195 | postings.append( | ||
196 | data.Posting(account, -amount, None, foreign_rate, None, card_meta)) | ||
197 | if conversion_fee and conversion_fee.amount.number != ZERO: | ||
198 | account, amount = conversion_fee | ||
199 | postings.append( | ||
200 | data.Posting(account, amount, None, None, None, None)) | ||
201 | return data.Transaction( | ||
202 | meta, self.value_date, self.flag, payee, desc, self.tags, self.links, postings) | ||
203 | |||
204 | |||
205 | Extractor = Callable[[Row], None] | ||
206 | |||
207 | |||
208 | def extract_foreign_currencies( | ||
209 | currencies: Iterable[str], conversion_fees_account: str) -> Extractor: | ||
210 | currencies_joined = '|'.join(currencies) | ||
211 | currency_regex = re.compile( | ||
212 | r'(?P<amount>\d+(,\d+)?)(?P<currency>' + currencies_joined | ||
213 | + r')\s+(?P<rate>\d+(,\d+)?)$', re.IGNORECASE) | ||
214 | |||
215 | def do_extract(row: Row) -> None: | ||
216 | if currency_match := currency_regex.search(row.comment): | ||
217 | foreign_amount_d = _parse_number(currency_match.group('amount')) | ||
218 | if row.cd == 'T': | ||
219 | foreign_amount_d *= -1 | ||
220 | foreign_currency = currency_match.group('currency') | ||
221 | foreign_amount = Amount( | ||
222 | foreign_amount_d, foreign_currency.upper()) | ||
223 | foreign_rate_d = _parse_number(currency_match.group('rate')) | ||
224 | foreign_rate = Amount( | ||
225 | foreign_rate_d, row.native_amount.currency) | ||
226 | converted_amount = am.mul(foreign_rate, foreign_amount_d) | ||
227 | conversion_fee_amount = am.sub( | ||
228 | converted_amount, row.native_amount) | ||
229 | conversion_fee = Posting( | ||
230 | conversion_fees_account, conversion_fee_amount) | ||
231 | row.conversion = Conversion( | ||
232 | foreign_amount, foreign_rate, conversion_fee) | ||
233 | row.comment = row.comment[:currency_match.start()].strip() | ||
234 | return do_extract | ||
235 | |||
236 | |||
237 | def extract_cards(card_accounts: Dict[str, str]) -> Extractor: | ||
238 | def do_extract(row: Row) -> None: | ||
239 | if row.entry_type.lower() not in [ | ||
240 | 'vásárlás kártyával', | ||
241 | 'bankkártyával kapcs. díj', | ||
242 | ]: | ||
243 | return | ||
244 | if card_match := CARD_REGEX.search(row.comment): | ||
245 | card_number = card_match.group('card_number') | ||
246 | if card_number not in card_accounts: | ||
247 | raise InvalidEntry(f'No account for card {card_number}') | ||
248 | card_account = card_accounts[card_number] | ||
249 | card_date = _parse_card_date(card_match.group('date')) | ||
250 | row.card = Card(card_account, card_date) | ||
251 | row.comment = row.comment[card_match.end():].strip() | ||
252 | else: | ||
253 | raise InvalidEntry( | ||
254 | f'Cannot extract card information from: {row.comment}') | ||
255 | if paypass_match := PAYPASS_REGEX.search(row.comment): | ||
256 | row.comment = row.comment[:paypass_match.start()].strip() | ||
257 | row.tags.add(PAYPASS_TAG) | ||
258 | elif simple_match := PAYPASS_REGEX.search(row.comment): | ||
259 | row.comment = row.comment[:simple_match.start()].strip() | ||
260 | row.tags.add(SIMPLE_TAG) | ||
261 | return do_extract | ||
262 | |||
263 | |||
264 | def extract_sms(sms: str, sms_liability: str, sms_expense: str) -> Extractor: | ||
265 | def do_extract(row: Row) -> None: | ||
266 | if row.entry_type != 'OTPdirekt ÜZENETDÍJ': | ||
267 | return | ||
268 | if sms_match := SMS_REGEX.search(row.comment): | ||
269 | card_account = sms_liability | ||
270 | card_date = _parse_card_date(sms_match.group('date')) | ||
271 | row.card = Card(card_account, card_date) | ||
272 | sms_amount_d = _parse_number(sms_match.group('count')) | ||
273 | foreign_amount = -Amount(sms_amount_d, sms) | ||
274 | foreign_rate = am.div(-row.native_amount, sms_amount_d) | ||
275 | row.conversion = Conversion( | ||
276 | foreign_amount, foreign_rate, None) | ||
277 | row.comment = 'SMS díj' | ||
278 | row.payee = OTP_BANK | ||
279 | row.assign_to_account(sms_expense) | ||
280 | else: | ||
281 | raise InvalidEntry( | ||
282 | f'Cannot parse SMS transaction: {row.comment}') | ||
283 | return do_extract | ||
284 | |||
285 | |||
286 | class Importer(ImporterProtocol): | ||
287 | ''' | ||
288 | Importer for OTP Bank CSV transaction history. | ||
289 | ''' | ||
290 | |||
291 | _log: logging.Logger | ||
292 | _accounts: Dict[str, str] | ||
293 | _extracts: List[Extractor] | ||
294 | |||
295 | def __init__(self, | ||
296 | accounts: Dict[str, str], | ||
297 | extractors: List[Extractor]): | ||
298 | self._log = logging.getLogger(type(self).__qualname__) | ||
299 | self._accounts = {number.replace('-', ''): name for number, name in accounts.items()} | ||
300 | self._extractors = extractors | ||
301 | |||
302 | def identify(self, file: FileMemo) -> bool: | ||
303 | _, extension = path.splitext(file.name) | ||
304 | if extension.lower() != '.csv': | ||
305 | return False | ||
306 | return self._find_account(file) is not None | ||
307 | |||
308 | def _find_account(self, file: FileMemo) -> Optional[str]: | ||
309 | head = file.head().strip().split('\n')[0] | ||
310 | if head.count(';') != 14: | ||
311 | return None | ||
312 | for account_number, account_name in self._accounts.items(): | ||
313 | if head.startswith(f'"{account_number}"'): | ||
314 | return account_name | ||
315 | return None | ||
316 | |||
317 | def file_name(self, file: FileMemo) -> str: | ||
318 | return 'otpbank.csv' | ||
319 | |||
320 | def file_account(self, file: FileMemo) -> str: | ||
321 | account_name = self._find_account(file) | ||
322 | if not account_name: | ||
323 | raise RuntimeError(f'Invalid account number in {file.name}') | ||
324 | return account_name | ||
325 | |||
326 | def file_date(self, file: FileMemo) -> Optional[dt.date]: | ||
327 | ''' | ||
328 | Files account statements according to the booking date of the last | ||
329 | transaction. | ||
330 | ''' | ||
331 | date = None | ||
332 | with open(file.name, 'r') as csv_file: | ||
333 | for row in csv.reader(csv_file, delimiter=';'): | ||
334 | date_str = row[4] | ||
335 | try: | ||
336 | date = _parse_date(date_str) | ||
337 | except InvalidEntry as exc: | ||
338 | self._log.error( | ||
339 | 'Invalid entry in %s when looking for filing date', | ||
340 | file.name, exc_info=exc) | ||
341 | return None | ||
342 | return date | ||
343 | |||
344 | def extract(self, file: FileMemo) -> data.Entries: | ||
345 | file_name = file.name | ||
346 | entries: data.Entries = [] | ||
347 | with open(file_name, 'r') as csv_file: | ||
348 | last_date: Optional[dt.date] = None | ||
349 | last_date_str = "" | ||
350 | count_within_date = 1 | ||
351 | for index, row_str in enumerate(csv.reader(csv_file, delimiter=';')): | ||
352 | try: | ||
353 | row = Row(file_name, index, row_str, self._accounts) | ||
354 | if last_date != row.booking_date: | ||
355 | last_date = row.booking_date | ||
356 | last_date_str = last_date.strftime('%Y-%m-%d') | ||
357 | count_within_date = 1 | ||
358 | else: | ||
359 | count_within_date += 1 | ||
360 | row.links.add(f'otpbank_{last_date_str}_{count_within_date:03}') | ||
361 | self._run_row_extractors(row) | ||
362 | entries.append(row._to_transaction()) | ||
363 | except InvalidEntry as exc: | ||
364 | self._log.warning( | ||
365 | 'Skipping invalid entry %d of %s', | ||
366 | index, file_name, exc_info=exc) | ||
367 | return entries | ||
368 | |||
369 | def _run_row_extractors(self, row: Row): | ||
370 | utils.run_row_extractors(row, self._extractors) | ||
diff --git a/beancount_extras_kris7t/importers/otpbank/otpbank_pdf.py b/beancount_extras_kris7t/importers/otpbank/otpbank_pdf.py new file mode 100644 index 0000000..c2de559 --- /dev/null +++ b/beancount_extras_kris7t/importers/otpbank/otpbank_pdf.py | |||
@@ -0,0 +1,187 @@ | |||
1 | ''' | ||
2 | Importer for OTP Bank PDF account statements. | ||
3 | ''' | ||
4 | __copyright__ = 'Copyright (c) 2020 Kristóf Marussy <kristof@marussy.com>' | ||
5 | __license__ = 'GNU GPLv2' | ||
6 | |||
7 | from decimal import Decimal | ||
8 | import datetime as dt | ||
9 | import logging | ||
10 | import re | ||
11 | from typing import cast, Dict, Iterable, List, NamedTuple, Optional | ||
12 | |||
13 | from beancount.core import data | ||
14 | from beancount.core.amount import Amount | ||
15 | from beancount.core.number import D | ||
16 | from beancount.ingest.cache import _FileMemo as FileMemo | ||
17 | from beancount.ingest.importer import ImporterProtocol | ||
18 | |||
19 | from pdfminer.high_level import extract_pages | ||
20 | from pdfminer.layout import LTPage, LTTextContainer | ||
21 | |||
22 | |||
23 | STATEMENT_NAME_REGEX = re.compile( | ||
24 | r'.*(Banksz[a ]mla|(Ertekpapirszamla|rt `kpap rsz mla)_)kivonat_(?P<account>\d[\d-]*\d)_' + | ||
25 | r'(?P<date>\d{4}\.\d{2}\.\d{2})(_\d+)?\.pdf') | ||
26 | CHECKING_ACCOUNT_STATEMENT_NAME_REGEX = re.compile( | ||
27 | r'.*Banksz[a ]mlakivonat_(?P<account>\d[\d-]*\d)_.+') | ||
28 | INVESTMENT_ACCOUNT_STATEMENT_NAME_REGEX = re.compile( | ||
29 | r'.*(Ertekpapirszamla|rt `kpap rsz mla)_kivonat_(?P<account>\d[\d-]*\d)_.+') | ||
30 | ACCOUNT_NUMBER_REGEX = re.compile(r'SZÁMLASZÁM: (?P<account>\d[\d-]*\d)') | ||
31 | CURRENCY_REGEX = re.compile(r'DEVIZANEM: (?P<currency>[A-Z]+)$') | ||
32 | |||
33 | |||
34 | class Total(NamedTuple): | ||
35 | date: dt.date | ||
36 | units: Decimal | ||
37 | |||
38 | |||
39 | def _append_total(entries: data.Entries, | ||
40 | meta: data.Meta, | ||
41 | account: str, | ||
42 | currency: str, | ||
43 | total: Optional[Total], | ||
44 | delta: dt.timedelta = dt.timedelta(days=0)) -> None: | ||
45 | if not total: | ||
46 | return | ||
47 | date, units = total | ||
48 | amount = Amount(units, currency) | ||
49 | balance = data.Balance(meta, date + delta, account, amount, None, None) | ||
50 | entries.append(balance) | ||
51 | |||
52 | |||
53 | def _find_label_y(page: LTPage, label: str) -> Optional[int]: | ||
54 | for element in page: | ||
55 | if isinstance(element, LTTextContainer) and element.get_text().strip() == label: | ||
56 | return element.bbox[1] | ||
57 | return None | ||
58 | |||
59 | |||
60 | def _find_match(page: LTPage, pattern: re.Pattern) -> Optional[re.Match]: | ||
61 | for element in page: | ||
62 | if isinstance(element, LTTextContainer): | ||
63 | text = element.get_text().strip() | ||
64 | if match := pattern.search(text): | ||
65 | return match | ||
66 | return None | ||
67 | |||
68 | |||
69 | class Importer(ImporterProtocol): | ||
70 | ''' | ||
71 | Importer for OTP Bank PDF account statements. | ||
72 | ''' | ||
73 | |||
74 | _log: logging.Logger | ||
75 | _accounts: Dict[str, str] | ||
76 | _extract_opening: bool | ||
77 | |||
78 | def __init__(self, accounts: Dict[str, str], extract_opening: bool = False): | ||
79 | self._log = logging.getLogger(type(self).__qualname__) | ||
80 | self._accounts = accounts | ||
81 | self._extract_opening = extract_opening | ||
82 | |||
83 | def identify(self, file: FileMemo) -> bool: | ||
84 | if match := STATEMENT_NAME_REGEX.match(file.name): | ||
85 | return match.group('account') in self._accounts | ||
86 | else: | ||
87 | return False | ||
88 | |||
89 | def file_name(self, file: FileMemo) -> str: | ||
90 | if match := CHECKING_ACCOUNT_STATEMENT_NAME_REGEX.match(file.name): | ||
91 | account_number = match.group('account') | ||
92 | return f'Bankszámlakivonat_{account_number}.pdf' | ||
93 | elif match := INVESTMENT_ACCOUNT_STATEMENT_NAME_REGEX.match(file.name): | ||
94 | account_number = match.group('account') | ||
95 | return f'Értékpapírszámla_kivonat_{account_number}.pdf' | ||
96 | else: | ||
97 | raise RuntimeError(f'Not an account statement: {file.name}') | ||
98 | |||
99 | def file_account(self, file: FileMemo) -> str: | ||
100 | if match := STATEMENT_NAME_REGEX.match(file.name): | ||
101 | account_number = match.group('account') | ||
102 | return self._accounts[account_number] | ||
103 | else: | ||
104 | raise RuntimeError(f'Not an account statement: {file.name}') | ||
105 | |||
106 | def file_date(self, file: FileMemo) -> dt.date: | ||
107 | if match := STATEMENT_NAME_REGEX.match(file.name): | ||
108 | date_str = match.group('date') | ||
109 | return dt.datetime.strptime(date_str, '%Y.%m.%d').date() | ||
110 | else: | ||
111 | raise RuntimeError(f'Not an account statement: {file.name}') | ||
112 | |||
113 | def extract(self, file: FileMemo) -> data.Entries: | ||
114 | if not CHECKING_ACCOUNT_STATEMENT_NAME_REGEX.match(file.name): | ||
115 | return [] | ||
116 | pages = [page for page in cast(Iterable[LTPage], extract_pages(file.name))] | ||
117 | if not pages: | ||
118 | return [] | ||
119 | entries: data.Entries = [] | ||
120 | meta = data.new_metadata(file.name, 1) | ||
121 | if account_match := _find_match(pages[0], ACCOUNT_NUMBER_REGEX): | ||
122 | account_name = self._accounts[account_match.group('account')] | ||
123 | else: | ||
124 | self._log.warning('No account number in %s', file.name) | ||
125 | account_name = self.file_account(file) | ||
126 | if currency_match := _find_match(pages[0], CURRENCY_REGEX): | ||
127 | currency = currency_match.group('currency') | ||
128 | else: | ||
129 | self._log.warning('No currency number in %s', file.name) | ||
130 | currency = 'HUF' | ||
131 | if self._extract_opening: | ||
132 | opening_balance = self._extract_total_from_page(pages[0], 'NYITÓ EGYENLEG') | ||
133 | _append_total(entries, meta, account_name, currency, opening_balance) | ||
134 | closing_balance = self._extract_total(pages, 'ZÁRÓ EGYENLEG') | ||
135 | _append_total(entries, meta, account_name, currency, closing_balance, dt.timedelta(days=1)) | ||
136 | return entries | ||
137 | |||
138 | def _extract_total(self, pages: List[LTPage], label: str) -> Optional[Total]: | ||
139 | for page in pages: | ||
140 | if total := self._extract_total_from_page(page, label): | ||
141 | return total | ||
142 | self._log.error('%s was not found in the pdf file', label) | ||
143 | return None | ||
144 | |||
145 | def _extract_total_from_page(self, page: LTPage, label: str) -> Optional[Total]: | ||
146 | if total_y := _find_label_y(page, label): | ||
147 | return self._extract_total_by_y(page, total_y) | ||
148 | return None | ||
149 | |||
150 | def _extract_total_by_y(self, page: LTPage, total_y: int) -> Optional[Total]: | ||
151 | date: Optional[dt.date] = None | ||
152 | units: Optional[Decimal] = None | ||
153 | for element in page: | ||
154 | if isinstance(element, LTTextContainer): | ||
155 | x, y, x2, _ = element.bbox | ||
156 | if abs(y - total_y) > 0.5: | ||
157 | continue | ||
158 | elif abs(x - 34) <= 0.5: | ||
159 | date_str = element.get_text().strip() | ||
160 | if date is not None: | ||
161 | self._log.warning( | ||
162 | 'Found date %s, but date was already set to %s', | ||
163 | date_str, | ||
164 | date) | ||
165 | continue | ||
166 | try: | ||
167 | date = dt.datetime.strptime(date_str, '%y.%m.%d').date() | ||
168 | except ValueError as exc: | ||
169 | self._log.warning(f'Invalid date {date_str}', exc_info=exc) | ||
170 | elif abs(x2 - 572.68) <= 0.5: | ||
171 | units_str = element.get_text().strip().replace('.', '').replace(',', '.') | ||
172 | if units is not None: | ||
173 | self._log.warning( | ||
174 | 'Found units %s, but units were already set to %s', | ||
175 | units_str, | ||
176 | units) | ||
177 | try: | ||
178 | units = D(units_str) | ||
179 | except ValueError as exc: | ||
180 | self._log.error('Invalid units %s', units_str, exc_info=exc) | ||
181 | if not date: | ||
182 | self._log.error('Date was not found at y=%d', total_y) | ||
183 | return None | ||
184 | if not units: | ||
185 | self._log.error('Units were not found at y=%d', total_y) | ||
186 | return None | ||
187 | return Total(date, units) | ||
diff --git a/beancount_extras_kris7t/importers/rules.py b/beancount_extras_kris7t/importers/rules.py new file mode 100644 index 0000000..3890f24 --- /dev/null +++ b/beancount_extras_kris7t/importers/rules.py | |||
@@ -0,0 +1,134 @@ | |||
1 | #!/usr/bin/env python3 | ||
2 | |||
3 | from typing import cast, Dict, List, NamedTuple, Optional, Tuple, Union | ||
4 | import re | ||
5 | |||
6 | from beancount.core.amount import Amount | ||
7 | |||
8 | from beancount_extras_kris7t.importers.utils import Extractor, Row | ||
9 | |||
10 | WILDCARD = re.compile('.*') | ||
11 | |||
12 | |||
13 | class When(NamedTuple): | ||
14 | payee: re.Pattern | ||
15 | text: re.Pattern | ||
16 | amount: Optional[Amount] | ||
17 | |||
18 | |||
19 | def _compile_regex(s: str) -> re.Pattern: | ||
20 | return re.compile(s, re.IGNORECASE) | ||
21 | |||
22 | |||
23 | def when(payee: Optional[Union[re.Pattern, str]] = None, | ||
24 | text: Optional[Union[re.Pattern, str]] = None, | ||
25 | amount: Optional[Amount] = None) -> When: | ||
26 | if not payee and not text: | ||
27 | raise TypeError('at least one of payee and desc must be provided') | ||
28 | if isinstance(payee, str): | ||
29 | payee_regex = _compile_regex(payee) | ||
30 | else: | ||
31 | payee_regex = payee or WILDCARD | ||
32 | if isinstance(text, str): | ||
33 | text_regex = _compile_regex(text) | ||
34 | else: | ||
35 | text_regex = text or WILDCARD | ||
36 | return When(payee_regex, text_regex, amount) | ||
37 | |||
38 | |||
39 | Condition = Union[str, re.Pattern, When] | ||
40 | |||
41 | |||
42 | def _compile_condition(cond: Condition) -> When: | ||
43 | if isinstance(cond, When): | ||
44 | return cond | ||
45 | else: | ||
46 | return when(text=cond) | ||
47 | |||
48 | |||
49 | class let(NamedTuple): | ||
50 | payee: Optional[str] = None | ||
51 | desc: Optional[str] = None | ||
52 | account: Optional[str] = None | ||
53 | flag: Optional[str] = None | ||
54 | tag: Optional[str] = None | ||
55 | |||
56 | |||
57 | Action = Union[str, | ||
58 | Tuple[str, str], | ||
59 | Tuple[str, str, str], | ||
60 | Tuple[str, str, str, str], | ||
61 | let] | ||
62 | |||
63 | |||
64 | def _compile_action(action: Action) -> let: | ||
65 | if isinstance(action, str): | ||
66 | return let(account=action) | ||
67 | if isinstance(action, let): | ||
68 | return action | ||
69 | elif isinstance(action, tuple): | ||
70 | if len(action) == 2: | ||
71 | payee, account = cast(Tuple[str, str], action) | ||
72 | return let(payee=payee, account=account) | ||
73 | elif len(action) == 3: | ||
74 | payee, desc, account = cast(Tuple[str, str, str], action) | ||
75 | return let(payee, desc, account) | ||
76 | else: | ||
77 | flag, payee, desc, account = cast(Tuple[str, str, str, str], action) | ||
78 | return let(payee, desc, account, flag) | ||
79 | else: | ||
80 | raise ValueError(f'Unknown action: {action}') | ||
81 | |||
82 | |||
83 | Rules = Dict[Condition, Action] | ||
84 | CompiledRules = List[Tuple[When, let]] | ||
85 | |||
86 | |||
87 | def _compile_rules(rules: Rules) -> CompiledRules: | ||
88 | return [(_compile_condition(cond), _compile_action(action)) | ||
89 | for cond, action in rules.items()] | ||
90 | |||
91 | |||
92 | def _rule_condition_matches(cond: When, row: Row) -> bool: | ||
93 | if row.payee: | ||
94 | payee_valid = cond.payee.search(row.payee) is not None | ||
95 | else: | ||
96 | payee_valid = cond.payee == WILDCARD | ||
97 | if cond.text == WILDCARD: | ||
98 | text_valid = True | ||
99 | else: | ||
100 | characteristics: List[str] = [] | ||
101 | if row.entry_type: | ||
102 | characteristics.append(row.entry_type) | ||
103 | if row.payee: | ||
104 | characteristics.append(row.payee) | ||
105 | if row.comment: | ||
106 | characteristics.append(row.comment) | ||
107 | row_str = ' '.join(characteristics) | ||
108 | text_valid = cond.text.search(row_str) is not None | ||
109 | amount_valid = not cond.amount or row.transacted_amount == cond.amount | ||
110 | return payee_valid and text_valid and amount_valid | ||
111 | |||
112 | |||
113 | def extract_rules(input_rules: Rules) -> Extractor: | ||
114 | compiled_rules = _compile_rules(input_rules) | ||
115 | |||
116 | def do_extract(row: Row) -> None: | ||
117 | for cond, (payee, desc, account, flag, tag) in compiled_rules: | ||
118 | if not _rule_condition_matches(cond, row): | ||
119 | continue | ||
120 | if payee is not None: | ||
121 | if row.payee == row.comment: | ||
122 | row.comment = '' | ||
123 | row.payee = payee | ||
124 | if desc is not None: | ||
125 | row.comment = desc | ||
126 | if account is not None: | ||
127 | row.assign_to_account(account) | ||
128 | if flag is not None: | ||
129 | row.flag = flag | ||
130 | if tag is not None: | ||
131 | row.tags.add(tag) | ||
132 | if row.postings: | ||
133 | return | ||
134 | return do_extract | ||
diff --git a/beancount_extras_kris7t/importers/transferwise/__init__.py b/beancount_extras_kris7t/importers/transferwise/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/beancount_extras_kris7t/importers/transferwise/__init__.py | |||
diff --git a/beancount_extras_kris7t/importers/transferwise/__main__.py b/beancount_extras_kris7t/importers/transferwise/__main__.py new file mode 100644 index 0000000..e25580d --- /dev/null +++ b/beancount_extras_kris7t/importers/transferwise/__main__.py | |||
@@ -0,0 +1,11 @@ | |||
1 | ''' | ||
2 | Importer for Transferwise API transaction history. | ||
3 | ''' | ||
4 | __copyright__ = 'Copyright (c) 2020 Kristóf Marussy <kristof@marussy.com>' | ||
5 | __license__ = 'GNU GPLv2' | ||
6 | |||
7 | from importers.transferwise.client import main | ||
8 | |||
9 | |||
10 | if __name__ == '__main__': | ||
11 | main() | ||
diff --git a/beancount_extras_kris7t/importers/transferwise/client.py b/beancount_extras_kris7t/importers/transferwise/client.py new file mode 100644 index 0000000..a4b6629 --- /dev/null +++ b/beancount_extras_kris7t/importers/transferwise/client.py | |||
@@ -0,0 +1,236 @@ | |||
1 | ''' | ||
2 | Importer for Transferwise API transaction history from the command line. | ||
3 | ''' | ||
4 | __copyright__ = 'Copyright (c) 2020 Kristóf Marussy <kristof@marussy.com>' | ||
5 | __license__ = 'GNU GPLv2' | ||
6 | |||
7 | import datetime as dt | ||
8 | import logging | ||
9 | import os | ||
10 | from typing import Any, Dict, Optional, Tuple, Set | ||
11 | |||
12 | import beancount | ||
13 | from beancount.core import data | ||
14 | |||
15 | from beancount_extras_kris7t.importers.transferwise.transferwise_json import Accounts, \ | ||
16 | DATE_FORMAT, Importer | ||
17 | |||
18 | LOG = logging.getLogger('importers.transferwise.client') | ||
19 | |||
20 | |||
21 | def _parse_date_arg(date_str: str) -> dt.date: | ||
22 | return dt.datetime.strptime(date_str, '%Y-%m-%d').date() | ||
23 | |||
24 | |||
25 | def _import_config(config_path: str) -> Importer: | ||
26 | import runpy | ||
27 | |||
28 | config = runpy.run_path(config_path) | ||
29 | importer = config['TRANSFERWISE_CONFIG'] # type: ignore | ||
30 | if isinstance(importer, Importer): | ||
31 | LOG.info('Loaded configuration from %s', config_path) | ||
32 | return importer | ||
33 | else: | ||
34 | raise ValueError(f'Invalid configuration: {config_path}') | ||
35 | |||
36 | |||
37 | def _get_reference(transaction: data.Transaction) -> Optional[str]: | ||
38 | for link in transaction.links: | ||
39 | if link.startswith('transferwise_'): | ||
40 | return link[13:] | ||
41 | return None | ||
42 | |||
43 | |||
44 | def _get_last_transaction_date(ledger_path: str, skip_references: Set[str]) -> Optional[dt.date]: | ||
45 | from beancount.parser import parser | ||
46 | |||
47 | LOG.info('Checking %s for already imported transactions', ledger_path) | ||
48 | entries, _, _ = parser.parse_file(ledger_path) | ||
49 | date: Optional[dt.date] = None | ||
50 | skip: Set[str] = set() | ||
51 | for entry in entries: | ||
52 | if isinstance(entry, data.Transaction): | ||
53 | reference = _get_reference(entry) | ||
54 | if not reference: | ||
55 | continue | ||
56 | if date is None or date < entry.date: | ||
57 | date = entry.date | ||
58 | skip.clear() | ||
59 | if date == entry.date: | ||
60 | skip.add(reference) | ||
61 | skip_references.update(skip) | ||
62 | return date | ||
63 | |||
64 | |||
65 | def _get_date_range(from_date: Optional[dt.date], | ||
66 | to_date: dt.date, | ||
67 | ledger_path: Optional[str]) -> Tuple[dt.date, dt.date, Set[str]]: | ||
68 | skip_references = set() | ||
69 | if not from_date and ledger_path: | ||
70 | from_date = _get_last_transaction_date(ledger_path, skip_references) | ||
71 | if not from_date: | ||
72 | from_date = to_date - dt.timedelta(days=365) | ||
73 | LOG.info('Fetching transactions from %s to %s', from_date, to_date) | ||
74 | return from_date, to_date, skip_references | ||
75 | |||
76 | |||
77 | def _get_secrets(importer: Importer, | ||
78 | api_key: Optional[str], | ||
79 | proxy_uri: Optional[str]) -> Tuple[str, Optional[str]]: | ||
80 | import urllib.parse | ||
81 | |||
82 | if proxy_uri: | ||
83 | uri_parts = urllib.parse.urlsplit(proxy_uri) | ||
84 | else: | ||
85 | uri_parts = None | ||
86 | if api_key and (not uri_parts or not uri_parts.username or uri_parts.password): | ||
87 | return api_key, proxy_uri | ||
88 | |||
89 | from contextlib import closing | ||
90 | |||
91 | import secretstorage | ||
92 | |||
93 | with closing(secretstorage.dbus_init()) as connection: | ||
94 | collection = secretstorage.get_default_collection(connection) | ||
95 | if not api_key: | ||
96 | items = collection.search_items({ | ||
97 | 'profile_id': str(importer.profile_id), | ||
98 | 'borderless_account_id': str(importer.borderless_account_id), | ||
99 | 'xdg:schema': 'com.marussy.beancount.importer.TransferwiseAPIKey', | ||
100 | }) | ||
101 | item = next(items, None) | ||
102 | if not item: | ||
103 | raise ValueError('No API key found in SecretService') | ||
104 | LOG.info('Found API key secret "%s" from SecretService', item.get_label()) | ||
105 | api_key = item.get_secret().decode('utf-8') | ||
106 | if uri_parts and uri_parts.username and not uri_parts.password: | ||
107 | host = uri_parts.hostname or uri_parts.netloc | ||
108 | items = collection.search_items({ | ||
109 | 'host': host, | ||
110 | 'port': str(uri_parts.port or 1080), | ||
111 | 'user': uri_parts.username, | ||
112 | 'xdg:schema': 'org.freedesktop.Secret.Generic', | ||
113 | }) | ||
114 | item = next(items, None) | ||
115 | if item: | ||
116 | LOG.info('Found proxy password secret "%s" from SecretService', item.get_label()) | ||
117 | password = urllib.parse.quote_from_bytes(item.get_secret()) | ||
118 | uri = f'{uri_parts.scheme}://{uri_parts.username}:{password}@{host}' | ||
119 | if uri_parts.port: | ||
120 | proxy_uri = f'{uri}:{uri_parts.port}' | ||
121 | else: | ||
122 | proxy_uri = uri | ||
123 | else: | ||
124 | LOG.info('No proxy password secret was found in SecretService') | ||
125 | assert api_key # Make pyright happy | ||
126 | return api_key, proxy_uri | ||
127 | |||
128 | |||
129 | def _fetch_statements(importer: Importer, | ||
130 | from_date: dt.date, | ||
131 | to_date: dt.date, | ||
132 | api_key: str, | ||
133 | proxy_uri: Optional[str]) -> Dict[str, Any]: | ||
134 | import json | ||
135 | |||
136 | import requests | ||
137 | |||
138 | now = dt.datetime.utcnow().time() | ||
139 | from_time_str = dt.datetime.combine(from_date, dt.datetime.min.time()).strftime(DATE_FORMAT) | ||
140 | to_time_str = dt.datetime.combine(to_date, now).strftime(DATE_FORMAT) | ||
141 | uri_prefix = f'https://api.transferwise.com/v3/profiles/{importer.profile_id}/' + \ | ||
142 | f'borderless-accounts/{importer.borderless_account_id}/statement.json' + \ | ||
143 | f'?intervalStart={from_time_str}&intervalEnd={to_time_str}&type=COMPACT¤cy=' | ||
144 | headers = { | ||
145 | 'User-Agent': f'Beancount {beancount.__version__} Transferwise importer {__copyright__}', | ||
146 | 'Authorization': f'Bearer {api_key}', | ||
147 | } | ||
148 | proxy_dict: Dict[str, str] = {} | ||
149 | if proxy_uri: | ||
150 | proxy_dict['https'] = proxy_uri | ||
151 | statements: Dict[str, Any] = {} | ||
152 | for currency in importer.currencies: | ||
153 | result = requests.get(uri_prefix + currency, headers=headers, proxies=proxy_dict) | ||
154 | if result.status_code != 200: | ||
155 | LOG.error( | ||
156 | 'Fetcing %s statement failed with HTTP status code %d: %s', | ||
157 | currency, | ||
158 | result.status_code, | ||
159 | result.text) | ||
160 | else: | ||
161 | try: | ||
162 | statement = json.loads(result.text) | ||
163 | except json.JSONDecodeError as exc: | ||
164 | LOG.error('Failed to decode %s statement', currency, exc_info=exc) | ||
165 | else: | ||
166 | statements[currency] = statement | ||
167 | LOG.info('Fetched %s statement', currency) | ||
168 | return statements | ||
169 | |||
170 | |||
171 | def _print_statements(from_date: dt.date, | ||
172 | to_date: dt.date, | ||
173 | entries: data.Entries) -> None: | ||
174 | from beancount.parser import printer | ||
175 | print(f'*** Transferwise from {from_date} to {to_date}', flush=True) | ||
176 | printer.print_entries(entries) | ||
177 | |||
178 | |||
179 | def _determine_path(dir: str, currency: str, to_date: dt.date) -> str: | ||
180 | date_str = to_date.strftime('%Y-%m-%d') | ||
181 | simple_path = os.path.join(dir, f'{date_str}.transferwise_{currency}.json') | ||
182 | if not os.path.exists(simple_path): | ||
183 | return simple_path | ||
184 | for i in range(2, 10): | ||
185 | path = os.path.join(dir, f'{date_str}.transferwise_{currency}_{i}.json') | ||
186 | if not os.path.exists(path): | ||
187 | return path | ||
188 | raise ValueError(f'Cannot find unused name for {simple_path}') | ||
189 | |||
190 | |||
191 | def _archive_statements(documents_path: str, | ||
192 | to_date: dt.date, | ||
193 | accounts: Accounts, | ||
194 | statements: Dict[str, Any]): | ||
195 | import json | ||
196 | |||
197 | from beancount.core.account import sep | ||
198 | |||
199 | for currency, statement in statements.items(): | ||
200 | dir = os.path.join(documents_path, *accounts.get_borderless_account(currency).split(sep)) | ||
201 | os.makedirs(dir, exist_ok=True) | ||
202 | path = _determine_path(dir, currency, to_date) | ||
203 | with open(path, 'w') as file: | ||
204 | json.dump(statement, file, indent=2) | ||
205 | LOG.info('Saved %s statement as %s', currency, path) | ||
206 | |||
207 | |||
208 | def main(): | ||
209 | import argparse | ||
210 | |||
211 | parser = argparse.ArgumentParser() | ||
212 | parser.add_argument('--verbose', '-v', action='store_true') | ||
213 | parser.add_argument('--from-date', '-f', required=False, type=_parse_date_arg) | ||
214 | parser.add_argument('--to-date', '-t', default=dt.date.today(), type=_parse_date_arg) | ||
215 | parser.add_argument('--api-key', '-k', required=False, type=str) | ||
216 | parser.add_argument('--proxy', '-p', required=False, type=str) | ||
217 | parser.add_argument('--archive', '-a', required=False, type=str) | ||
218 | parser.add_argument('config', nargs=1) | ||
219 | parser.add_argument('ledger', nargs='?') | ||
220 | args = parser.parse_args() | ||
221 | if args.verbose: | ||
222 | log_level = logging.INFO | ||
223 | else: | ||
224 | log_level = logging.WARN | ||
225 | logging.basicConfig(level=log_level) | ||
226 | importer = _import_config(args.config[0]) | ||
227 | from_date, to_date, skip = _get_date_range(args.from_date, args.to_date, args.ledger) | ||
228 | if skip: | ||
229 | LOG.info('Skipping %s', skip) | ||
230 | api_key, proxy_uri = _get_secrets(importer, args.api_key, args.proxy) | ||
231 | statements = _fetch_statements(importer, from_date, to_date, api_key, proxy_uri) | ||
232 | if args.archive: | ||
233 | _archive_statements(args.archive, to_date, importer.accounts, statements) | ||
234 | entries = importer.extract_objects(statements.values(), skip) | ||
235 | if entries: | ||
236 | _print_statements(from_date, to_date, entries) | ||
diff --git a/beancount_extras_kris7t/importers/transferwise/transferwise_json.py b/beancount_extras_kris7t/importers/transferwise/transferwise_json.py new file mode 100644 index 0000000..c42de90 --- /dev/null +++ b/beancount_extras_kris7t/importers/transferwise/transferwise_json.py | |||
@@ -0,0 +1,377 @@ | |||
1 | ''' | ||
2 | Importer for Transferwise API transaction history. | ||
3 | ''' | ||
4 | __copyright__ = 'Copyright (c) 2020 Kristóf Marussy <kristof@marussy.com>' | ||
5 | __license__ = 'GNU GPLv2' | ||
6 | |||
7 | from collections import defaultdict | ||
8 | import datetime as dt | ||
9 | import json | ||
10 | import logging | ||
11 | import re | ||
12 | from os import path | ||
13 | from typing import Any, Callable, Dict, Iterable, List, NamedTuple, Optional, Set | ||
14 | |||
15 | from beancount.core import account, data | ||
16 | import beancount.core.amount as am | ||
17 | from beancount.core.amount import Amount | ||
18 | from beancount.core.flags import FLAG_WARNING | ||
19 | from beancount.core.inventory import Inventory | ||
20 | from beancount.core.number import ZERO | ||
21 | from beancount.ingest.cache import _FileMemo as FileMemo | ||
22 | from beancount.ingest.importer import ImporterProtocol | ||
23 | |||
24 | import beancount_extras_kris7t.importers.utils as utils | ||
25 | from beancount_extras_kris7t.importers.utils import COMMENT_META, InvalidEntry, PAYEE_META | ||
26 | |||
27 | DATE_FORMAT = '%Y-%m-%dT%H:%M:%SZ' | ||
28 | DATE_FORMAT_FRACTIONAL = '%Y-%m-%dT%H:%M:%S.%fZ' | ||
29 | CATEGORY_META = 'transferwise-category' | ||
30 | ENTRY_TYPE_META = 'transferwise-entry-type' | ||
31 | TRANSFERWISE_JSON_TAG = 'transferwise-json' | ||
32 | CD_DEBIT = 'DEBIT' | ||
33 | CD_CREDIT = 'CREDIT' | ||
34 | MONEY_ADDED_TYPE = 'MONEY_ADDED' | ||
35 | CARD_TYPE = 'CARD' | ||
36 | CARD_REGEX = re.compile(r'^Card transaction of.*issued by', re.IGNORECASE) | ||
37 | |||
38 | |||
39 | def _parse_date(date_str: str) -> dt.date: | ||
40 | # TODO Handle time zones accurately | ||
41 | try: | ||
42 | return utils.parse_date(date_str, DATE_FORMAT) | ||
43 | except InvalidEntry: | ||
44 | return utils.parse_date(date_str, DATE_FORMAT_FRACTIONAL) | ||
45 | |||
46 | |||
47 | def _parse_json_amount(data: Any, cd: Optional[str] = None) -> Amount: | ||
48 | # TODO Handle precision better | ||
49 | amount = Amount(round(utils.parse_number(data['value']), 2), data['currency']) | ||
50 | if cd == CD_DEBIT: | ||
51 | return -amount | ||
52 | else: | ||
53 | return amount | ||
54 | |||
55 | |||
56 | def _validate_cd(amount: Amount, cd: str) -> None: | ||
57 | if cd == CD_DEBIT: | ||
58 | if amount.number >= ZERO: | ||
59 | raise InvalidEntry(f'Invalid debit amount: {amount}') | ||
60 | elif cd == CD_CREDIT: | ||
61 | if amount.number <= ZERO: | ||
62 | raise InvalidEntry(f'Invalid credit amount: {amount}') | ||
63 | else: | ||
64 | raise InvalidEntry(f'Invalid credit/debit type: {cd}') | ||
65 | |||
66 | |||
67 | class Reference(NamedTuple): | ||
68 | type: str | ||
69 | reference_number: str | ||
70 | |||
71 | |||
72 | class _ConversionResult(NamedTuple): | ||
73 | converted_fraction: Amount | ||
74 | price: Optional[Amount] | ||
75 | fudge: Optional[Amount] | ||
76 | |||
77 | |||
78 | class _Conversion(NamedTuple): | ||
79 | native_amount: Amount | ||
80 | converted_amount: Optional[Amount] | ||
81 | |||
82 | def get_fraction(self, | ||
83 | total_converted_amount: Amount, | ||
84 | assigned_amount: Amount, | ||
85 | diverted_fees: Inventory) -> _ConversionResult: | ||
86 | fees = diverted_fees.get_currency_units(self.native_amount.currency) | ||
87 | native_amount = am.add(self.native_amount, fees) | ||
88 | fraction = am.div(assigned_amount, total_converted_amount.number).number | ||
89 | if self.converted_amount: | ||
90 | converted_fraction = am.mul(self.converted_amount, fraction) | ||
91 | price = am.div(native_amount, self.converted_amount.number) | ||
92 | assert price.number is not None | ||
93 | price = price._replace(number=round(price.number, 4)) | ||
94 | # TODO Do we have to calculate the fudge here? | ||
95 | return _ConversionResult(converted_fraction, price, None) | ||
96 | else: | ||
97 | return _ConversionResult(am.mul(native_amount, fraction), None, None) | ||
98 | |||
99 | |||
100 | class Accounts(NamedTuple): | ||
101 | borderless_root_asset: str | ||
102 | fees_expense: str | ||
103 | |||
104 | def get_borderless_account(self, currency: str) -> str: | ||
105 | return account.sep.join([self.borderless_root_asset, currency]) | ||
106 | |||
107 | |||
108 | class Row(utils.Row): | ||
109 | cd: str | ||
110 | _transacted_amount: Amount | ||
111 | date: dt.date | ||
112 | _conversions: List[_Conversion] | ||
113 | _inputs: Inventory | ||
114 | _fees: Inventory | ||
115 | divert_fees: Optional[str] | ||
116 | |||
117 | def __init__(self, reference: Reference, transaction_list: List[Any]): | ||
118 | assert len(transaction_list) >= 1 | ||
119 | first_transaction = transaction_list[0] | ||
120 | details = first_transaction['details'] | ||
121 | entry_type = details.get('type', None) | ||
122 | merchant = details.get('merchant', None) | ||
123 | if merchant: | ||
124 | payee = merchant['name'] | ||
125 | else: | ||
126 | payee = None | ||
127 | comment = details['description'] | ||
128 | super().__init__('<transferwise>', 0, entry_type, payee, comment) | ||
129 | if entry_type: | ||
130 | self.meta[ENTRY_TYPE_META] = entry_type | ||
131 | category = details.get('category', None) | ||
132 | if category: | ||
133 | self.meta[CATEGORY_META] = category | ||
134 | if merchant: | ||
135 | if 'category' in merchant and merchant['category'] == category: | ||
136 | del merchant['category'] | ||
137 | not_null_elements = [str(value).strip() for _, value in merchant.items() if value] | ||
138 | self.meta[PAYEE_META] = '; '.join(not_null_elements) | ||
139 | self.meta[COMMENT_META] = comment | ||
140 | self.tags.add(TRANSFERWISE_JSON_TAG) | ||
141 | self.links.add(f'transferwise_{reference.reference_number}') | ||
142 | self.date = _parse_date(first_transaction['date']) | ||
143 | self.cd = reference.type | ||
144 | self.divert_fees = None | ||
145 | self._conversions = [] | ||
146 | self._inputs = Inventory() | ||
147 | self._fees = Inventory() | ||
148 | self._compute_transacted_amount(transaction_list) | ||
149 | for transaction in transaction_list: | ||
150 | self._add_json_transaction(transaction) | ||
151 | |||
152 | def _compute_transacted_amount(self, transaction_list: List[Any]) -> None: | ||
153 | first_transaction = transaction_list[0] | ||
154 | details = first_transaction['details'] | ||
155 | transacted_json = details.get('amount', None) | ||
156 | if transacted_json: | ||
157 | self._transacted_amount = _parse_json_amount(transacted_json, self.cd) | ||
158 | else: | ||
159 | if len(transaction_list) != 1: | ||
160 | raise InvalidEntry('Cannot determine transaction amount') | ||
161 | if exchange_details := first_transaction.get('exchangeDetails', None): | ||
162 | self._transacted_amount = _parse_json_amount(exchange_details, self.cd) | ||
163 | else: | ||
164 | self._transacted_amount = _parse_json_amount(first_transaction['amount']) | ||
165 | _validate_cd(self._transacted_amount, self.cd) | ||
166 | |||
167 | def _add_json_transaction(self, transaction: Any) -> None: | ||
168 | if exchange := transaction.get('exchangeDetails', None): | ||
169 | native = _parse_json_amount(exchange['fromAmount'], self.cd) | ||
170 | converted = _parse_json_amount(exchange['toAmount'], self.cd) | ||
171 | _validate_cd(converted, self.cd) | ||
172 | else: | ||
173 | native = _parse_json_amount(transaction['amount']) | ||
174 | converted = None | ||
175 | _validate_cd(native, self.cd) | ||
176 | if total_fees := transaction.get('totalFees', None): | ||
177 | fee = -_parse_json_amount(total_fees) | ||
178 | if fee.number > ZERO: | ||
179 | raise InvalidEntry(f'Invalid transaction fee: {fee}') | ||
180 | if fee.number != ZERO: | ||
181 | self._fees.add_amount(fee) | ||
182 | native_after_fees = am.sub(native, fee) | ||
183 | else: | ||
184 | native_after_fees = native | ||
185 | self._conversions.append(_Conversion(native_after_fees, converted)) | ||
186 | self._inputs.add_amount(native) | ||
187 | |||
188 | @property | ||
189 | def transacted_amount(self) -> Amount: | ||
190 | return self._transacted_amount | ||
191 | |||
192 | def _to_transaction(self, accounts: Accounts) -> data.Transaction: | ||
193 | postings = self._get_postings(accounts) | ||
194 | return data.Transaction(self.meta, self.date, self.flag, self.payee, self.comment, | ||
195 | self.tags, self.links, postings) | ||
196 | |||
197 | def _get_postings(self, accounts: Accounts) -> List[data.Posting]: | ||
198 | postings: List[data.Posting] = [] | ||
199 | for units, cost in self._inputs: | ||
200 | assert cost is None | ||
201 | postings.append(data.Posting( | ||
202 | accounts.get_borderless_account(units.currency), units, None, None, None, None)) | ||
203 | if self.divert_fees: | ||
204 | for units, cost in self._fees: | ||
205 | assert cost is None | ||
206 | postings.append(data.Posting( | ||
207 | self.divert_fees, units, None, None, None, None)) | ||
208 | diverted_fees = self._fees | ||
209 | else: | ||
210 | diverted_fees = Inventory() | ||
211 | # Also add the "fudge" amounts to the fees generated by rounding currency conversions. | ||
212 | all_fees = Inventory() | ||
213 | all_fees.add_inventory(self._fees) | ||
214 | for acc, assigned_units in self.postings: | ||
215 | for conversion in self._conversions: | ||
216 | units, price, fudge = conversion.get_fraction( | ||
217 | self._transacted_amount, assigned_units, diverted_fees) | ||
218 | postings.append(data.Posting(acc, -units, None, price, None, None)) | ||
219 | if fudge: | ||
220 | all_fees.add_amount(-fudge) | ||
221 | for units, cost in all_fees: | ||
222 | assert cost is None | ||
223 | postings.append(data.Posting( | ||
224 | accounts.fees_expense, -units, None, None, None, None)) | ||
225 | return postings | ||
226 | |||
227 | |||
228 | Extractor = Callable[[Row], None] | ||
229 | |||
230 | |||
231 | def extract_card_transaction(payment_processors: Dict[str, Optional[str]] = {}) -> Extractor: | ||
232 | regexes = [(re.compile(f'^\\s*{key}\\s*\\*', re.IGNORECASE), value) | ||
233 | for key, value in payment_processors.items()] | ||
234 | |||
235 | def do_extract(row: Row) -> None: | ||
236 | if row.entry_type == CARD_TYPE and CARD_REGEX.search(row.comment): | ||
237 | if row.cd == CD_DEBIT: | ||
238 | row.comment = '' | ||
239 | else: | ||
240 | row.comment = 'Refund' | ||
241 | # Most manually add posting for refunded fees. | ||
242 | row.flag = FLAG_WARNING | ||
243 | if row.payee: | ||
244 | for key, value in regexes: | ||
245 | if match := key.search(row.payee): | ||
246 | if value: | ||
247 | row.tags.add(value) | ||
248 | row.payee = row.payee[match.end():].strip() | ||
249 | return do_extract | ||
250 | |||
251 | |||
252 | def extract_add_money(add_money_asset: str, add_money_fees_asset: str) -> Extractor: | ||
253 | def do_extract(row: Row) -> None: | ||
254 | if row.entry_type == MONEY_ADDED_TYPE: | ||
255 | row.payee = 'Transferwise' | ||
256 | row.divert_fees = add_money_fees_asset | ||
257 | row.assign_to_account(add_money_asset) | ||
258 | return do_extract | ||
259 | |||
260 | |||
261 | class Importer(ImporterProtocol): | ||
262 | _log: logging.Logger | ||
263 | profile_id: int | ||
264 | borderless_account_id: int | ||
265 | currencies: List[str] | ||
266 | accounts: Accounts | ||
267 | _extractors: List[Extractor] | ||
268 | |||
269 | def __init__(self, | ||
270 | profile_id: int, | ||
271 | borderless_account_id: int, | ||
272 | currencies: Iterable[str], | ||
273 | accounts: Accounts, | ||
274 | extractors: List[Extractor]): | ||
275 | self._log = logging.getLogger(type(self).__qualname__) | ||
276 | self.profile_id = profile_id | ||
277 | self.borderless_account_id = borderless_account_id | ||
278 | self.currencies = list(currencies) | ||
279 | self.accounts = accounts | ||
280 | self._extractors = extractors | ||
281 | |||
282 | def _parse_file(self, file: FileMemo) -> Any: | ||
283 | def parse_json(path: str) -> Any: | ||
284 | with open(path, 'r') as json_file: | ||
285 | try: | ||
286 | return json.load(json_file) | ||
287 | except json.JSONDecodeError as exc: | ||
288 | self._log.info('Invalid JSON: %s', path, exc_info=exc) | ||
289 | return None | ||
290 | |||
291 | return file.convert(parse_json) | ||
292 | |||
293 | def identify(self, file: FileMemo) -> bool: | ||
294 | _, extension = path.splitext(file.name) | ||
295 | if extension.lower() != '.json': | ||
296 | return False | ||
297 | contents = self._parse_file(file) | ||
298 | try: | ||
299 | query = contents['query'] | ||
300 | return query['accountId'] == self.borderless_account_id and \ | ||
301 | query['currency'] in self.currencies | ||
302 | except (KeyError, TypeError): | ||
303 | return False | ||
304 | |||
305 | def file_name(self, file: FileMemo) -> str: | ||
306 | return 'statement.json' | ||
307 | |||
308 | def file_account(self, file: FileMemo) -> str: | ||
309 | contents = self._parse_file(file) | ||
310 | try: | ||
311 | currency = contents['query']['currency'] | ||
312 | except (KeyError, TypeError) as exc: | ||
313 | raise ValueError(f'Invalid account statement: {file.name}') from exc | ||
314 | if not isinstance(currency, str): | ||
315 | raise ValueError(f'Invalid account statement: {file.name}') | ||
316 | return self.accounts.get_borderless_account(currency) | ||
317 | |||
318 | def file_date(self, file: FileMemo) -> dt.date: | ||
319 | contents = self._parse_file(file) | ||
320 | try: | ||
321 | date_str = contents['query']['intervalEnd'] | ||
322 | except (KeyError, TypeError) as exc: | ||
323 | raise ValueError(f'Invalid account statement: {file.name}') from exc | ||
324 | if not isinstance(date_str, str): | ||
325 | raise ValueError(f'Invalid account statement: {file.name}') | ||
326 | return _parse_date(date_str) | ||
327 | |||
328 | def extract(self, file: FileMemo) -> data.Entries: | ||
329 | contents = self._parse_file(file) | ||
330 | if contents: | ||
331 | return self.extract_objects([contents]) | ||
332 | else: | ||
333 | return [] | ||
334 | |||
335 | def extract_objects(self, | ||
336 | statements: Iterable[Any], | ||
337 | skip_references: Set[str] = set()) -> data.Entries: | ||
338 | transactions: Dict[Reference, List[Any]] = defaultdict(lambda: []) | ||
339 | for statement in statements: | ||
340 | for transaction in statement['transactions']: | ||
341 | reference_number = transaction['referenceNumber'] | ||
342 | if reference_number in skip_references: | ||
343 | continue | ||
344 | reference = Reference(transaction['type'], reference_number) | ||
345 | transactions[reference].append(transaction) | ||
346 | entries: data.Entries = [] | ||
347 | for reference, transaction_list in transactions.items(): | ||
348 | if not transaction_list: | ||
349 | continue | ||
350 | try: | ||
351 | row = Row(reference, transaction_list) | ||
352 | except (TypeError, KeyError, InvalidEntry) as exc: | ||
353 | self._log.warn('Invalid entry: %s', reference, exc_info=exc) | ||
354 | continue | ||
355 | try: | ||
356 | utils.run_row_extractors(row, self._extractors) | ||
357 | except InvalidEntry as exc: | ||
358 | self._log.warn('Invalid entry: %s', reference, exc_info=exc) | ||
359 | continue | ||
360 | entries.append(row._to_transaction(self.accounts)) | ||
361 | entries.sort(key=lambda entry: entry.date) | ||
362 | if entries: | ||
363 | self._extract_closing_balances(statements, entries) | ||
364 | return entries | ||
365 | |||
366 | def _extract_closing_balances(self, | ||
367 | statements: Iterable[Any], | ||
368 | entries: data.Entries) -> None: | ||
369 | for statement in statements: | ||
370 | query = statement['query'] | ||
371 | end_date = _parse_date(query['intervalEnd']) + dt.timedelta(days=1) | ||
372 | currency = query['currency'] | ||
373 | balance = statement['endOfStatementBalance'] | ||
374 | amount = Amount(utils.parse_number(balance['value']), balance['currency']) | ||
375 | meta = data.new_metadata(f'<transferwise:{currency}>', 0) | ||
376 | account = self.accounts.get_borderless_account(currency) | ||
377 | entries.append(data.Balance(meta, end_date, account, amount, None, None)) | ||
diff --git a/beancount_extras_kris7t/importers/utils.py b/beancount_extras_kris7t/importers/utils.py new file mode 100644 index 0000000..f0a8134 --- /dev/null +++ b/beancount_extras_kris7t/importers/utils.py | |||
@@ -0,0 +1,125 @@ | |||
1 | ''' | ||
2 | Utilities for custom importers. | ||
3 | ''' | ||
4 | __copyright__ = 'Copyright (c) 2020 Kristóf Marussy <kristof@marussy.com>' | ||
5 | __license__ = 'GNU GPLv2' | ||
6 | |||
7 | from abc import ABC, abstractmethod | ||
8 | import datetime as dt | ||
9 | from decimal import Decimal | ||
10 | from typing import cast, Callable, Iterable, List, NamedTuple, Optional, Set, TypeVar, Union | ||
11 | |||
12 | from beancount.core import amount as am, data | ||
13 | from beancount.core.amount import Amount | ||
14 | from beancount.core.flags import FLAG_OKAY, FLAG_WARNING | ||
15 | from beancount.core.number import D, ZERO | ||
16 | |||
17 | MISSING_AMOUNT = cast(Amount, None) | ||
18 | COMMENT_META = 'import-raw-comment' | ||
19 | PAYEE_META = 'import-raw-payee' | ||
20 | |||
21 | |||
22 | class InvalidEntry(Exception): | ||
23 | pass | ||
24 | |||
25 | |||
26 | class Posting(NamedTuple): | ||
27 | account: str | ||
28 | amount: Amount | ||
29 | |||
30 | |||
31 | class Row(ABC): | ||
32 | entry_type: Optional[str] | ||
33 | payee: Optional[str] | ||
34 | comment: str | ||
35 | meta: data.Meta | ||
36 | flag: str | ||
37 | tags: Set[str] | ||
38 | links: Set[str] | ||
39 | _postings: Optional[List[Posting]] | ||
40 | |||
41 | def __init__(self, | ||
42 | file_name: str, | ||
43 | line_number: int, | ||
44 | entry_type: Optional[str], | ||
45 | payee: Optional[str], | ||
46 | comment: str): | ||
47 | self.entry_type = entry_type | ||
48 | self.payee = payee | ||
49 | self.comment = comment | ||
50 | self.meta = data.new_metadata(file_name, line_number) | ||
51 | self.flag = FLAG_OKAY | ||
52 | self.tags = set() | ||
53 | self.links = set() | ||
54 | self._postings = None | ||
55 | |||
56 | @property | ||
57 | @abstractmethod | ||
58 | def transacted_amount(self) -> Amount: | ||
59 | pass | ||
60 | |||
61 | @property | ||
62 | def transacted_currency(self) -> str: | ||
63 | return self.transacted_amount.currency | ||
64 | |||
65 | @property | ||
66 | def postings(self) -> Optional[List[Posting]]: | ||
67 | return self._postings | ||
68 | |||
69 | def assign_to_accounts(self, *postings: Posting) -> None: | ||
70 | if self.done: | ||
71 | raise InvalidEntry('Transaction is alrady done processing') | ||
72 | self._postings = list(postings) | ||
73 | if not self._postings: | ||
74 | raise InvalidEntry('Not assigned to any accounts') | ||
75 | head, *rest = self._postings | ||
76 | sum = head.amount | ||
77 | for posting in rest: | ||
78 | sum = am.add(sum, posting.amount) | ||
79 | if sum != self.transacted_amount: | ||
80 | self.flag = FLAG_WARNING | ||
81 | |||
82 | def assign_to_account(self, account: str) -> None: | ||
83 | self.assign_to_accounts(Posting(account, self.transacted_amount)) | ||
84 | |||
85 | @property | ||
86 | def done(self) -> bool: | ||
87 | return self._postings is not None | ||
88 | |||
89 | |||
90 | Extractor = Callable[[Row], None] | ||
91 | TRow = TypeVar('TRow', bound=Row) | ||
92 | |||
93 | |||
94 | def run_row_extractors(row: TRow, extractors: Iterable[Callable[[TRow], None]]) -> None: | ||
95 | for extractor in extractors: | ||
96 | extractor(row) | ||
97 | if row.done: | ||
98 | return | ||
99 | |||
100 | |||
101 | def extract_unknown(expenses_account: str, income_account: str) -> Extractor: | ||
102 | def do_extract(row: Row) -> None: | ||
103 | if row.transacted_amount.number < ZERO: | ||
104 | row.assign_to_account(expenses_account) | ||
105 | else: | ||
106 | row.assign_to_account(income_account) | ||
107 | row.flag = FLAG_WARNING | ||
108 | return do_extract | ||
109 | |||
110 | |||
111 | def parse_date(date_str: str, format_string: str) -> dt.date: | ||
112 | try: | ||
113 | return dt.datetime.strptime(date_str, format_string).date() | ||
114 | except ValueError as exc: | ||
115 | raise InvalidEntry(f'Cannot parse date: {date_str}') from exc | ||
116 | |||
117 | |||
118 | def parse_number(in_amount: Union[str, int, float, Decimal]) -> Decimal: | ||
119 | try: | ||
120 | value = D(in_amount) | ||
121 | except ValueError as exc: | ||
122 | raise InvalidEntry(f'Cannot parse number: {in_amount}') from exc | ||
123 | if value is None: | ||
124 | raise InvalidEntry(f'Parse number returned None: {in_amount}') | ||
125 | return value | ||
diff --git a/beancount_extras_kris7t/plugins/__init__.py b/beancount_extras_kris7t/plugins/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/beancount_extras_kris7t/plugins/__init__.py | |||
diff --git a/beancount_extras_kris7t/plugins/closing_balance.py b/beancount_extras_kris7t/plugins/closing_balance.py new file mode 100644 index 0000000..a22e712 --- /dev/null +++ b/beancount_extras_kris7t/plugins/closing_balance.py | |||
@@ -0,0 +1,70 @@ | |||
1 | ''' | ||
2 | Plugin that closes an account by transferring its whole balance to another account. | ||
3 | ''' | ||
4 | __copyright__ = 'Copyright (c) 2020 Kristóf Marussy <kristof@marussy.com>' | ||
5 | __license__ = 'GNU GPLv2' | ||
6 | |||
7 | from typing import Any, Dict, List, NamedTuple, Optional, Tuple | ||
8 | |||
9 | from beancount.core.data import Balance, Close, Directive, Entries, Meta, Posting, Transaction | ||
10 | from beancount.core.flags import FLAG_OKAY | ||
11 | from beancount.core.number import ZERO | ||
12 | |||
13 | __plugins__ = ('close_with_balance_assertions',) | ||
14 | |||
15 | CLOSE_TO_META = 'close-to' | ||
16 | CLOSING_META = 'closing' | ||
17 | |||
18 | |||
19 | class ClosingBalanceError(NamedTuple): | ||
20 | source: Optional[Meta] | ||
21 | message: str | ||
22 | entry: Directive | ||
23 | |||
24 | |||
25 | def close_with_balance_assertions(entries: Entries, | ||
26 | options_map: Dict[str, Any], | ||
27 | config_str: Optional[str] = None) -> \ | ||
28 | Tuple[Entries, List[ClosingBalanceError]]: | ||
29 | new_entries: Entries = [] | ||
30 | errors: List[ClosingBalanceError] = [] | ||
31 | for entry in entries: | ||
32 | new_entries.append(entry) | ||
33 | if isinstance(entry, Balance) and CLOSE_TO_META in entry.meta: | ||
34 | close_to_account = entry.meta[CLOSE_TO_META] | ||
35 | if not isinstance(close_to_account, str): | ||
36 | errors.append(ClosingBalanceError( | ||
37 | entry.meta, | ||
38 | f'{CLOSE_TO_META} must be a string, got {close_to_account} instead', | ||
39 | entry)) | ||
40 | continue | ||
41 | if entry.tolerance is not None and entry.tolerance != ZERO: | ||
42 | errors.append(ClosingBalanceError( | ||
43 | entry.meta, | ||
44 | f'Closing an account requires {ZERO} tolerance, got {entry.tolerance} instead', | ||
45 | entry)) | ||
46 | continue | ||
47 | if entry.diff_amount is not None: | ||
48 | errors.append(ClosingBalanceError( | ||
49 | entry.meta, | ||
50 | f'Not closing {entry.account} with {entry.diff_amount} failed balance check', | ||
51 | entry)) | ||
52 | continue | ||
53 | new_meta = dict(entry.meta) | ||
54 | del new_meta[CLOSE_TO_META] | ||
55 | if entry.amount.number != ZERO: | ||
56 | new_entries.append(Transaction( | ||
57 | new_meta, | ||
58 | entry.date, | ||
59 | FLAG_OKAY, | ||
60 | None, | ||
61 | f'Closing {entry.account}', | ||
62 | set(), | ||
63 | set(), | ||
64 | [ | ||
65 | Posting(entry.account, -entry.amount, None, None, None, | ||
66 | {CLOSING_META: True}), | ||
67 | Posting(close_to_account, entry.amount, None, None, None, None) | ||
68 | ])) | ||
69 | new_entries.append(Close(new_meta, entry.date, entry.account)) | ||
70 | return new_entries, errors | ||
diff --git a/beancount_extras_kris7t/plugins/closing_balance_test.py b/beancount_extras_kris7t/plugins/closing_balance_test.py new file mode 100644 index 0000000..40115bf --- /dev/null +++ b/beancount_extras_kris7t/plugins/closing_balance_test.py | |||
@@ -0,0 +1,124 @@ | |||
1 | __copyright__ = 'Copyright (c) 2020 Kristóf Marussy <kristof@marussy.com>' | ||
2 | __license__ = 'GNU GPLv2' | ||
3 | |||
4 | import unittest | ||
5 | |||
6 | from beancount import loader | ||
7 | from beancount.parser import cmptest | ||
8 | |||
9 | |||
10 | class TestClosingBalance(cmptest.TestCase): | ||
11 | |||
12 | @loader.load_doc() | ||
13 | def test_close_account_correct(self, entries, errors, options_map): | ||
14 | ''' | ||
15 | plugin "beancount_extras_kris7t.plugins.closing_balance" | ||
16 | |||
17 | 2020-01-01 open Assets:Checking | ||
18 | 2020-01-01 open Equity:Opening-Balances | ||
19 | 2020-01-01 open Equity:Closing-Balances | ||
20 | |||
21 | 2020-01-01 * "Opening balances" | ||
22 | Assets:Checking 100 USD | ||
23 | Equity:Opening-Balances -100 USD | ||
24 | |||
25 | 2020-03-15 balance Assets:Checking 100 USD | ||
26 | close-to: Equity:Closing-Balances | ||
27 | ''' | ||
28 | self.assertEqualEntries(''' | ||
29 | plugin "beancount_extras_kris7t.plugins.closing_balance" | ||
30 | |||
31 | 2020-01-01 open Assets:Checking | ||
32 | 2020-01-01 open Equity:Opening-Balances | ||
33 | 2020-01-01 open Equity:Closing-Balances | ||
34 | |||
35 | 2020-01-01 * "Opening balances" | ||
36 | Assets:Checking 100 USD | ||
37 | Equity:Opening-Balances -100 USD | ||
38 | |||
39 | 2020-03-15 balance Assets:Checking 100 USD | ||
40 | close-to: Equity:Closing-Balances | ||
41 | |||
42 | 2020-03-15 * "Closing Assets:Checking" | ||
43 | Assets:Checking -100 USD | ||
44 | closing: TRUE | ||
45 | Equity:Closing-Balances 100 USD | ||
46 | |||
47 | 2020-03-15 close Assets:Checking | ||
48 | ''', entries) | ||
49 | |||
50 | @loader.load_doc(expect_errors=True) | ||
51 | def test_close_account_incorrect(self, entries, errors, options_map): | ||
52 | ''' | ||
53 | plugin "beancount_extras_kris7t.plugins.closing_balance" | ||
54 | |||
55 | 2020-01-01 open Assets:Checking | ||
56 | 2020-01-01 open Equity:Opening-Balances | ||
57 | 2020-01-01 open Equity:Closing-Balances | ||
58 | |||
59 | 2020-01-01 * "Opening balances" | ||
60 | Assets:Checking 100 USD | ||
61 | Equity:Opening-Balances -100 USD | ||
62 | |||
63 | 2020-03-15 balance Assets:Checking 80 USD | ||
64 | close-to: Equity:Closing-Balances | ||
65 | ''' | ||
66 | self.assertEqualEntries(''' | ||
67 | plugin "beancount_extras_kris7t.plugins.closing_balance" | ||
68 | |||
69 | 2020-01-01 open Assets:Checking | ||
70 | 2020-01-01 open Equity:Opening-Balances | ||
71 | 2020-01-01 open Equity:Closing-Balances | ||
72 | |||
73 | 2020-01-01 * "Opening balances" | ||
74 | Assets:Checking 100 USD | ||
75 | Equity:Opening-Balances -100 USD | ||
76 | |||
77 | 2020-03-15 balance Assets:Checking 80 USD | ||
78 | close-to: Equity:Closing-Balances | ||
79 | |||
80 | 2020-03-15 * "Closing Assets:Checking" | ||
81 | Assets:Checking -80 USD | ||
82 | closing: TRUE | ||
83 | Equity:Closing-Balances 80 USD | ||
84 | |||
85 | 2020-03-15 close Assets:Checking | ||
86 | ''', entries) | ||
87 | self.assertRegex(errors[0].message, '^Balance failed for \'Assets:Checking\'') | ||
88 | |||
89 | @loader.load_doc() | ||
90 | def test_close_account_zero(self, entries, errors, options_map): | ||
91 | ''' | ||
92 | plugin "beancount_extras_kris7t.plugins.closing_balance" | ||
93 | |||
94 | 2020-01-01 open Assets:Checking | ||
95 | |||
96 | 2020-03-15 balance Assets:Checking 0 USD | ||
97 | close-to: Equity:Closing-Balances | ||
98 | ''' | ||
99 | self.assertEqualEntries(''' | ||
100 | plugin "beancount_extras_kris7t.plugins.closing_balance" | ||
101 | |||
102 | 2020-01-01 open Assets:Checking | ||
103 | |||
104 | 2020-03-15 balance Assets:Checking 0 USD | ||
105 | close-to: Equity:Closing-Balances | ||
106 | |||
107 | 2020-03-15 close Assets:Checking | ||
108 | ''', entries) | ||
109 | |||
110 | @loader.load_doc(expect_errors=True) | ||
111 | def test_invalid_close_to(self, entries, errors, options_map): | ||
112 | ''' | ||
113 | plugin "beancount_extras_kris7t.plugins.closing_balance" | ||
114 | |||
115 | 2020-01-01 open Assets:Checking | ||
116 | |||
117 | 2020-03-15 balance Assets:Checking 100 USD | ||
118 | close-to: TRUE | ||
119 | ''' | ||
120 | self.assertRegex(errors[0].message, '^close-to must be a string') | ||
121 | |||
122 | |||
123 | if __name__ == '__main__': | ||
124 | unittest.main() | ||
diff --git a/beancount_extras_kris7t/plugins/default_tolerance.py b/beancount_extras_kris7t/plugins/default_tolerance.py new file mode 100644 index 0000000..52fa956 --- /dev/null +++ b/beancount_extras_kris7t/plugins/default_tolerance.py | |||
@@ -0,0 +1,47 @@ | |||
1 | ''' | ||
2 | Plugin that sets the tolerance values of balance assertions to an amount determined by the account. | ||
3 | ''' | ||
4 | __copyright__ = 'Copyright (c) 2020 Kristóf Marussy <kristof@marussy.com>' | ||
5 | __license__ = 'GNU GPLv2' | ||
6 | |||
7 | from decimal import Decimal | ||
8 | from typing import Any, Dict, List, NamedTuple, Optional, Tuple | ||
9 | |||
10 | from beancount.core.data import Open, Balance, Directive, Entries, Meta | ||
11 | |||
12 | __plugins__ = ('set_tolerances_to_default',) | ||
13 | |||
14 | DEFAULT_TOLERANCE_META = 'default-balance-tolerance' | ||
15 | |||
16 | |||
17 | class DefaultToleranceError(NamedTuple): | ||
18 | source: Optional[Meta] | ||
19 | message: str | ||
20 | entry: Directive | ||
21 | |||
22 | |||
23 | def set_tolerances_to_default(entries: Entries, | ||
24 | options_map: Dict[str, Any], | ||
25 | config_str: Optional[str] = None) -> \ | ||
26 | Tuple[Entries, List[DefaultToleranceError]]: | ||
27 | errors: List[DefaultToleranceError] = [] | ||
28 | accounts: Dict[str, Optional[Decimal]] = {} | ||
29 | for entry in entries: | ||
30 | if not isinstance(entry, Open): | ||
31 | continue | ||
32 | if tolerance := entry.meta.get(DEFAULT_TOLERANCE_META, None): | ||
33 | if isinstance(tolerance, Decimal): | ||
34 | accounts[entry.account] = tolerance | ||
35 | else: | ||
36 | errors.append(DefaultToleranceError( | ||
37 | entry.meta, | ||
38 | f'{DEFAULT_TOLERANCE_META} must be decimal, got {tolerance} instead', | ||
39 | entry)) | ||
40 | new_entries: Entries = [] | ||
41 | for entry in entries: | ||
42 | if isinstance(entry, Balance) and entry.tolerance is None and entry.account in accounts: | ||
43 | account_tolerance = accounts[entry.account] | ||
44 | new_entries.append(entry._replace(tolerance=account_tolerance)) | ||
45 | else: | ||
46 | new_entries.append(entry) | ||
47 | return new_entries, errors | ||
diff --git a/beancount_extras_kris7t/plugins/default_tolerance_test.py b/beancount_extras_kris7t/plugins/default_tolerance_test.py new file mode 100644 index 0000000..2e5c629 --- /dev/null +++ b/beancount_extras_kris7t/plugins/default_tolerance_test.py | |||
@@ -0,0 +1,104 @@ | |||
1 | __copyright__ = 'Copyright (c) 2020 Kristóf Marussy <kristof@marussy.com>' | ||
2 | __license__ = 'GNU GPLv2' | ||
3 | |||
4 | import unittest | ||
5 | |||
6 | from beancount import loader | ||
7 | from beancount.parser import cmptest | ||
8 | |||
9 | |||
10 | class DefaultToleranceTest(cmptest.TestCase): | ||
11 | |||
12 | @loader.load_doc() | ||
13 | def test_account_with_tolerance(self, entries, errors, options_map): | ||
14 | ''' | ||
15 | plugin "beancount_extras_kris7t.plugins.default_tolerance" | ||
16 | |||
17 | 2020-01-01 open Assets:Checking | ||
18 | default-balance-tolerance: 10 | ||
19 | |||
20 | 2020-01-01 balance Assets:Checking 0 USD | ||
21 | ''' | ||
22 | self.assertEqualEntries(''' | ||
23 | plugin "beancount_extras_kris7t.plugins.default_tolerance" | ||
24 | |||
25 | 2020-01-01 open Assets:Checking | ||
26 | default-balance-tolerance: 10 | ||
27 | |||
28 | 2020-01-01 balance Assets:Checking 0 ~ 10 USD | ||
29 | ''', entries) | ||
30 | |||
31 | @loader.load_doc() | ||
32 | def test_account_with_tolerance_override(self, entries, errors, options_map): | ||
33 | ''' | ||
34 | plugin "beancount_extras_kris7t.plugins.default_tolerance" | ||
35 | |||
36 | 2020-01-01 open Assets:Checking | ||
37 | default-balance-tolerance: 10 | ||
38 | |||
39 | 2020-01-01 balance Assets:Checking 10 ~ 20 USD | ||
40 | ''' | ||
41 | self.assertEqualEntries(''' | ||
42 | plugin "beancount_extras_kris7t.plugins.default_tolerance" | ||
43 | |||
44 | 2020-01-01 open Assets:Checking | ||
45 | default-balance-tolerance: 10 | ||
46 | |||
47 | 2020-01-01 balance Assets:Checking 10 ~ 20 USD | ||
48 | ''', entries) | ||
49 | |||
50 | @loader.load_doc() | ||
51 | def test_account_with_tolerance_override_zero(self, entries, errors, options_map): | ||
52 | ''' | ||
53 | plugin "beancount_extras_kris7t.plugins.default_tolerance" | ||
54 | |||
55 | 2020-01-01 open Assets:Checking | ||
56 | default-balance-tolerance: 10 | ||
57 | |||
58 | 2020-01-01 balance Assets:Checking 0 ~ 0 USD | ||
59 | ''' | ||
60 | self.assertEqualEntries(''' | ||
61 | plugin "beancount_extras_kris7t.plugins.default_tolerance" | ||
62 | |||
63 | 2020-01-01 open Assets:Checking | ||
64 | default-balance-tolerance: 10 | ||
65 | |||
66 | 2020-01-01 balance Assets:Checking 0 ~ 0 USD | ||
67 | ''', entries) | ||
68 | |||
69 | @loader.load_doc() | ||
70 | def test_account_without_tolerance(self, entries, errors, options_map): | ||
71 | ''' | ||
72 | plugin "beancount_extras_kris7t.plugins.default_tolerance" | ||
73 | |||
74 | 2020-01-01 open Assets:Checking | ||
75 | |||
76 | 2020-01-01 balance Assets:Checking 0 USD | ||
77 | |||
78 | 2020-01-02 balance Assets:Checking 10 ~ 20 USD | ||
79 | ''' | ||
80 | self.assertEqualEntries(''' | ||
81 | plugin "beancount_extras_kris7t.plugins.default_tolerance" | ||
82 | |||
83 | 2020-01-01 open Assets:Checking | ||
84 | |||
85 | 2020-01-01 balance Assets:Checking 0 USD | ||
86 | |||
87 | 2020-01-02 balance Assets:Checking 10 ~ 20 USD | ||
88 | ''', entries) | ||
89 | |||
90 | @loader.load_doc(expect_errors=True) | ||
91 | def test_account_with_invalid_tolerance(self, entries, errors, options_map): | ||
92 | ''' | ||
93 | plugin "beancount_extras_kris7t.plugins.default_tolerance" | ||
94 | |||
95 | 2020-01-01 open Assets:Checking | ||
96 | default-balance-tolerance: TRUE | ||
97 | |||
98 | 2020-01-01 balance Assets:Checking 0 USD | ||
99 | ''' | ||
100 | self.assertRegex(errors[0].message, '^default-balance-tolerance must be decimal') | ||
101 | |||
102 | |||
103 | if __name__ == '__main__': | ||
104 | unittest.main() | ||
diff --git a/beancount_extras_kris7t/plugins/selective_implicit_prices.py b/beancount_extras_kris7t/plugins/selective_implicit_prices.py new file mode 100644 index 0000000..07dc893 --- /dev/null +++ b/beancount_extras_kris7t/plugins/selective_implicit_prices.py | |||
@@ -0,0 +1,157 @@ | |||
1 | """This plugin synthesizes Price directives for all Postings with a price or | ||
2 | directive or if it is an augmenting posting, has a cost directive. | ||
3 | |||
4 | Price directives will be synthesized only for commodities with the | ||
5 | implicit-prices: TRUE metadata set. | ||
6 | """ | ||
7 | __copyright__ = "Copyright (C) 2015-2017 Martin Blais, " + \ | ||
8 | "2020 Kristóf Marussy <kristof@marussy.com>" | ||
9 | __license__ = "GNU GPLv2" | ||
10 | |||
11 | import collections | ||
12 | from typing import List, Tuple, Set | ||
13 | |||
14 | from beancount.core.data import Commodity, Entries, Transaction | ||
15 | from beancount.core import data | ||
16 | from beancount.core import amount | ||
17 | from beancount.core import inventory | ||
18 | from beancount.core.position import Cost | ||
19 | |||
20 | __plugins__ = ('add_implicit_prices',) | ||
21 | |||
22 | |||
23 | ImplicitPriceError = collections.namedtuple('ImplicitPriceError', 'source message entry') | ||
24 | |||
25 | |||
26 | METADATA_FIELD = "__implicit_prices__" | ||
27 | IMPLICIT_PRICES_META = "implicit-prices" | ||
28 | |||
29 | |||
30 | def fetch_commodities(entries: Entries) -> Tuple[Set[str], List[ImplicitPriceError]]: | ||
31 | commodities: Set[str] = set() | ||
32 | errors: List[ImplicitPriceError] = [] | ||
33 | for entry in entries: | ||
34 | if isinstance(entry, Commodity): | ||
35 | implicit_prices = entry.meta.get(IMPLICIT_PRICES_META, False) | ||
36 | if not isinstance(implicit_prices, bool): | ||
37 | errors.append(ImplicitPriceError( | ||
38 | entry.meta, | ||
39 | f'{IMPLICIT_PRICES_META} must be Boolean, got {implicit_prices} instead', | ||
40 | entry)) | ||
41 | if implicit_prices: | ||
42 | commodities.add(entry.currency) | ||
43 | return commodities, errors | ||
44 | |||
45 | |||
46 | def add_implicit_prices(entries: Entries, | ||
47 | unused_options_map) -> Tuple[Entries, List[ImplicitPriceError]]: | ||
48 | """Insert implicitly defined prices from Transactions. | ||
49 | |||
50 | Explicit price entries are simply maintained in the output list. Prices from | ||
51 | postings with costs or with prices from Transaction entries are synthesized | ||
52 | as new Price entries in the list of entries output. | ||
53 | |||
54 | Args: | ||
55 | entries: A list of directives. We're interested only in the Transaction instances. | ||
56 | unused_options_map: A parser options dict. | ||
57 | Returns: | ||
58 | A list of entries, possibly with more Price entries than before, and a | ||
59 | list of errors. | ||
60 | """ | ||
61 | new_entries: Entries = [] | ||
62 | errors: List[ImplicitPriceError] = [] | ||
63 | |||
64 | commodities, fetch_errors = fetch_commodities(entries) | ||
65 | errors.extend(fetch_errors) | ||
66 | |||
67 | # A dict of (date, currency, cost-currency) to price entry. | ||
68 | new_price_entry_map = {} | ||
69 | |||
70 | balances = collections.defaultdict(inventory.Inventory) | ||
71 | for entry in entries: | ||
72 | # Always replicate the existing entries. | ||
73 | new_entries.append(entry) | ||
74 | |||
75 | if isinstance(entry, Transaction): | ||
76 | # Inspect all the postings in the transaction. | ||
77 | for posting in entry.postings: | ||
78 | units = posting.units | ||
79 | if units.currency not in commodities: | ||
80 | continue | ||
81 | |||
82 | cost = posting.cost | ||
83 | |||
84 | # Check if the position is matching against an existing | ||
85 | # position. | ||
86 | _, booking = balances[posting.account].add_position(posting) | ||
87 | |||
88 | # Add prices when they're explicitly specified on a posting. An | ||
89 | # explicitly specified price may occur in a conversion, e.g. | ||
90 | # Assets:Account 100 USD @ 1.10 CAD | ||
91 | # or, if a cost is also specified, as the current price of the | ||
92 | # underlying instrument, e.g. | ||
93 | # Assets:Account 100 HOOL {564.20} @ {581.97} USD | ||
94 | if posting.price is not None: | ||
95 | meta = data.new_metadata(entry.meta["filename"], entry.meta["lineno"]) | ||
96 | meta[METADATA_FIELD] = "from_price" | ||
97 | price_entry = data.Price(meta, entry.date, | ||
98 | units.currency, | ||
99 | posting.price) | ||
100 | |||
101 | # Add costs, when we're not matching against an existing | ||
102 | # position. This happens when we're just specifying the cost, | ||
103 | # e.g. | ||
104 | # Assets:Account 100 HOOL {564.20} | ||
105 | elif (cost is not None and | ||
106 | booking != inventory.MatchResult.REDUCED): | ||
107 | # TODO(blais): What happens here if the account has no | ||
108 | # booking strategy? Do we end up inserting a price for the | ||
109 | # reducing leg? Check. | ||
110 | meta = data.new_metadata(entry.meta["filename"], entry.meta["lineno"]) | ||
111 | meta[METADATA_FIELD] = "from_cost" | ||
112 | if isinstance(cost, Cost): | ||
113 | price_entry = data.Price(meta, entry.date, | ||
114 | units.currency, | ||
115 | amount.Amount(cost.number, cost.currency)) | ||
116 | else: | ||
117 | errors.append( | ||
118 | ImplicitPriceError( | ||
119 | entry.meta, | ||
120 | f'Expected {entry} to have a Cost, got {cost} instead', | ||
121 | entry)) | ||
122 | price_entry = None | ||
123 | else: | ||
124 | price_entry = None | ||
125 | |||
126 | if price_entry is not None: | ||
127 | key = (price_entry.date, | ||
128 | price_entry.currency, | ||
129 | price_entry.amount.number, # Ideally should be removed. | ||
130 | price_entry.amount.currency) | ||
131 | try: | ||
132 | new_price_entry_map[key] | ||
133 | |||
134 | # Do not fail for now. We still have many valid use | ||
135 | # cases of duplicate prices on the same date, for | ||
136 | # example, stock splits, or trades on two dates with | ||
137 | # two separate reported prices. We need to figure out a | ||
138 | # more elegant solution for this in the long term. | ||
139 | # Keeping both for now. We should ideally not use the | ||
140 | # number in the de-dup key above. | ||
141 | # | ||
142 | # dup_entry = new_price_entry_map[key] | ||
143 | # if price_entry.amount.number == dup_entry.amount.number: | ||
144 | # # Skip duplicates. | ||
145 | # continue | ||
146 | # else: | ||
147 | # errors.append( | ||
148 | # ImplicitPriceError( | ||
149 | # entry.meta, | ||
150 | # "Duplicate prices for {} on {}".format(entry, | ||
151 | # dup_entry), | ||
152 | # entry)) | ||
153 | except KeyError: | ||
154 | new_price_entry_map[key] = price_entry | ||
155 | new_entries.append(price_entry) | ||
156 | |||
157 | return new_entries, errors | ||
diff --git a/beancount_extras_kris7t/plugins/selective_implicit_prices_test.py b/beancount_extras_kris7t/plugins/selective_implicit_prices_test.py new file mode 100644 index 0000000..6ead45d --- /dev/null +++ b/beancount_extras_kris7t/plugins/selective_implicit_prices_test.py | |||
@@ -0,0 +1,410 @@ | |||
1 | __copyright__ = "Copyright (C) 2015-2017 Martin Blais, " + \ | ||
2 | "2020 Kristóf Marussy <kristof@marussy.com>" | ||
3 | __license__ = "GNU GPLv2" | ||
4 | |||
5 | import unittest | ||
6 | |||
7 | from beancount.core.number import D | ||
8 | from beancount.core import data | ||
9 | from beancount.parser import cmptest | ||
10 | from beancount import loader | ||
11 | |||
12 | from beancount_extras_kris7t.plugins import selective_implicit_prices as implicit_prices | ||
13 | |||
14 | |||
15 | class TestImplicitPrices(cmptest.TestCase): | ||
16 | |||
17 | @loader.load_doc() | ||
18 | def test_add_implicit_prices__all_cases(self, entries, _, options_map): | ||
19 | """ | ||
20 | 1702-04-02 commodity USD | ||
21 | implicit-prices: TRUE | ||
22 | |||
23 | 2013-01-01 commodity HOOL | ||
24 | implicit-prices: TRUE | ||
25 | |||
26 | 2013-01-01 open Assets:Account1 | ||
27 | 2013-01-01 open Assets:Account2 | ||
28 | 2013-01-01 open Assets:Other | ||
29 | |||
30 | ;; An explicit price directive. | ||
31 | 2013-02-01 price USD 1.10 CAD | ||
32 | |||
33 | 2013-04-01 * "A transaction with a price conversion." | ||
34 | Assets:Account1 150 USD @ 1.12 CAD | ||
35 | Assets:Other | ||
36 | |||
37 | ;; This should book at price at the cost. | ||
38 | 2013-04-02 * "A transaction with a cost." | ||
39 | Assets:Account1 1500 HOOL {520 USD} | ||
40 | Assets:Other | ||
41 | |||
42 | ;; This one should be IGNORED because it books against the above. | ||
43 | 2013-04-03 * "A transaction with a cost that reduces an existing position" | ||
44 | Assets:Account1 -500 HOOL {520 USD} | ||
45 | Assets:Other | ||
46 | |||
47 | ;; This one should generate the price, even if it is reducing. | ||
48 | 2013-04-04 * "A transaction with a cost that reduces existing position, with price" | ||
49 | Assets:Account1 -100 HOOL {520 USD} @ 530 USD | ||
50 | Assets:Other | ||
51 | |||
52 | ;; This is not reducing and should also book a price at cost. | ||
53 | 2013-04-05 * "A transaction with another cost that is not reducing." | ||
54 | Assets:Account1 500 HOOL {540 USD} | ||
55 | Assets:Other | ||
56 | |||
57 | ;; The price here overrides the cost and should create an entry. | ||
58 | 2013-04-06 * "A transaction with a cost and a price." | ||
59 | Assets:Account1 500 HOOL {540 USD} @ 560 USD | ||
60 | Assets:Other | ||
61 | """ | ||
62 | self.assertEqual(12, len(entries)) | ||
63 | new_entries, _ = implicit_prices.add_implicit_prices(entries, options_map) | ||
64 | price_entries = [entry for entry in new_entries if isinstance(entry, data.Price)] | ||
65 | |||
66 | self.assertEqualEntries(""" | ||
67 | 1702-04-02 commodity USD | ||
68 | implicit-prices: TRUE | ||
69 | |||
70 | 2013-01-01 commodity HOOL | ||
71 | implicit-prices: TRUE | ||
72 | |||
73 | 2013-01-01 open Assets:Account1 | ||
74 | 2013-01-01 open Assets:Account2 | ||
75 | 2013-01-01 open Assets:Other | ||
76 | |||
77 | 2013-02-01 price USD 1.10 CAD | ||
78 | |||
79 | 2013-04-01 * "A transaction with a price conversion." | ||
80 | Assets:Account1 150 USD @ 1.12 CAD | ||
81 | Assets:Other -168.00 CAD | ||
82 | |||
83 | 2013-04-01 price USD 1.12 CAD | ||
84 | |||
85 | 2013-04-02 * "A transaction with a cost." | ||
86 | Assets:Account1 1500 HOOL {520 USD} | ||
87 | Assets:Other -780000 USD | ||
88 | |||
89 | 2013-04-02 price HOOL 520 USD | ||
90 | |||
91 | 2013-04-03 * "A transaction with a cost that reduces an existing position" | ||
92 | Assets:Account1 -500 HOOL {520 USD} | ||
93 | Assets:Other 260000 USD | ||
94 | |||
95 | 2013-04-04 * "A transaction with a cost that reduces existing position, with price" | ||
96 | Assets:Account1 -100 HOOL {520 USD} @ 530 USD | ||
97 | Assets:Other 52000 USD | ||
98 | |||
99 | 2013-04-04 price HOOL 530 USD | ||
100 | |||
101 | 2013-04-05 * "A transaction with another cost that is not reducing." | ||
102 | Assets:Account1 500 HOOL {540 USD} | ||
103 | Assets:Other -270000 USD | ||
104 | |||
105 | 2013-04-05 price HOOL 540 USD | ||
106 | |||
107 | 2013-04-06 * "A transaction with a cost and a price." | ||
108 | Assets:Account1 500 HOOL {540 USD} @ 560 USD | ||
109 | Assets:Other -270000 USD | ||
110 | |||
111 | 2013-04-06 price HOOL 560 USD | ||
112 | """, new_entries) | ||
113 | |||
114 | self.assertEqual(6, len(price_entries)) | ||
115 | expected_values = [(x[0], x[1], D(x[2])) for x in [ | ||
116 | ('USD', 'CAD', '1.10'), | ||
117 | ('USD', 'CAD', '1.12'), | ||
118 | ('HOOL', 'USD', '520.00'), | ||
119 | ('HOOL', 'USD', '530.00'), | ||
120 | ('HOOL', 'USD', '540.00'), | ||
121 | ('HOOL', 'USD', '560.00') | ||
122 | ]] | ||
123 | for expected, price in zip(expected_values, price_entries): | ||
124 | actual = (price.currency, price.amount.currency, price.amount.number) | ||
125 | self.assertEqual(expected, actual) | ||
126 | |||
127 | @loader.load_doc() | ||
128 | def test_add_implicit_prices__other_account(self, entries, errors, options_map): | ||
129 | """ | ||
130 | 2013-01-01 commodity HOOL | ||
131 | implicit-prices: TRUE | ||
132 | |||
133 | 2013-01-01 open Assets:Account1 | ||
134 | 2013-01-01 open Assets:Account2 "NONE" | ||
135 | 2013-01-01 open Assets:Other | ||
136 | |||
137 | 2013-04-01 * | ||
138 | Assets:Account1 1500 HOOL {520 USD} | ||
139 | Assets:Other | ||
140 | |||
141 | 2013-04-02 * | ||
142 | Assets:Account2 1500 HOOL {530 USD} | ||
143 | Assets:Other | ||
144 | |||
145 | 2013-04-10 * "Reduces existing position in account 1" | ||
146 | Assets:Account1 -100 HOOL {520 USD} | ||
147 | Assets:Other 52000 USD | ||
148 | |||
149 | 2013-04-11 * "Does not find an existing position in account 2" | ||
150 | Assets:Account2 -200 HOOL {531 USD} | ||
151 | Assets:Other 106200 USD | ||
152 | |||
153 | """ | ||
154 | new_entries, _ = implicit_prices.add_implicit_prices(entries, options_map) | ||
155 | self.assertEqualEntries(""" | ||
156 | 2013-01-01 commodity HOOL | ||
157 | implicit-prices: TRUE | ||
158 | |||
159 | 2013-01-01 open Assets:Account1 | ||
160 | 2013-01-01 open Assets:Account2 "NONE" | ||
161 | 2013-01-01 open Assets:Other | ||
162 | |||
163 | 2013-04-01 * | ||
164 | Assets:Account1 1500 HOOL {520 USD} | ||
165 | Assets:Other -780000 USD | ||
166 | |||
167 | 2013-04-02 * | ||
168 | Assets:Account2 1500 HOOL {530 USD} | ||
169 | Assets:Other -795000 USD | ||
170 | |||
171 | 2013-04-01 price HOOL 520 USD | ||
172 | |||
173 | 2013-04-02 price HOOL 530 USD | ||
174 | |||
175 | 2013-04-10 * "Reduces existing position in account 1" | ||
176 | Assets:Account1 -100 HOOL {520 USD} | ||
177 | Assets:Other 52000 USD | ||
178 | |||
179 | 2013-04-11 * "Does not find an existing position in account 2" | ||
180 | Assets:Account2 -200 HOOL {531 USD} | ||
181 | Assets:Other 106200 USD | ||
182 | |||
183 | ;; Because a match was not found against the inventory, a price will be added. | ||
184 | 2013-04-11 price HOOL 531 USD | ||
185 | |||
186 | """, new_entries) | ||
187 | |||
188 | @loader.load_doc() | ||
189 | def test_add_implicit_prices__duplicates_on_same_transaction(self, | ||
190 | entries, _, options_map): | ||
191 | """ | ||
192 | 2013-01-01 commodity HOOL | ||
193 | implicit-prices: TRUE | ||
194 | |||
195 | 2013-01-01 open Assets:Account1 | ||
196 | 2013-01-01 open Assets:Account2 | ||
197 | 2013-01-01 open Assets:Other | ||
198 | |||
199 | 2013-04-01 * "Allowed because of same price" | ||
200 | Assets:Account1 1500 HOOL {520 USD} | ||
201 | Assets:Account2 1500 HOOL {520 USD} | ||
202 | Assets:Other | ||
203 | |||
204 | 2013-04-02 * "Second one is disallowed because of different price" | ||
205 | Assets:Account1 1500 HOOL {520 USD} | ||
206 | Assets:Account2 1500 HOOL {530 USD} | ||
207 | Assets:Other | ||
208 | |||
209 | """ | ||
210 | new_entries, errors = implicit_prices.add_implicit_prices(entries, options_map) | ||
211 | self.assertEqual([], [type(error) for error in errors]) | ||
212 | self.assertEqualEntries(""" | ||
213 | 2013-01-01 commodity HOOL | ||
214 | implicit-prices: TRUE | ||
215 | |||
216 | 2013-01-01 open Assets:Account1 | ||
217 | 2013-01-01 open Assets:Account2 | ||
218 | 2013-01-01 open Assets:Other | ||
219 | |||
220 | 2013-04-01 * "Allowed because of same price" | ||
221 | Assets:Account1 1500 HOOL {520 USD} | ||
222 | Assets:Account2 1500 HOOL {520 USD} | ||
223 | Assets:Other -1560000 USD | ||
224 | |||
225 | 2013-04-01 price HOOL 520 USD | ||
226 | |||
227 | 2013-04-02 * "Second one is disallowed because of different price" | ||
228 | Assets:Account1 1500 HOOL {520 USD} | ||
229 | Assets:Account2 1500 HOOL {530 USD} | ||
230 | Assets:Other -1575000 USD | ||
231 | |||
232 | 2013-04-02 price HOOL 520 USD | ||
233 | 2013-04-02 price HOOL 530 USD ;; Allowed for now. | ||
234 | |||
235 | """, new_entries) | ||
236 | |||
237 | @loader.load_doc() | ||
238 | def test_add_implicit_prices__duplicates_on_different_transactions(self, | ||
239 | entries, _, | ||
240 | options_map): | ||
241 | """ | ||
242 | 2013-01-01 commodity HOOL | ||
243 | implicit-prices: TRUE | ||
244 | |||
245 | 2013-01-01 open Assets:Account1 | ||
246 | 2013-01-01 open Assets:Account2 | ||
247 | 2013-01-01 open Assets:Other | ||
248 | |||
249 | 2013-04-01 * "Allowed because of same price #1" | ||
250 | Assets:Account1 1500 HOOL {520 USD} | ||
251 | Assets:Other | ||
252 | |||
253 | 2013-04-01 * "Allowed because of same price #2" | ||
254 | Assets:Account2 1500 HOOL {520 USD} | ||
255 | Assets:Other | ||
256 | |||
257 | 2013-04-02 * "Second one is disallowed because of different price #1" | ||
258 | Assets:Account1 1500 HOOL {520 USD} | ||
259 | Assets:Other | ||
260 | |||
261 | 2013-04-02 * "Second one is disallowed because of different price #2" | ||
262 | Assets:Account2 1500 HOOL {530 USD} | ||
263 | Assets:Other | ||
264 | |||
265 | """ | ||
266 | new_entries, errors = implicit_prices.add_implicit_prices(entries, options_map) | ||
267 | self.assertEqual([], [type(error) for error in errors]) | ||
268 | self.assertEqualEntries(""" | ||
269 | 2013-01-01 commodity HOOL | ||
270 | implicit-prices: TRUE | ||
271 | |||
272 | 2013-01-01 open Assets:Account1 | ||
273 | 2013-01-01 open Assets:Account2 | ||
274 | 2013-01-01 open Assets:Other | ||
275 | |||
276 | 2013-04-01 * "Allowed because of same price #1" | ||
277 | Assets:Account1 1500 HOOL {520 USD} | ||
278 | Assets:Other -780000 USD | ||
279 | |||
280 | 2013-04-01 * "Allowed because of same price #2" | ||
281 | Assets:Account2 1500 HOOL {520 USD} | ||
282 | Assets:Other -780000 USD | ||
283 | |||
284 | 2013-04-01 price HOOL 520 USD | ||
285 | |||
286 | 2013-04-02 * "Second one is disallowed because of different price #1" | ||
287 | Assets:Account1 1500 HOOL {520 USD} | ||
288 | Assets:Other -780000 USD | ||
289 | |||
290 | 2013-04-02 * "Second one is disallowed because of different price #2" | ||
291 | Assets:Account2 1500 HOOL {530 USD} | ||
292 | Assets:Other -795000 USD | ||
293 | |||
294 | 2013-04-02 price HOOL 520 USD | ||
295 | 2013-04-02 price HOOL 530 USD ;; Allowed for now. | ||
296 | |||
297 | """, new_entries) | ||
298 | |||
299 | @loader.load_doc() | ||
300 | def test_add_implicit_prices__duplicates_overloaded(self, entries, _, options_map): | ||
301 | """ | ||
302 | 2013-01-01 commodity HOOL | ||
303 | implicit-prices: TRUE | ||
304 | |||
305 | 2013-01-01 open Assets:Account1 | ||
306 | 2013-01-01 open Assets:Other | ||
307 | |||
308 | 2013-04-01 * "Allowed, sets the price for that day" | ||
309 | Assets:Account1 1500 HOOL {520 USD} | ||
310 | Assets:Other | ||
311 | |||
312 | 2013-04-01 * "Will be ignored, price for the day already set" | ||
313 | Assets:Account1 1500 HOOL {530 USD} | ||
314 | Assets:Other | ||
315 | |||
316 | 2013-04-01 * "Should be ignored too, price for the day already set" | ||
317 | Assets:Account1 1500 HOOL {530 USD} | ||
318 | Assets:Other | ||
319 | """ | ||
320 | new_entries, errors = implicit_prices.add_implicit_prices(entries, options_map) | ||
321 | self.assertEqual([], [type(error) for error in errors]) | ||
322 | self.assertEqualEntries(""" | ||
323 | 2013-01-01 commodity HOOL | ||
324 | implicit-prices: TRUE | ||
325 | |||
326 | 2013-01-01 open Assets:Account1 | ||
327 | 2013-01-01 open Assets:Other | ||
328 | |||
329 | 2013-04-01 * "Allowed, sets the price for that day" | ||
330 | Assets:Account1 1500 HOOL {520 USD} | ||
331 | Assets:Other -780000 USD | ||
332 | |||
333 | 2013-04-01 * "Will be ignored, price for the day already set" | ||
334 | Assets:Account1 1500 HOOL {530 USD} | ||
335 | Assets:Other -795000 USD | ||
336 | |||
337 | 2013-04-01 * "Should be ignored too, price for the day already set" | ||
338 | Assets:Account1 1500 HOOL {530 USD} | ||
339 | Assets:Other -795000 USD | ||
340 | |||
341 | 2013-04-01 price HOOL 520 USD | ||
342 | 2013-04-01 price HOOL 530 USD | ||
343 | |||
344 | """, new_entries) | ||
345 | |||
346 | @loader.load_doc() | ||
347 | def test_add_implicit_prices__not_enabled(self, entries, errors, options_map): | ||
348 | """ | ||
349 | 2013-01-01 open Assets:Account1 | ||
350 | 2013-01-01 open Assets:Other | ||
351 | |||
352 | 2013-04-01 * | ||
353 | Assets:Account1 1500 HOOL {520 USD} | ||
354 | Assets:Other | ||
355 | """ | ||
356 | new_entries, _ = implicit_prices.add_implicit_prices(entries, options_map) | ||
357 | self.assertEqualEntries(""" | ||
358 | 2013-01-01 open Assets:Account1 | ||
359 | 2013-01-01 open Assets:Other | ||
360 | |||
361 | 2013-04-01 * | ||
362 | Assets:Account1 1500 HOOL {520 USD} | ||
363 | Assets:Other -780000 USD | ||
364 | """, new_entries) | ||
365 | |||
366 | @loader.load_doc() | ||
367 | def test_add_implicit_prices__disabled(self, entries, errors, options_map): | ||
368 | """ | ||
369 | 2013-01-01 commodity HOOL | ||
370 | implicit-prices: FALSE | ||
371 | |||
372 | 2013-01-01 open Assets:Account1 | ||
373 | 2013-01-01 open Assets:Other | ||
374 | |||
375 | 2013-04-01 * | ||
376 | Assets:Account1 1500 HOOL {520 USD} | ||
377 | Assets:Other | ||
378 | """ | ||
379 | new_entries, _ = implicit_prices.add_implicit_prices(entries, options_map) | ||
380 | self.assertEqualEntries(""" | ||
381 | 2013-01-01 commodity HOOL | ||
382 | implicit-prices: FALSE | ||
383 | |||
384 | 2013-01-01 open Assets:Account1 | ||
385 | 2013-01-01 open Assets:Other | ||
386 | |||
387 | 2013-04-01 * | ||
388 | Assets:Account1 1500 HOOL {520 USD} | ||
389 | Assets:Other -780000 USD | ||
390 | """, new_entries) | ||
391 | |||
392 | @loader.load_doc() | ||
393 | def test_add_implicit_prices__invalid(self, entries, errors, options_map): | ||
394 | """ | ||
395 | 2013-01-01 commodity HOOL | ||
396 | implicit-prices: "yes" | ||
397 | |||
398 | 2013-01-01 open Assets:Account1 | ||
399 | 2013-01-01 open Assets:Other | ||
400 | |||
401 | 2013-04-01 * | ||
402 | Assets:Account1 1500 HOOL {520 USD} | ||
403 | Assets:Other | ||
404 | """ | ||
405 | _, new_errors = implicit_prices.add_implicit_prices(entries, options_map) | ||
406 | self.assertRegex(new_errors[0].message, '^implicit-prices must be Boolean') | ||
407 | |||
408 | |||
409 | if __name__ == '__main__': | ||
410 | unittest.main() | ||
diff --git a/beancount_extras_kris7t/plugins/templates.py b/beancount_extras_kris7t/plugins/templates.py new file mode 100644 index 0000000..7d2d2c1 --- /dev/null +++ b/beancount_extras_kris7t/plugins/templates.py | |||
@@ -0,0 +1,144 @@ | |||
1 | ''' | ||
2 | Plugin that closes an account by transferring its whole balance to another account. | ||
3 | ''' | ||
4 | __copyright__ = 'Copyright (c) 2020 Kristóf Marussy <kristof@marussy.com>' | ||
5 | __license__ = 'GNU GPLv2' | ||
6 | |||
7 | import datetime as dt | ||
8 | from decimal import Decimal | ||
9 | from typing import Any, Dict, List, NamedTuple, Optional, Tuple | ||
10 | |||
11 | from beancount.core import amount | ||
12 | from beancount.core.data import Custom, Directive, Entries, Meta, Transaction, Union | ||
13 | from beancount.core.number import ZERO | ||
14 | |||
15 | __plugins__ = ('apply_templates',) | ||
16 | |||
17 | TEMPLATE_META = 'template' | ||
18 | TEMPLATE_USE_CUSTOM = 'template-use' | ||
19 | TEMPLATE_DELETE_CUSTOM = 'template-delete' | ||
20 | TEMPLATE_TAG_PREFIX = 'template' | ||
21 | |||
22 | |||
23 | Templates = Dict[str, Transaction] | ||
24 | |||
25 | |||
26 | class TemplateError(NamedTuple): | ||
27 | source: Optional[Meta] | ||
28 | message: str | ||
29 | entry: Directive | ||
30 | |||
31 | |||
32 | def _create_transaction(date: dt.date, | ||
33 | meta: Meta, | ||
34 | template: Transaction, | ||
35 | scale_factor: Decimal) -> Transaction: | ||
36 | return template._replace( | ||
37 | date=date, | ||
38 | meta={**template.meta, **meta}, | ||
39 | postings=[posting._replace(units=amount.mul(posting.units, scale_factor)) | ||
40 | for posting in template.postings]) | ||
41 | |||
42 | |||
43 | def _use_template(entry: Custom, | ||
44 | templates: Templates) -> Union[Transaction, TemplateError]: | ||
45 | if len(entry.values) == 0: | ||
46 | return TemplateError(entry.meta, 'Template name missing', entry) | ||
47 | if len(entry.values) > 2: | ||
48 | return TemplateError( | ||
49 | entry.meta, | ||
50 | f'Too many {TEMPLATE_USE_CUSTOM} arguments', | ||
51 | entry) | ||
52 | template_name = entry.values[0].value | ||
53 | if not isinstance(template_name, str): | ||
54 | return TemplateError( | ||
55 | entry.meta, | ||
56 | f'Template name must be a string, got {template_name} instead', | ||
57 | entry) | ||
58 | template = templates.get(template_name, None) | ||
59 | if template is None: | ||
60 | return TemplateError( | ||
61 | entry.meta, | ||
62 | f'Unknown template: {template_name}', | ||
63 | entry) | ||
64 | if len(entry.values) == 2: | ||
65 | scale_factor = entry.values[1].value | ||
66 | if not isinstance(scale_factor, Decimal): | ||
67 | return TemplateError( | ||
68 | entry.meta, | ||
69 | f'Invalid scale factor {scale_factor}', | ||
70 | entry) | ||
71 | if scale_factor == ZERO: | ||
72 | return TemplateError( | ||
73 | entry.meta, | ||
74 | f'Scale factor must not be {ZERO}', | ||
75 | entry) | ||
76 | else: | ||
77 | scale_factor = Decimal(1.0) | ||
78 | return _create_transaction(entry.date, entry.meta, template, scale_factor) | ||
79 | |||
80 | |||
81 | def _add_template(entry: Transaction, templates: Templates) -> Optional[TemplateError]: | ||
82 | template_name = entry.meta[TEMPLATE_META] | ||
83 | if not isinstance(template_name, str): | ||
84 | return TemplateError( | ||
85 | entry.meta, | ||
86 | f'{TEMPLATE_META} must be a string, got {template_name} instead', | ||
87 | entry) | ||
88 | new_meta = dict(entry.meta) | ||
89 | del new_meta[TEMPLATE_META] | ||
90 | templates[template_name] = entry._replace( | ||
91 | meta=new_meta, | ||
92 | links={*entry.links, f'{TEMPLATE_TAG_PREFIX}_{template_name}'}) | ||
93 | return None | ||
94 | |||
95 | |||
96 | def _delete_template(entry: Custom, templates: Templates) -> Optional[TemplateError]: | ||
97 | if len(entry.values) != 1: | ||
98 | return TemplateError( | ||
99 | entry.meta, | ||
100 | f'{TEMPLATE_DELETE_CUSTOM} takes a single argument', | ||
101 | entry) | ||
102 | template_name = entry.values[0].value | ||
103 | if not isinstance(template_name, str): | ||
104 | return TemplateError( | ||
105 | entry.meta, | ||
106 | f'Template name must be a string, got {template_name} instead', | ||
107 | entry) | ||
108 | if template_name not in templates: | ||
109 | return TemplateError( | ||
110 | entry.meta, | ||
111 | f'Unknown template: {template_name}', | ||
112 | entry) | ||
113 | del templates[template_name] | ||
114 | return None | ||
115 | |||
116 | |||
117 | def apply_templates(entries: Entries, | ||
118 | options_map: Dict[str, Any], | ||
119 | config_str: Optional[str] = None) -> \ | ||
120 | Tuple[Entries, List[TemplateError]]: | ||
121 | new_entries: Entries = [] | ||
122 | errors: List[TemplateError] = [] | ||
123 | templates: Templates = {} | ||
124 | for entry in entries: | ||
125 | if isinstance(entry, Transaction) and TEMPLATE_META in entry.meta: | ||
126 | result = _add_template(entry, templates) | ||
127 | if result: | ||
128 | errors.append(result) | ||
129 | elif isinstance(entry, Custom): | ||
130 | if entry.type == TEMPLATE_USE_CUSTOM: | ||
131 | result = _use_template(entry, templates) | ||
132 | if isinstance(result, TemplateError): | ||
133 | errors.append(result) | ||
134 | else: | ||
135 | new_entries.append(result) | ||
136 | elif entry.type == TEMPLATE_DELETE_CUSTOM: | ||
137 | result = _delete_template(entry, templates) | ||
138 | if result: | ||
139 | errors.append(result) | ||
140 | else: | ||
141 | new_entries.append(entry) | ||
142 | else: | ||
143 | new_entries.append(entry) | ||
144 | return new_entries, errors | ||
diff --git a/beancount_extras_kris7t/plugins/templates_test.py b/beancount_extras_kris7t/plugins/templates_test.py new file mode 100644 index 0000000..5f63e6c --- /dev/null +++ b/beancount_extras_kris7t/plugins/templates_test.py | |||
@@ -0,0 +1,326 @@ | |||
1 | __copyright__ = 'Copyright (c) 2020 Kristóf Marussy <kristof@marussy.com>' | ||
2 | __license__ = 'GNU GPLv2' | ||
3 | |||
4 | import unittest | ||
5 | |||
6 | from beancount import loader | ||
7 | from beancount.parser import cmptest | ||
8 | import pytest | ||
9 | |||
10 | |||
11 | class TestClosingBalance(cmptest.TestCase): | ||
12 | |||
13 | @loader.load_doc() | ||
14 | def test_use_template_simple(self, entries, errors, options_map): | ||
15 | ''' | ||
16 | plugin "beancount_extras_kris7t.plugins.templates" | ||
17 | |||
18 | 2020-01-01 open Assets:Checking | ||
19 | 2020-01-01 open Expenses:Food | ||
20 | |||
21 | 2020-01-01 * "Eating out" #tag ^link | ||
22 | template: "eating-out" | ||
23 | Assets:Checking 25 USD | ||
24 | Expenses:Food -25 USD | ||
25 | |||
26 | 2020-03-15 custom "template-use" "eating-out" | ||
27 | |||
28 | 2020-04-12 custom "template-use" "eating-out" | ||
29 | ''' | ||
30 | self.assertEqualEntries(''' | ||
31 | plugin "beancount_extras_kris7t.plugins.closing_balance" | ||
32 | |||
33 | 2020-01-01 open Assets:Checking | ||
34 | 2020-01-01 open Expenses:Food | ||
35 | |||
36 | 2020-03-15 * "Eating out" #tag ^link ^template_eating-out | ||
37 | template: "eating-out" | ||
38 | Assets:Checking 25 USD | ||
39 | Expenses:Food -25 USD | ||
40 | |||
41 | 2020-04-12 * "Eating out" #tag ^link ^template_eating-out | ||
42 | template: "eating-out" | ||
43 | Assets:Checking 25 USD | ||
44 | Expenses:Food -25 USD | ||
45 | ''', entries) | ||
46 | |||
47 | @loader.load_doc() | ||
48 | def test_use_template_metadata(self, entries, errors, options_map): | ||
49 | ''' | ||
50 | plugin "beancount_extras_kris7t.plugins.templates" | ||
51 | |||
52 | 2020-01-01 open Assets:Checking | ||
53 | 2020-01-01 open Expenses:Food | ||
54 | |||
55 | 2020-01-01 * "Eating out" #tag ^link | ||
56 | template: "eating-out" | ||
57 | meta1: "data" | ||
58 | meta2: TRUE | ||
59 | Assets:Checking 25 USD | ||
60 | Expenses:Food -25 USD | ||
61 | |||
62 | 2020-03-15 custom "template-use" "eating-out" | ||
63 | meta1: "foo" | ||
64 | meta3: 3.14 | ||
65 | ''' | ||
66 | self.assertEqualEntries(''' | ||
67 | plugin "beancount_extras_kris7t.plugins.closing_balance" | ||
68 | |||
69 | 2020-01-01 open Assets:Checking | ||
70 | 2020-01-01 open Expenses:Food | ||
71 | |||
72 | 2020-03-15 * "Eating out" #tag ^link ^template_eating-out | ||
73 | template: "eating-out" | ||
74 | meta1: "foo" | ||
75 | meta2: TRUE | ||
76 | meta3: 2.14 | ||
77 | Assets:Checking 25 USD | ||
78 | Expenses:Food -25 USD | ||
79 | ''', entries) | ||
80 | |||
81 | @loader.load_doc() | ||
82 | def test_use_template_scaled(self, entries, errors, options_map): | ||
83 | ''' | ||
84 | plugin "beancount_extras_kris7t.plugins.templates" | ||
85 | |||
86 | 2020-01-01 open Assets:Checking | ||
87 | 2020-01-01 open Expenses:Food | ||
88 | |||
89 | 2020-01-01 * "Eating out" #tag ^link | ||
90 | template: "eating-out" | ||
91 | Assets:Checking 1 USD | ||
92 | Expenses:Food -1 USD | ||
93 | |||
94 | 2020-03-15 custom "template-use" "eating-out" 25 | ||
95 | |||
96 | 2020-04-12 custom "template-use" "eating-out" 27 | ||
97 | ''' | ||
98 | self.assertEqualEntries(''' | ||
99 | plugin "beancount_extras_kris7t.plugins.closing_balance" | ||
100 | |||
101 | 2020-01-01 open Assets:Checking | ||
102 | 2020-01-01 open Expenses:Food | ||
103 | |||
104 | 2020-03-15 * "Eating out" #tag ^link ^template_eating-out | ||
105 | template: "eating-out" | ||
106 | Assets:Checking 25 USD | ||
107 | Expenses:Food -25 USD | ||
108 | |||
109 | 2020-04-12 * "Eating out" #tag ^link ^template_eating-out | ||
110 | template: "eating-out" | ||
111 | Assets:Checking 27 USD | ||
112 | Expenses:Food -27 USD | ||
113 | ''', entries) | ||
114 | |||
115 | @loader.load_doc() | ||
116 | def test_use_template_overwritten(self, entries, errors, options_map): | ||
117 | ''' | ||
118 | plugin "beancount_extras_kris7t.plugins.templates" | ||
119 | |||
120 | 2020-01-01 open Assets:Checking | ||
121 | 2020-01-01 open Expenses:Food | ||
122 | 2020-01-01 open Expenses:Tax | ||
123 | |||
124 | 2020-04-01 * "Eating out" #tag ^link | ||
125 | template: "eating-out" | ||
126 | Assets:Checking 1.10 USD | ||
127 | Expenses:Food -1 USD | ||
128 | Expenses:Tax -0.10 USD | ||
129 | |||
130 | 2020-01-01 * "Eating out" #tag ^link | ||
131 | template: "eating-out" | ||
132 | Assets:Checking 1 USD | ||
133 | Expenses:Food -1 USD | ||
134 | |||
135 | 2020-03-15 custom "template-use" "eating-out" 25 | ||
136 | |||
137 | 2020-04-12 custom "template-use" "eating-out" 27 | ||
138 | ''' | ||
139 | self.assertEqualEntries(''' | ||
140 | plugin "beancount_extras_kris7t.plugins.closing_balance" | ||
141 | |||
142 | 2020-01-01 open Assets:Checking | ||
143 | 2020-01-01 open Expenses:Food | ||
144 | 2020-01-01 open Expenses:Tax | ||
145 | |||
146 | 2020-03-15 * "Eating out" #tag ^link ^template_eating-out | ||
147 | template: "eating-out" | ||
148 | Assets:Checking 25 USD | ||
149 | Expenses:Food -25 USD | ||
150 | |||
151 | 2020-04-12 * "Eating out" #tag ^link ^template_eating-out | ||
152 | template: "eating-out" | ||
153 | Assets:Checking 29.70 USD | ||
154 | Expenses:Food -27 USD | ||
155 | Expenses:Tax -2.70 USD | ||
156 | ''', entries) | ||
157 | |||
158 | @loader.load_doc(expect_errors=True) | ||
159 | def test_invalid_name(self, entries, errors, options_map): | ||
160 | ''' | ||
161 | plugin "beancount_extras_kris7t.plugins.templates" | ||
162 | |||
163 | 2020-01-01 open Assets:Checking | ||
164 | 2020-01-01 open Expenses:Food | ||
165 | |||
166 | 2020-01-01 * "Eating out" | ||
167 | template: TRUE | ||
168 | Assets:Checking 25 USD | ||
169 | Expenses:Food -25 USD | ||
170 | ''' | ||
171 | self.assertRegex(errors[0].message, "^template must be a string") | ||
172 | |||
173 | @pytest.mark.xfail(reason="Empty custom directive fails in beancount.ops.pad") | ||
174 | @loader.load_doc(expect_errors=True) | ||
175 | def test_use_missing_name(self, entries, errors, options_map): | ||
176 | ''' | ||
177 | plugin "beancount_extras_kris7t.plugins.templates" | ||
178 | |||
179 | 2020-01-01 open Assets:Checking | ||
180 | 2020-01-01 open Expenses:Food | ||
181 | |||
182 | 2020-01-01 * "Eating out" | ||
183 | template: "eating-out" | ||
184 | Assets:Checking 25 USD | ||
185 | Expenses:Food -25 USD | ||
186 | |||
187 | 2020-03-15 custom "template-use" | ||
188 | ''' | ||
189 | self.assertRegex(errors[0].message, "^Template name missing") | ||
190 | |||
191 | @loader.load_doc(expect_errors=True) | ||
192 | def test_use_too_many_arguments(self, entries, errors, options_map): | ||
193 | ''' | ||
194 | plugin "beancount_extras_kris7t.plugins.templates" | ||
195 | |||
196 | 2020-01-01 open Assets:Checking | ||
197 | 2020-01-01 open Expenses:Food | ||
198 | |||
199 | 2020-01-01 * "Eating out" | ||
200 | template: "eating-out" | ||
201 | Assets:Checking 25 USD | ||
202 | Expenses:Food -25 USD | ||
203 | |||
204 | 2020-03-15 custom "template-use" "eating-out" 2 "another" | ||
205 | ''' | ||
206 | self.assertRegex(errors[0].message, "^Too many template-use arguments") | ||
207 | |||
208 | @loader.load_doc(expect_errors=True) | ||
209 | def test_use_invalid_name(self, entries, errors, options_map): | ||
210 | ''' | ||
211 | plugin "beancount_extras_kris7t.plugins.templates" | ||
212 | |||
213 | 2020-03-15 custom "template-use" TRUE | ||
214 | ''' | ||
215 | self.assertRegex(errors[0].message, "^Template name must be a string") | ||
216 | |||
217 | @loader.load_doc(expect_errors=True) | ||
218 | def test_use_unknown(self, entries, errors, options_map): | ||
219 | ''' | ||
220 | plugin "beancount_extras_kris7t.plugins.templates" | ||
221 | |||
222 | 2020-03-15 custom "template-use" "taxi" | ||
223 | ''' | ||
224 | self.assertRegex(errors[0].message, "^Unknown template") | ||
225 | |||
226 | @loader.load_doc(expect_errors=True) | ||
227 | def test_use_invalid_scale_factor(self, entries, errors, options_map): | ||
228 | ''' | ||
229 | plugin "beancount_extras_kris7t.plugins.templates" | ||
230 | |||
231 | 2020-01-01 open Assets:Checking | ||
232 | 2020-01-01 open Expenses:Food | ||
233 | |||
234 | 2020-01-01 * "Eating out" | ||
235 | template: "eating-out" | ||
236 | Assets:Checking 25 USD | ||
237 | Expenses:Food -25 USD | ||
238 | |||
239 | 2020-03-15 custom "template-use" "eating-out" TRUE | ||
240 | ''' | ||
241 | self.assertRegex(errors[0].message, "^Invalid scale factor") | ||
242 | |||
243 | @loader.load_doc(expect_errors=True) | ||
244 | def test_use_zero_scale_factor(self, entries, errors, options_map): | ||
245 | ''' | ||
246 | plugin "beancount_extras_kris7t.plugins.templates" | ||
247 | |||
248 | 2020-01-01 open Assets:Checking | ||
249 | 2020-01-01 open Expenses:Food | ||
250 | |||
251 | 2020-01-01 * "Eating out" | ||
252 | template: "eating-out" | ||
253 | Assets:Checking 25 USD | ||
254 | Expenses:Food -25 USD | ||
255 | |||
256 | 2020-03-15 custom "template-use" "eating-out" 0.0 | ||
257 | ''' | ||
258 | self.assertRegex(errors[0].message, "^Scale factor must not be 0") | ||
259 | |||
260 | @loader.load_doc(expect_errors=True) | ||
261 | def test_template_delete(self, entries, errors, options_map): | ||
262 | ''' | ||
263 | plugin "beancount_extras_kris7t.plugins.templates" | ||
264 | |||
265 | 2020-01-01 open Assets:Checking | ||
266 | 2020-01-01 open Expenses:Food | ||
267 | |||
268 | 2020-01-01 * "Eating out" | ||
269 | template: "eating-out" | ||
270 | Assets:Checking 25 USD | ||
271 | Expenses:Food -25 USD | ||
272 | |||
273 | 2020-03-01 custom "template-delete" "eating-out" | ||
274 | |||
275 | 2020-03-15 custom "template-use" "eating-out" | ||
276 | ''' | ||
277 | self.assertRegex(errors[0].message, "^Unknown template") | ||
278 | |||
279 | @pytest.mark.xfail(reason="Empty custom directive fails in beancount.ops.pad") | ||
280 | @loader.load_doc(expect_errors=True) | ||
281 | def test_template_delete_too_few_arguments(self, entries, errors, options_map): | ||
282 | ''' | ||
283 | plugin "beancount_extras_kris7t.plugins.templates" | ||
284 | |||
285 | 2020-03-01 custom "template-delete" | ||
286 | ''' | ||
287 | self.assertRegex(errors[0].message, "^template-delete takes a single argument") | ||
288 | |||
289 | @loader.load_doc(expect_errors=True) | ||
290 | def test_template_delete_too_many_arguments(self, entries, errors, options_map): | ||
291 | ''' | ||
292 | plugin "beancount_extras_kris7t.plugins.templates" | ||
293 | |||
294 | 2020-01-01 open Assets:Checking | ||
295 | 2020-01-01 open Expenses:Food | ||
296 | |||
297 | 2020-01-01 * "Eating out" | ||
298 | template: "eating-out" | ||
299 | Assets:Checking 25 USD | ||
300 | Expenses:Food -25 USD | ||
301 | |||
302 | 2020-03-01 custom "template-delete" "eating-out" TRUE | ||
303 | ''' | ||
304 | self.assertRegex(errors[0].message, "^template-delete takes a single argument") | ||
305 | |||
306 | @loader.load_doc(expect_errors=True) | ||
307 | def test_template_delete_invalid_argument(self, entries, errors, options_map): | ||
308 | ''' | ||
309 | plugin "beancount_extras_kris7t.plugins.templates" | ||
310 | |||
311 | 2020-03-01 custom "template-delete" TRUE | ||
312 | ''' | ||
313 | self.assertRegex(errors[0].message, "^Template name must be a string") | ||
314 | |||
315 | @loader.load_doc(expect_errors=True) | ||
316 | def test_template_delete_unknown(self, entries, errors, options_map): | ||
317 | ''' | ||
318 | plugin "beancount_extras_kris7t.plugins.templates" | ||
319 | |||
320 | 2020-03-01 custom "template-delete" "taxi" | ||
321 | ''' | ||
322 | self.assertRegex(errors[0].message, "^Unknown template") | ||
323 | |||
324 | |||
325 | if __name__ == '__main__': | ||
326 | unittest.main() | ||
diff --git a/beancount_extras_kris7t/plugins/transfer_accounts.py b/beancount_extras_kris7t/plugins/transfer_accounts.py new file mode 100644 index 0000000..653a54f --- /dev/null +++ b/beancount_extras_kris7t/plugins/transfer_accounts.py | |||
@@ -0,0 +1,188 @@ | |||
1 | ''' | ||
2 | Plugin that splits off postings into a new transaction to simulate settlement dates. | ||
3 | ''' | ||
4 | __copyright__ = 'Copyright (c) 2020 Kristóf Marussy <kristof@marussy.com>' | ||
5 | __license__ = 'GNU GPLv2' | ||
6 | |||
7 | from collections import defaultdict | ||
8 | import datetime as dt | ||
9 | from typing import Any, Dict, List, NamedTuple, Optional, Tuple, Union | ||
10 | |||
11 | from beancount.core import amount, convert | ||
12 | from beancount.core.amount import Amount | ||
13 | from beancount.core.data import Cost, CostSpec, Directive, Entries, Meta, Posting, Open, \ | ||
14 | Transaction | ||
15 | from beancount.core.inventory import Inventory | ||
16 | from beancount.core.number import ZERO | ||
17 | |||
18 | __plugins__ = ('split_entries_via_transfer_accounts',) | ||
19 | |||
20 | TRANSFER_ACCOUNT_META = 'transfer-account' | ||
21 | TRANSFER_DATE_META = 'transfer-date' | ||
22 | TRANSFER_CONVERTED_META = 'transfer-converted' | ||
23 | TRANSFER_CONVERTED_DEFAULT = True | ||
24 | |||
25 | |||
26 | class TransferAccountError(NamedTuple): | ||
27 | source: Optional[Meta] | ||
28 | message: str | ||
29 | entry: Directive | ||
30 | |||
31 | |||
32 | class _OutgoingTransfer(NamedTuple): | ||
33 | accout: str | ||
34 | currency: str | ||
35 | cost: Optional[Union[Cost, CostSpec]] | ||
36 | price: Optional[Amount] | ||
37 | |||
38 | |||
39 | class _IncomingTransfer(NamedTuple): | ||
40 | account: str | ||
41 | date: dt.date | ||
42 | |||
43 | |||
44 | class _IncomingPostings(NamedTuple): | ||
45 | postings: List[Posting] | ||
46 | inventroy: Inventory | ||
47 | |||
48 | |||
49 | class _Splitter: | ||
50 | _entry: Transaction | ||
51 | _processed_entries: Entries | ||
52 | _errors: List[TransferAccountError] | ||
53 | _default_converted: Dict[str, bool] | ||
54 | _processed_postings: List[Posting] | ||
55 | _amounts_to_transfer: Dict[_OutgoingTransfer, Amount] | ||
56 | _new_transactions: Dict[_IncomingTransfer, _IncomingPostings] | ||
57 | |||
58 | def __init__( | ||
59 | self, | ||
60 | entry: Transaction, | ||
61 | processed_entries: Entries, | ||
62 | errors: List[TransferAccountError], | ||
63 | default_converted: Dict[str, bool]): | ||
64 | self._entry = entry | ||
65 | self._processed_entries = processed_entries | ||
66 | self._errors = errors | ||
67 | self._default_converted = default_converted | ||
68 | self._processed_postings = [] | ||
69 | self._amounts_to_transfer = {} | ||
70 | self._new_transactions = defaultdict(lambda: _IncomingPostings([], Inventory())) | ||
71 | |||
72 | def split(self) -> None: | ||
73 | for posting in self._entry.postings: | ||
74 | self._split_posting(posting) | ||
75 | if not self._amounts_to_transfer: | ||
76 | self._processed_entries.append(self._entry) | ||
77 | return | ||
78 | for (account, _, cost, price), units in self._amounts_to_transfer.items(): | ||
79 | if units.number != ZERO: | ||
80 | self._processed_postings.append(Posting(account, units, cost, price, None, None)) | ||
81 | self._processed_entries.append(self._entry._replace(postings=self._processed_postings)) | ||
82 | for (account, date), (postings, inv) in self._new_transactions.items(): | ||
83 | for (units, cost) in inv: | ||
84 | postings.append(Posting(account, -units, cost, None, None, None)) | ||
85 | self._processed_entries.append(self._entry._replace(date=date, postings=postings)) | ||
86 | |||
87 | def _split_posting(self, posting: Posting) -> None: | ||
88 | if not posting.meta: | ||
89 | self._processed_postings.append(posting) | ||
90 | return | ||
91 | transfer_account = posting.meta.pop(TRANSFER_ACCOUNT_META, None) | ||
92 | transfer_date = posting.meta.pop(TRANSFER_DATE_META, None) | ||
93 | transfer_converted = posting.meta.pop(TRANSFER_CONVERTED_META, None) | ||
94 | if transfer_account is None: | ||
95 | if transfer_date is not None: | ||
96 | self._report_error( | ||
97 | f'{TRANSFER_DATE_META} was set but {TRANSFER_ACCOUNT_META} was not') | ||
98 | if transfer_converted is not None: | ||
99 | self._report_error( | ||
100 | f'{TRANSFER_CONVERTED_META} was set but {TRANSFER_ACCOUNT_META} was not') | ||
101 | self._processed_postings.append(posting) | ||
102 | return | ||
103 | if not isinstance(transfer_account, str): | ||
104 | self._report_error( | ||
105 | f'{TRANSFER_ACCOUNT_META} must be a string, got {transfer_account} instead') | ||
106 | self._processed_postings.append(posting) | ||
107 | return | ||
108 | if transfer_date is None: | ||
109 | transfer_date = self._entry.date | ||
110 | elif not isinstance(transfer_date, dt.date): | ||
111 | self._report_error( | ||
112 | f'{TRANSFER_DATE_META} must be a date, got {transfer_date} instead') | ||
113 | transfer_date = self._entry.date | ||
114 | transfer_converted_default = self._default_converted.get( | ||
115 | transfer_account, TRANSFER_CONVERTED_DEFAULT) | ||
116 | if transfer_converted is None: | ||
117 | transfer_converted = transfer_converted_default | ||
118 | elif not isinstance(transfer_converted, bool): | ||
119 | self._report_error( | ||
120 | f'{TRANSFER_CONVERTED_META} must be a Boolean, got {transfer_converted} instead') | ||
121 | transfer_converted = transfer_converted_default | ||
122 | elif posting.price is None and posting.cost is None: | ||
123 | self._report_error( | ||
124 | f'{TRANSFER_CONVERTED_META} was set, but there is no conversion') | ||
125 | assert posting.units | ||
126 | self._split_posting_with_options( | ||
127 | posting, transfer_account, transfer_date, transfer_converted) | ||
128 | |||
129 | def _split_posting_with_options( | ||
130 | self, | ||
131 | posting: Posting, | ||
132 | transfer_account: str, | ||
133 | transfer_date: dt.date, | ||
134 | transfer_converted: bool) -> None: | ||
135 | incoming = _IncomingTransfer(transfer_account, transfer_date) | ||
136 | incoming_postings, inv = self._new_transactions[incoming] | ||
137 | converted_amount = convert.get_weight(posting) | ||
138 | if transfer_converted: | ||
139 | outgoing = _OutgoingTransfer( | ||
140 | transfer_account, posting.units.currency, posting.cost, posting.price) | ||
141 | self._accumulate_outgoing(outgoing, posting.units) | ||
142 | incoming_postings.append(posting._replace(price=None)) | ||
143 | inv.add_amount(posting.units, posting.cost) | ||
144 | else: | ||
145 | outgoing = _OutgoingTransfer(transfer_account, converted_amount.currency, None, None) | ||
146 | self._accumulate_outgoing(outgoing, converted_amount) | ||
147 | incoming_postings.append(posting) | ||
148 | inv.add_amount(converted_amount) | ||
149 | |||
150 | def _accumulate_outgoing(self, outgoing: _OutgoingTransfer, units: Amount) -> None: | ||
151 | current_amount = self._amounts_to_transfer.get(outgoing, None) | ||
152 | if current_amount: | ||
153 | self._amounts_to_transfer[outgoing] = amount.add(current_amount, units) | ||
154 | else: | ||
155 | self._amounts_to_transfer[outgoing] = units | ||
156 | |||
157 | def _report_error(self, message: str) -> None: | ||
158 | self._errors.append(TransferAccountError(self._entry.meta, message, self._entry)) | ||
159 | |||
160 | |||
161 | def split_entries_via_transfer_accounts( | ||
162 | entries: Entries, | ||
163 | options_map: Dict[str, Any], | ||
164 | config_str: Optional[str] = None) -> Tuple[Entries, List[TransferAccountError]]: | ||
165 | default_converted: Dict[str, bool] = {} | ||
166 | errors: List[TransferAccountError] = [] | ||
167 | for entry in entries: | ||
168 | if isinstance(entry, Open): | ||
169 | if not entry.meta: | ||
170 | continue | ||
171 | transfer_converted = entry.meta.get(TRANSFER_CONVERTED_META, None) | ||
172 | if transfer_converted is None: | ||
173 | continue | ||
174 | if not isinstance(transfer_converted, bool): | ||
175 | errors.append(TransferAccountError( | ||
176 | entry.meta, | ||
177 | f'{TRANSFER_CONVERTED_META} must be a Boolean,' + | ||
178 | f' got {transfer_converted} instead', | ||
179 | entry)) | ||
180 | default_converted[entry.account] = transfer_converted | ||
181 | processed_entries: Entries = [] | ||
182 | for entry in entries: | ||
183 | if isinstance(entry, Transaction): | ||
184 | splitter = _Splitter(entry, processed_entries, errors, default_converted) | ||
185 | splitter.split() | ||
186 | else: | ||
187 | processed_entries.append(entry) | ||
188 | return processed_entries, errors | ||
diff --git a/beancount_extras_kris7t/plugins/transfer_accounts_test.py b/beancount_extras_kris7t/plugins/transfer_accounts_test.py new file mode 100644 index 0000000..0883522 --- /dev/null +++ b/beancount_extras_kris7t/plugins/transfer_accounts_test.py | |||
@@ -0,0 +1,628 @@ | |||
1 | __copyright__ = 'Copyright (c) 2020 Kristóf Marussy <kristof@marussy.com>' | ||
2 | __license__ = 'GNU GPLv2' | ||
3 | |||
4 | import unittest | ||
5 | |||
6 | from beancount import loader | ||
7 | from beancount.parser import cmptest | ||
8 | |||
9 | |||
10 | class TestTransferAccounts(cmptest.TestCase): | ||
11 | |||
12 | @loader.load_doc() | ||
13 | def test_same_currency(self, entries, _, __): | ||
14 | ''' | ||
15 | plugin "beancount_extras_kris7t.plugins.transfer_accounts" | ||
16 | |||
17 | 2020-01-01 open Assets:Checking | ||
18 | 2020-01-01 open Liabilities:CreditCard | ||
19 | 2020-01-01 open Expenses:Taxi | ||
20 | |||
21 | 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi | ||
22 | Assets:Checking -20 USD | ||
23 | Expenses:Taxi | ||
24 | transfer-account: Liabilities:CreditCard | ||
25 | transfer-date: 2020-03-10 | ||
26 | ''' | ||
27 | self.assertEqualEntries(''' | ||
28 | plugin "beancount_extras_kris7t.plugins.transfer_accounts" | ||
29 | |||
30 | 2020-01-01 open Assets:Checking | ||
31 | 2020-01-01 open Liabilities:CreditCard | ||
32 | 2020-01-01 open Expenses:Taxi | ||
33 | |||
34 | 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi | ||
35 | Assets:Checking -20 USD | ||
36 | Liabilities:CreditCard 20 USD | ||
37 | |||
38 | 2020-03-10 * "Taxi home from concert in Brooklyn" ^taxi | ||
39 | Expenses:Taxi 20 USD | ||
40 | Liabilities:CreditCard -20 USD | ||
41 | ''', entries) | ||
42 | |||
43 | @loader.load_doc() | ||
44 | def test_missing_date(self, entries, _, __): | ||
45 | ''' | ||
46 | plugin "beancount_extras_kris7t.plugins.transfer_accounts" | ||
47 | |||
48 | 2020-01-01 open Assets:Checking | ||
49 | 2020-01-01 open Liabilities:CreditCard | ||
50 | 2020-01-01 open Expenses:Taxi | ||
51 | |||
52 | 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi | ||
53 | Assets:Checking -20 USD | ||
54 | Expenses:Taxi | ||
55 | transfer-account: Liabilities:CreditCard | ||
56 | ''' | ||
57 | self.assertEqualEntries(''' | ||
58 | plugin "beancount_extras_kris7t.plugins.transfer_accounts" | ||
59 | |||
60 | 2020-01-01 open Assets:Checking | ||
61 | 2020-01-01 open Liabilities:CreditCard | ||
62 | 2020-01-01 open Expenses:Taxi | ||
63 | |||
64 | 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi | ||
65 | Assets:Checking -20 USD | ||
66 | Liabilities:CreditCard 20 USD | ||
67 | |||
68 | 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi | ||
69 | Expenses:Taxi 20 USD | ||
70 | Liabilities:CreditCard -20 USD | ||
71 | ''', entries) | ||
72 | |||
73 | @loader.load_doc(expect_errors=True) | ||
74 | def test_missing_account_with_date(self, _, errors, __): | ||
75 | ''' | ||
76 | plugin "beancount_extras_kris7t.plugins.transfer_accounts" | ||
77 | |||
78 | 2020-01-01 open Assets:Checking | ||
79 | 2020-01-01 open Liabilities:CreditCard | ||
80 | 2020-01-01 open Expenses:Taxi | ||
81 | |||
82 | 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi | ||
83 | Assets:Checking -20 USD | ||
84 | Expenses:Taxi | ||
85 | transfer-date: 2020-03-10 | ||
86 | ''' | ||
87 | self.assertRegex(errors[0].message, 'transfer-date was set but transfer-account was not') | ||
88 | |||
89 | @loader.load_doc(expect_errors=True) | ||
90 | def test_missing_account_with_conversion(self, _, errors, __): | ||
91 | ''' | ||
92 | plugin "beancount_extras_kris7t.plugins.transfer_accounts" | ||
93 | |||
94 | 2020-01-01 open Assets:Checking | ||
95 | 2020-01-01 open Liabilities:CreditCard | ||
96 | 2020-01-01 open Expenses:Taxi | ||
97 | |||
98 | 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi | ||
99 | Assets:Checking -25.60 CAD | ||
100 | Expenses:Taxi 20 USD @ 1.28 CAD | ||
101 | transfer-converted: FALSE | ||
102 | ''' | ||
103 | self.assertRegex( | ||
104 | errors[0].message, 'transfer-converted was set but transfer-account was not') | ||
105 | |||
106 | @loader.load_doc(expect_errors=True) | ||
107 | def test_invalid_account(self, _, errors, __): | ||
108 | ''' | ||
109 | plugin "beancount_extras_kris7t.plugins.transfer_accounts" | ||
110 | |||
111 | 2020-01-01 open Assets:Checking | ||
112 | 2020-01-01 open Liabilities:CreditCard | ||
113 | 2020-01-01 open Expenses:Taxi | ||
114 | |||
115 | 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi | ||
116 | Assets:Checking -20 USD | ||
117 | Expenses:Taxi | ||
118 | transfer-account: 2020-03-10 | ||
119 | ''' | ||
120 | self.assertRegex(errors[0].message, 'transfer-account must be a string.*') | ||
121 | |||
122 | @loader.load_doc(expect_errors=True) | ||
123 | def test_invalid_date(self, _, errors, __): | ||
124 | ''' | ||
125 | plugin "beancount_extras_kris7t.plugins.transfer_accounts" | ||
126 | |||
127 | 2020-01-01 open Assets:Checking | ||
128 | 2020-01-01 open Liabilities:CreditCard | ||
129 | 2020-01-01 open Expenses:Taxi | ||
130 | |||
131 | 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi | ||
132 | Assets:Checking -20 USD | ||
133 | Expenses:Taxi | ||
134 | transfer-account: Liabilities:CreditCard | ||
135 | transfer-date: "Ides of March" | ||
136 | ''' | ||
137 | self.assertRegex(errors[0].message, 'transfer-date must be a date.*') | ||
138 | |||
139 | @loader.load_doc(expect_errors=True) | ||
140 | def test_invalid_conversion(self, _, errors, __): | ||
141 | ''' | ||
142 | plugin "beancount_extras_kris7t.plugins.transfer_accounts" | ||
143 | |||
144 | 2020-01-01 open Assets:Checking | ||
145 | 2020-01-01 open Liabilities:CreditCard | ||
146 | 2020-01-01 open Expenses:Taxi | ||
147 | |||
148 | 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi | ||
149 | Assets:Checking -25.60 CAD | ||
150 | Expenses:Taxi 20 USD @ 1.28 CAD | ||
151 | transfer-account: Liabilities:CreditCard | ||
152 | transfer-converted: "indeed" | ||
153 | ''' | ||
154 | self.assertRegex(errors[0].message, 'transfer-converted must be a Boolean.*') | ||
155 | |||
156 | @loader.load_doc(expect_errors=True) | ||
157 | def test_invalid_account_conversion(self, _, errors, __): | ||
158 | ''' | ||
159 | plugin "beancount_extras_kris7t.plugins.transfer_accounts" | ||
160 | |||
161 | 2020-01-01 open Liabilities:CreditCard | ||
162 | transfer-converted: "Indeed" | ||
163 | ''' | ||
164 | self.assertRegex(errors[0].message, 'transfer-converted must be a Boolean.*') | ||
165 | |||
166 | @loader.load_doc(expect_errors=True) | ||
167 | def test_redundant_conversion(self, _, errors, __): | ||
168 | ''' | ||
169 | plugin "beancount_extras_kris7t.plugins.transfer_accounts" | ||
170 | |||
171 | 2020-01-01 open Assets:Checking | ||
172 | 2020-01-01 open Liabilities:CreditCard | ||
173 | 2020-01-01 open Expenses:Taxi | ||
174 | |||
175 | 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi | ||
176 | Assets:Checking -20 USD | ||
177 | Expenses:Taxi | ||
178 | transfer-account: Liabilities:CreditCard | ||
179 | transfer-converted: TRUE | ||
180 | ''' | ||
181 | self.assertRegex( | ||
182 | errors[0].message, 'transfer-converted was set, but there is no conversion.*') | ||
183 | |||
184 | @loader.load_doc() | ||
185 | def test_converted_price_false(self, entries, _, __): | ||
186 | ''' | ||
187 | plugin "beancount_extras_kris7t.plugins.transfer_accounts" | ||
188 | |||
189 | 2020-01-01 open Assets:Checking | ||
190 | 2020-01-01 open Liabilities:CreditCard | ||
191 | 2020-01-01 open Expenses:Taxi | ||
192 | |||
193 | 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi | ||
194 | Assets:Checking -25.60 CAD | ||
195 | Expenses:Taxi 20 USD @ 1.28 CAD | ||
196 | transfer-account: Liabilities:CreditCard | ||
197 | transfer-date: 2020-03-10 | ||
198 | transfer-converted: FALSE | ||
199 | ''' | ||
200 | self.assertEqualEntries(''' | ||
201 | plugin "beancount_extras_kris7t.plugins.transfer_accounts" | ||
202 | |||
203 | 2020-01-01 open Assets:Checking | ||
204 | 2020-01-01 open Liabilities:CreditCard | ||
205 | 2020-01-01 open Expenses:Taxi | ||
206 | |||
207 | 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi | ||
208 | Assets:Checking -25.60 CAD | ||
209 | Liabilities:CreditCard 25.60 CAD | ||
210 | |||
211 | 2020-03-10 * "Taxi home from concert in Brooklyn" ^taxi | ||
212 | Expenses:Taxi 20 USD @ 1.28 CAD | ||
213 | Liabilities:CreditCard -25.60 CAD | ||
214 | ''', entries) | ||
215 | |||
216 | @loader.load_doc() | ||
217 | def test_converted_price_true(self, entries, _, __): | ||
218 | ''' | ||
219 | plugin "beancount_extras_kris7t.plugins.transfer_accounts" | ||
220 | |||
221 | 2020-01-01 open Assets:Checking | ||
222 | 2020-01-01 open Liabilities:CreditCard | ||
223 | 2020-01-01 open Expenses:Taxi | ||
224 | |||
225 | 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi | ||
226 | Assets:Checking -25.60 CAD | ||
227 | Expenses:Taxi 20 USD @ 1.28 CAD | ||
228 | transfer-account: Liabilities:CreditCard | ||
229 | transfer-date: 2020-03-10 | ||
230 | transfer-converted: TRUE | ||
231 | ''' | ||
232 | self.assertEqualEntries(''' | ||
233 | plugin "beancount_extras_kris7t.plugins.transfer_accounts" | ||
234 | |||
235 | 2020-01-01 open Assets:Checking | ||
236 | 2020-01-01 open Liabilities:CreditCard | ||
237 | 2020-01-01 open Expenses:Taxi | ||
238 | |||
239 | 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi | ||
240 | Assets:Checking -25.60 CAD | ||
241 | Liabilities:CreditCard 20 USD @ 1.28 CAD | ||
242 | |||
243 | 2020-03-10 * "Taxi home from concert in Brooklyn" ^taxi | ||
244 | Expenses:Taxi 20 USD | ||
245 | Liabilities:CreditCard -20 USD | ||
246 | ''', entries) | ||
247 | |||
248 | @loader.load_doc() | ||
249 | def test_converted_price_default(self, entries, _, __): | ||
250 | ''' | ||
251 | plugin "beancount_extras_kris7t.plugins.transfer_accounts" | ||
252 | |||
253 | 2020-01-01 open Assets:Checking | ||
254 | 2020-01-01 open Liabilities:CreditCard | ||
255 | 2020-01-01 open Expenses:Taxi | ||
256 | |||
257 | 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi | ||
258 | Assets:Checking -25.60 CAD | ||
259 | Expenses:Taxi 20 USD @ 1.28 CAD | ||
260 | transfer-account: Liabilities:CreditCard | ||
261 | transfer-date: 2020-03-10 | ||
262 | ''' | ||
263 | self.assertEqualEntries(''' | ||
264 | plugin "beancount_extras_kris7t.plugins.transfer_accounts" | ||
265 | |||
266 | 2020-01-01 open Assets:Checking | ||
267 | 2020-01-01 open Liabilities:CreditCard | ||
268 | 2020-01-01 open Expenses:Taxi | ||
269 | |||
270 | 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi | ||
271 | Assets:Checking -25.60 CAD | ||
272 | Liabilities:CreditCard 20 USD @ 1.28 CAD | ||
273 | |||
274 | 2020-03-10 * "Taxi home from concert in Brooklyn" ^taxi | ||
275 | Expenses:Taxi 20 USD | ||
276 | Liabilities:CreditCard -20 USD | ||
277 | ''', entries) | ||
278 | |||
279 | @loader.load_doc() | ||
280 | def test_converted_price_account_false(self, entries, _, __): | ||
281 | ''' | ||
282 | plugin "beancount_extras_kris7t.plugins.transfer_accounts" | ||
283 | |||
284 | 2020-01-01 open Assets:Checking | ||
285 | 2020-01-01 open Liabilities:CreditCard | ||
286 | transfer-converted: FALSE | ||
287 | 2020-01-01 open Expenses:Taxi | ||
288 | |||
289 | 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi | ||
290 | Assets:Checking -25.60 CAD | ||
291 | Expenses:Taxi 20 USD @ 1.28 CAD | ||
292 | transfer-account: Liabilities:CreditCard | ||
293 | transfer-date: 2020-03-10 | ||
294 | ''' | ||
295 | self.assertEqualEntries(''' | ||
296 | plugin "beancount_extras_kris7t.plugins.transfer_accounts" | ||
297 | |||
298 | 2020-01-01 open Assets:Checking | ||
299 | 2020-01-01 open Liabilities:CreditCard | ||
300 | 2020-01-01 open Expenses:Taxi | ||
301 | |||
302 | 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi | ||
303 | Assets:Checking -25.60 CAD | ||
304 | Liabilities:CreditCard 25.60 CAD | ||
305 | |||
306 | 2020-03-10 * "Taxi home from concert in Brooklyn" ^taxi | ||
307 | Expenses:Taxi 20 USD @ 1.28 CAD | ||
308 | Liabilities:CreditCard -25.60 CAD | ||
309 | ''', entries) | ||
310 | |||
311 | @loader.load_doc() | ||
312 | def test_converted_price_account_true(self, entries, _, __): | ||
313 | ''' | ||
314 | plugin "beancount_extras_kris7t.plugins.transfer_accounts" | ||
315 | |||
316 | 2020-01-01 open Assets:Checking | ||
317 | 2020-01-01 open Liabilities:CreditCard | ||
318 | transfer-converted: TRUE | ||
319 | 2020-01-01 open Expenses:Taxi | ||
320 | |||
321 | 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi | ||
322 | Assets:Checking -25.60 CAD | ||
323 | Expenses:Taxi 20 USD @ 1.28 CAD | ||
324 | transfer-account: Liabilities:CreditCard | ||
325 | transfer-date: 2020-03-10 | ||
326 | ''' | ||
327 | self.assertEqualEntries(''' | ||
328 | plugin "beancount_extras_kris7t.plugins.transfer_accounts" | ||
329 | |||
330 | 2020-01-01 open Assets:Checking | ||
331 | 2020-01-01 open Liabilities:CreditCard | ||
332 | 2020-01-01 open Expenses:Taxi | ||
333 | |||
334 | 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi | ||
335 | Assets:Checking -25.60 CAD | ||
336 | Liabilities:CreditCard 20 USD @ 1.28 CAD | ||
337 | |||
338 | 2020-03-10 * "Taxi home from concert in Brooklyn" ^taxi | ||
339 | Expenses:Taxi 20 USD | ||
340 | Liabilities:CreditCard -20 USD | ||
341 | ''', entries) | ||
342 | |||
343 | @loader.load_doc() | ||
344 | def test_converted_cost_false(self, entries, _, __): | ||
345 | ''' | ||
346 | plugin "beancount_extras_kris7t.plugins.transfer_accounts" | ||
347 | |||
348 | 2020-01-01 open Assets:Checking | ||
349 | 2020-01-01 open Liabilities:CreditCard | ||
350 | 2020-01-01 open Expenses:Taxi | ||
351 | |||
352 | 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi | ||
353 | Assets:Checking -25.60 CAD | ||
354 | Expenses:Taxi 20 USD {1.28 CAD} | ||
355 | transfer-account: Liabilities:CreditCard | ||
356 | transfer-date: 2020-03-10 | ||
357 | transfer-converted: FALSE | ||
358 | ''' | ||
359 | self.assertEqualEntries(''' | ||
360 | plugin "beancount_extras_kris7t.plugins.transfer_accounts" | ||
361 | |||
362 | 2020-01-01 open Assets:Checking | ||
363 | 2020-01-01 open Liabilities:CreditCard | ||
364 | 2020-01-01 open Expenses:Taxi | ||
365 | |||
366 | 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi | ||
367 | Assets:Checking -25.60 CAD | ||
368 | Liabilities:CreditCard 25.60 CAD | ||
369 | |||
370 | 2020-03-10 * "Taxi home from concert in Brooklyn" ^taxi | ||
371 | Expenses:Taxi 20 USD {1.28 CAD, 2020-03-15} | ||
372 | Liabilities:CreditCard -25.60 CAD | ||
373 | ''', entries) | ||
374 | |||
375 | @loader.load_doc() | ||
376 | def test_converted_cost_true(self, entries, _, __): | ||
377 | ''' | ||
378 | plugin "beancount_extras_kris7t.plugins.transfer_accounts" | ||
379 | |||
380 | 2020-01-01 open Assets:Checking | ||
381 | 2020-01-01 open Liabilities:CreditCard | ||
382 | 2020-01-01 open Expenses:Taxi | ||
383 | |||
384 | 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi | ||
385 | Assets:Checking -25.60 CAD | ||
386 | Expenses:Taxi 20 USD {1.28 CAD} | ||
387 | transfer-account: Liabilities:CreditCard | ||
388 | transfer-date: 2020-03-10 | ||
389 | transfer-converted: TRUE | ||
390 | ''' | ||
391 | self.assertEqualEntries(''' | ||
392 | plugin "beancount_extras_kris7t.plugins.transfer_accounts" | ||
393 | |||
394 | 2020-01-01 open Assets:Checking | ||
395 | 2020-01-01 open Liabilities:CreditCard | ||
396 | 2020-01-01 open Expenses:Taxi | ||
397 | |||
398 | 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi | ||
399 | Assets:Checking -25.60 CAD | ||
400 | Liabilities:CreditCard 20 USD {1.28 CAD, 2020-03-15} | ||
401 | |||
402 | 2020-03-10 * "Taxi home from concert in Brooklyn" ^taxi | ||
403 | Expenses:Taxi 20 USD {1.28 CAD, 2020-03-15} | ||
404 | Liabilities:CreditCard -20 USD {1.28 CAD, 2020-03-15} | ||
405 | ''', entries) | ||
406 | |||
407 | @loader.load_doc() | ||
408 | def test_converted_cost_and_price_false(self, entries, _, __): | ||
409 | ''' | ||
410 | plugin "beancount_extras_kris7t.plugins.transfer_accounts" | ||
411 | |||
412 | 2020-01-01 open Assets:Checking | ||
413 | 2020-01-01 open Liabilities:CreditCard | ||
414 | 2020-01-01 open Expenses:Taxi | ||
415 | |||
416 | 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi | ||
417 | Assets:Checking -25.60 CAD | ||
418 | Expenses:Taxi 20 USD {1.28 CAD} @ 1.30 CAD | ||
419 | transfer-account: Liabilities:CreditCard | ||
420 | transfer-date: 2020-03-10 | ||
421 | transfer-converted: FALSE | ||
422 | ''' | ||
423 | self.assertEqualEntries(''' | ||
424 | plugin "beancount_extras_kris7t.plugins.transfer_accounts" | ||
425 | |||
426 | 2020-01-01 open Assets:Checking | ||
427 | 2020-01-01 open Liabilities:CreditCard | ||
428 | 2020-01-01 open Expenses:Taxi | ||
429 | |||
430 | 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi | ||
431 | Assets:Checking -25.60 CAD | ||
432 | Liabilities:CreditCard 25.60 CAD | ||
433 | |||
434 | 2020-03-10 * "Taxi home from concert in Brooklyn" ^taxi | ||
435 | Expenses:Taxi 20 USD {1.28 CAD, 2020-03-15} @ 1.30 CAD | ||
436 | Liabilities:CreditCard -25.60 CAD | ||
437 | ''', entries) | ||
438 | |||
439 | @loader.load_doc() | ||
440 | def test_converted_cost_and_price_true(self, entries, _, __): | ||
441 | ''' | ||
442 | plugin "beancount_extras_kris7t.plugins.transfer_accounts" | ||
443 | |||
444 | 2020-01-01 open Assets:Checking | ||
445 | 2020-01-01 open Liabilities:CreditCard | ||
446 | 2020-01-01 open Expenses:Taxi | ||
447 | |||
448 | 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi | ||
449 | Assets:Checking -25.60 CAD | ||
450 | Expenses:Taxi 20 USD {1.28 CAD} @ 1.30 CAD | ||
451 | transfer-account: Liabilities:CreditCard | ||
452 | transfer-date: 2020-03-10 | ||
453 | transfer-converted: TRUE | ||
454 | ''' | ||
455 | self.assertEqualEntries(''' | ||
456 | plugin "beancount_extras_kris7t.plugins.transfer_accounts" | ||
457 | |||
458 | 2020-01-01 open Assets:Checking | ||
459 | 2020-01-01 open Liabilities:CreditCard | ||
460 | 2020-01-01 open Expenses:Taxi | ||
461 | |||
462 | 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi | ||
463 | Assets:Checking -25.60 CAD | ||
464 | Liabilities:CreditCard 20 USD {1.28 CAD, 2020-03-15} @ 1.30 CAD | ||
465 | |||
466 | 2020-03-10 * "Taxi home from concert in Brooklyn" ^taxi | ||
467 | Expenses:Taxi 20 USD {1.28 CAD, 2020-03-15} | ||
468 | Liabilities:CreditCard -20 USD {1.28 CAD, 2020-03-15} | ||
469 | ''', entries) | ||
470 | |||
471 | @loader.load_doc() | ||
472 | def test_multiple_separate(self, entries, _, __): | ||
473 | ''' | ||
474 | plugin "beancount_extras_kris7t.plugins.transfer_accounts" | ||
475 | |||
476 | 2020-01-01 open Assets:Checking | ||
477 | 2020-01-01 open Liabilities:CreditCard | ||
478 | 2020-01-01 open Expenses:Taxi | ||
479 | 2020-01-01 open Expenses:Food | ||
480 | |||
481 | 2020-03-15 * "Night out in Brooklyn" ^taxi | ||
482 | Assets:Checking -45 USD | ||
483 | Expenses:Taxi 20 USD | ||
484 | transfer-account: Liabilities:CreditCard | ||
485 | transfer-date: 2020-03-10 | ||
486 | Expenses:Food 25 USD | ||
487 | transfer-account: Liabilities:CreditCard | ||
488 | transfer-date: 2020-03-12 | ||
489 | ''' | ||
490 | self.assertEqualEntries(''' | ||
491 | plugin "beancount_extras_kris7t.plugins.transfer_accounts" | ||
492 | |||
493 | 2020-01-01 open Assets:Checking | ||
494 | 2020-01-01 open Liabilities:CreditCard | ||
495 | 2020-01-01 open Expenses:Taxi | ||
496 | 2020-01-01 open Expenses:Food | ||
497 | |||
498 | 2020-03-15 * "Night out in Brooklyn" ^taxi | ||
499 | Assets:Checking -45 USD | ||
500 | Liabilities:CreditCard 45 USD | ||
501 | |||
502 | 2020-03-10 * "Night out in Brooklyn" ^taxi | ||
503 | Expenses:Taxi 20 USD | ||
504 | Liabilities:CreditCard -20 USD | ||
505 | |||
506 | 2020-03-12 * "Night out in Brooklyn" ^taxi | ||
507 | Expenses:Food 25 USD | ||
508 | Liabilities:CreditCard -25 USD | ||
509 | ''', entries) | ||
510 | |||
511 | @loader.load_doc() | ||
512 | def test_multiple_merge(self, entries, _, __): | ||
513 | ''' | ||
514 | plugin "beancount_extras_kris7t.plugins.transfer_accounts" | ||
515 | |||
516 | 2020-01-01 open Assets:Checking | ||
517 | 2020-01-01 open Liabilities:CreditCard | ||
518 | 2020-01-01 open Expenses:Taxi | ||
519 | 2020-01-01 open Expenses:Food | ||
520 | |||
521 | 2020-03-15 * "Night out in Brooklyn" ^taxi | ||
522 | Assets:Checking -45 USD | ||
523 | Expenses:Taxi 20 USD | ||
524 | transfer-account: Liabilities:CreditCard | ||
525 | transfer-date: 2020-03-10 | ||
526 | Expenses:Food 25 USD | ||
527 | transfer-account: Liabilities:CreditCard | ||
528 | transfer-date: 2020-03-10 | ||
529 | ''' | ||
530 | self.assertEqualEntries(''' | ||
531 | plugin "beancount_extras_kris7t.plugins.transfer_accounts" | ||
532 | |||
533 | 2020-01-01 open Assets:Checking | ||
534 | 2020-01-01 open Liabilities:CreditCard | ||
535 | 2020-01-01 open Expenses:Taxi | ||
536 | 2020-01-01 open Expenses:Food | ||
537 | |||
538 | 2020-03-15 * "Night out in Brooklyn" ^taxi | ||
539 | Assets:Checking -45 USD | ||
540 | Liabilities:CreditCard 45 USD | ||
541 | |||
542 | 2020-03-10 * "Night out in Brooklyn" ^taxi | ||
543 | Expenses:Taxi 20 USD | ||
544 | Expenses:Food 25 USD | ||
545 | Liabilities:CreditCard -45 USD | ||
546 | ''', entries) | ||
547 | |||
548 | @loader.load_doc() | ||
549 | def test_multiple_currencies_merge_converted_false(self, entries, _, __): | ||
550 | ''' | ||
551 | plugin "beancount_extras_kris7t.plugins.transfer_accounts" | ||
552 | |||
553 | 2020-01-01 open Assets:Checking | ||
554 | 2020-01-01 open Liabilities:CreditCard | ||
555 | 2020-01-01 open Expenses:Taxi | ||
556 | 2020-01-01 open Expenses:Food | ||
557 | |||
558 | 2020-03-15 * "Night out in Brooklyn" ^taxi | ||
559 | Assets:Checking -50.60 CAD | ||
560 | Expenses:Taxi 20 USD @ 1.28 CAD | ||
561 | transfer-account: Liabilities:CreditCard | ||
562 | transfer-date: 2020-03-10 | ||
563 | transfer-converted: FALSE | ||
564 | Expenses:Food 25 CAD | ||
565 | transfer-account: Liabilities:CreditCard | ||
566 | transfer-date: 2020-03-10 | ||
567 | ''' | ||
568 | self.assertEqualEntries(''' | ||
569 | plugin "beancount_extras_kris7t.plugins.transfer_accounts" | ||
570 | |||
571 | 2020-01-01 open Assets:Checking | ||
572 | 2020-01-01 open Liabilities:CreditCard | ||
573 | 2020-01-01 open Expenses:Taxi | ||
574 | 2020-01-01 open Expenses:Food | ||
575 | |||
576 | 2020-03-15 * "Night out in Brooklyn" ^taxi | ||
577 | Assets:Checking -50.60 CAD | ||
578 | Liabilities:CreditCard 50.60 CAD | ||
579 | |||
580 | 2020-03-10 * "Night out in Brooklyn" ^taxi | ||
581 | Expenses:Taxi 20 USD @ 1.28 CAD | ||
582 | Expenses:Food 25 CAD | ||
583 | Liabilities:CreditCard -50.60 CAD | ||
584 | ''', entries) | ||
585 | |||
586 | @loader.load_doc() | ||
587 | def test_multiple_currencies_merge_converted_true(self, entries, _, __): | ||
588 | ''' | ||
589 | plugin "beancount_extras_kris7t.plugins.transfer_accounts" | ||
590 | |||
591 | 2020-01-01 open Assets:Checking | ||
592 | 2020-01-01 open Liabilities:CreditCard | ||
593 | 2020-01-01 open Expenses:Taxi | ||
594 | 2020-01-01 open Expenses:Food | ||
595 | |||
596 | 2020-03-15 * "Night out in Brooklyn" ^taxi | ||
597 | Assets:Checking -50.60 CAD | ||
598 | Expenses:Taxi 20 USD @ 1.28 CAD | ||
599 | transfer-account: Liabilities:CreditCard | ||
600 | transfer-date: 2020-03-10 | ||
601 | transfer-converted: TRUE | ||
602 | Expenses:Food 25 CAD | ||
603 | transfer-account: Liabilities:CreditCard | ||
604 | transfer-date: 2020-03-10 | ||
605 | ''' | ||
606 | self.assertEqualEntries(''' | ||
607 | plugin "beancount_extras_kris7t.plugins.transfer_accounts" | ||
608 | |||
609 | 2020-01-01 open Assets:Checking | ||
610 | 2020-01-01 open Liabilities:CreditCard | ||
611 | 2020-01-01 open Expenses:Taxi | ||
612 | 2020-01-01 open Expenses:Food | ||
613 | |||
614 | 2020-03-15 * "Night out in Brooklyn" ^taxi | ||
615 | Assets:Checking -50.60 CAD | ||
616 | Liabilities:CreditCard 20 USD @ 1.28 CAD | ||
617 | Liabilities:CreditCard 25 CAD | ||
618 | |||
619 | 2020-03-10 * "Night out in Brooklyn" ^taxi | ||
620 | Expenses:Taxi 20 USD | ||
621 | Expenses:Food 25 CAD | ||
622 | Liabilities:CreditCard -20 USD | ||
623 | Liabilities:CreditCard -25 CAD | ||
624 | ''', entries) | ||
625 | |||
626 | |||
627 | if __name__ == '__main__': | ||
628 | unittest.main() | ||
diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..a98d7fb --- /dev/null +++ b/mypy.ini | |||
@@ -0,0 +1,14 @@ | |||
1 | [mypy] | ||
2 | check_untyped_defs = True | ||
3 | ignore_errors = False | ||
4 | ignore_missing_imports = True | ||
5 | strict_optional = True | ||
6 | warn_unused_ignores = True | ||
7 | warn_redundant_casts = True | ||
8 | warn_unused_configs = True | ||
9 | |||
10 | [mypy-beancount.*] | ||
11 | ignore_missing_imports = True | ||
12 | |||
13 | [mypy-pdfminer.*] | ||
14 | ignore_missing_imports = True | ||
diff --git a/poetry.lock b/poetry.lock index c371393..434a066 100644 --- a/poetry.lock +++ b/poetry.lock | |||
@@ -5,7 +5,6 @@ description = "Atomic file writes." | |||
5 | category = "main" | 5 | category = "main" |
6 | optional = false | 6 | optional = false |
7 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" | 7 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" |
8 | marker = "sys_platform == \"win32\"" | ||
9 | 8 | ||
10 | [[package]] | 9 | [[package]] |
11 | name = "attrs" | 10 | name = "attrs" |
@@ -16,10 +15,10 @@ optional = false | |||
16 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" | 15 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" |
17 | 16 | ||
18 | [package.extras] | 17 | [package.extras] |
19 | dev = ["coverage (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "furo", "sphinx", "pre-commit"] | 18 | dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope-interface", "furo", "sphinx", "pre-commit"] |
20 | docs = ["furo", "sphinx", "zope.interface"] | 19 | docs = ["furo", "sphinx", "zope-interface"] |
21 | tests = ["coverage (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] | 20 | tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope-interface"] |
22 | tests_no_zope = ["coverage (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six"] | 21 | tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six"] |
23 | 22 | ||
24 | [[package]] | 23 | [[package]] |
25 | name = "beancount" | 24 | name = "beancount" |
@@ -40,9 +39,8 @@ python-dateutil = "*" | |||
40 | python-magic = "*" | 39 | python-magic = "*" |
41 | 40 | ||
42 | [package.source] | 41 | [package.source] |
43 | url = "beancount" | ||
44 | reference = "" | ||
45 | type = "directory" | 42 | type = "directory" |
43 | url = "beancount" | ||
46 | 44 | ||
47 | [[package]] | 45 | [[package]] |
48 | name = "beautifulsoup4" | 46 | name = "beautifulsoup4" |
@@ -52,18 +50,16 @@ category = "main" | |||
52 | optional = false | 50 | optional = false |
53 | python-versions = "*" | 51 | python-versions = "*" |
54 | 52 | ||
53 | [package.dependencies] | ||
54 | soupsieve = {version = ">1.2", markers = "python_version >= \"3.0\""} | ||
55 | |||
55 | [package.extras] | 56 | [package.extras] |
56 | html5lib = ["html5lib"] | 57 | html5lib = ["html5lib"] |
57 | lxml = ["lxml"] | 58 | lxml = ["lxml"] |
58 | 59 | ||
59 | [package.dependencies] | ||
60 | [package.dependencies.soupsieve] | ||
61 | version = ">1.2" | ||
62 | python = ">=3.0" | ||
63 | |||
64 | [[package]] | 60 | [[package]] |
65 | name = "cachetools" | 61 | name = "cachetools" |
66 | version = "4.2.0" | 62 | version = "4.2.1" |
67 | description = "Extensible memoizing collections and decorators" | 63 | description = "Extensible memoizing collections and decorators" |
68 | category = "main" | 64 | category = "main" |
69 | optional = false | 65 | optional = false |
@@ -78,6 +74,17 @@ optional = false | |||
78 | python-versions = "*" | 74 | python-versions = "*" |
79 | 75 | ||
80 | [[package]] | 76 | [[package]] |
77 | name = "cffi" | ||
78 | version = "1.14.4" | ||
79 | description = "Foreign Function Interface for Python calling C code." | ||
80 | category = "main" | ||
81 | optional = false | ||
82 | python-versions = "*" | ||
83 | |||
84 | [package.dependencies] | ||
85 | pycparser = "*" | ||
86 | |||
87 | [[package]] | ||
81 | name = "chardet" | 88 | name = "chardet" |
82 | version = "4.0.0" | 89 | version = "4.0.0" |
83 | description = "Universal encoding detector for Python 2 and 3" | 90 | description = "Universal encoding detector for Python 2 and 3" |
@@ -92,7 +99,38 @@ description = "Cross-platform colored terminal text." | |||
92 | category = "main" | 99 | category = "main" |
93 | optional = false | 100 | optional = false |
94 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" | 101 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" |
95 | marker = "sys_platform == \"win32\"" | 102 | |
103 | [[package]] | ||
104 | name = "cryptography" | ||
105 | version = "3.3.1" | ||
106 | description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." | ||
107 | category = "main" | ||
108 | optional = false | ||
109 | python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*" | ||
110 | |||
111 | [package.dependencies] | ||
112 | cffi = ">=1.12" | ||
113 | six = ">=1.4.1" | ||
114 | |||
115 | [package.extras] | ||
116 | docs = ["sphinx (>=1.6.5,!=1.8.0,!=3.1.0,!=3.1.1)", "sphinx-rtd-theme"] | ||
117 | docstest = ["doc8", "pyenchant (>=1.6.11)", "twine (>=1.12.0)", "sphinxcontrib-spelling (>=4.0.1)"] | ||
118 | pep8test = ["black", "flake8", "flake8-import-order", "pep8-naming"] | ||
119 | ssh = ["bcrypt (>=3.1.5)"] | ||
120 | test = ["pytest (>=3.6.0,!=3.9.0,!=3.9.1,!=3.9.2)", "pretend", "iso8601", "pytz", "hypothesis (>=1.11.4,!=3.79.2)"] | ||
121 | |||
122 | [[package]] | ||
123 | name = "flake8" | ||
124 | version = "3.8.4" | ||
125 | description = "the modular source code checker: pep8 pyflakes and co" | ||
126 | category = "dev" | ||
127 | optional = false | ||
128 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" | ||
129 | |||
130 | [package.dependencies] | ||
131 | mccabe = ">=0.6.0,<0.7.0" | ||
132 | pycodestyle = ">=2.6.0a1,<2.7.0" | ||
133 | pyflakes = ">=2.2.0,<2.3.0" | ||
96 | 134 | ||
97 | [[package]] | 135 | [[package]] |
98 | name = "google-api-core" | 136 | name = "google-api-core" |
@@ -102,20 +140,19 @@ category = "main" | |||
102 | optional = false | 140 | optional = false |
103 | python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*" | 141 | python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*" |
104 | 142 | ||
105 | [package.extras] | ||
106 | grpc = ["grpcio (>=1.29.0,<2.0dev)"] | ||
107 | grpcgcp = ["grpcio-gcp (>=0.2.2)"] | ||
108 | grpcio-gcp = ["grpcio-gcp (>=0.2.2)"] | ||
109 | |||
110 | [package.dependencies] | 143 | [package.dependencies] |
111 | google-auth = ">=1.21.1,<2.0dev" | 144 | google-auth = ">=1.21.1,<2.0dev" |
112 | googleapis-common-protos = ">=1.6.0,<2.0dev" | 145 | googleapis-common-protos = ">=1.6.0,<2.0dev" |
113 | protobuf = ">=3.12.0" | 146 | protobuf = ">=3.12.0" |
114 | pytz = "*" | 147 | pytz = "*" |
115 | requests = ">=2.18.0,<3.0.0dev" | 148 | requests = ">=2.18.0,<3.0.0dev" |
116 | setuptools = ">=40.3.0" | ||
117 | six = ">=1.13.0" | 149 | six = ">=1.13.0" |
118 | 150 | ||
151 | [package.extras] | ||
152 | grpc = ["grpcio (>=1.29.0,<2.0dev)"] | ||
153 | grpcgcp = ["grpcio-gcp (>=0.2.2)"] | ||
154 | grpcio-gcp = ["grpcio-gcp (>=0.2.2)"] | ||
155 | |||
119 | [[package]] | 156 | [[package]] |
120 | name = "google-api-python-client" | 157 | name = "google-api-python-client" |
121 | version = "1.12.8" | 158 | version = "1.12.8" |
@@ -140,18 +177,14 @@ category = "main" | |||
140 | optional = false | 177 | optional = false |
141 | python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*" | 178 | python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*" |
142 | 179 | ||
143 | [package.extras] | ||
144 | aiohttp = ["aiohttp (>=3.6.2,<4.0.0dev)"] | ||
145 | |||
146 | [package.dependencies] | 180 | [package.dependencies] |
147 | cachetools = ">=2.0.0,<5.0" | 181 | cachetools = ">=2.0.0,<5.0" |
148 | pyasn1-modules = ">=0.2.1" | 182 | pyasn1-modules = ">=0.2.1" |
149 | setuptools = ">=40.3.0" | 183 | rsa = {version = ">=3.1.4,<5", markers = "python_version >= \"3.6\""} |
150 | six = ">=1.9.0" | 184 | six = ">=1.9.0" |
151 | 185 | ||
152 | [package.dependencies.rsa] | 186 | [package.extras] |
153 | version = ">=3.1.4,<5" | 187 | aiohttp = ["aiohttp (>=3.6.2,<4.0.0dev)"] |
154 | python = ">=3.6" | ||
155 | 188 | ||
156 | [[package]] | 189 | [[package]] |
157 | name = "google-auth-httplib2" | 190 | name = "google-auth-httplib2" |
@@ -174,12 +207,12 @@ category = "main" | |||
174 | optional = false | 207 | optional = false |
175 | python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" | 208 | python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" |
176 | 209 | ||
177 | [package.extras] | ||
178 | grpc = ["grpcio (>=1.0.0)"] | ||
179 | |||
180 | [package.dependencies] | 210 | [package.dependencies] |
181 | protobuf = ">=3.6.0" | 211 | protobuf = ">=3.6.0" |
182 | 212 | ||
213 | [package.extras] | ||
214 | grpc = ["grpcio (>=1.0.0)"] | ||
215 | |||
183 | [[package]] | 216 | [[package]] |
184 | name = "httplib2" | 217 | name = "httplib2" |
185 | version = "0.18.1" | 218 | version = "0.18.1" |
@@ -205,6 +238,49 @@ optional = false | |||
205 | python-versions = "*" | 238 | python-versions = "*" |
206 | 239 | ||
207 | [[package]] | 240 | [[package]] |
241 | name = "jeepney" | ||
242 | version = "0.6.0" | ||
243 | description = "Low-level, pure Python DBus protocol wrapper." | ||
244 | category = "main" | ||
245 | optional = true | ||
246 | python-versions = ">=3.6" | ||
247 | |||
248 | [package.extras] | ||
249 | test = ["pytest", "pytest-trio", "pytest-asyncio", "testpath", "trio"] | ||
250 | |||
251 | [[package]] | ||
252 | name = "mccabe" | ||
253 | version = "0.6.1" | ||
254 | description = "McCabe checker, plugin for flake8" | ||
255 | category = "dev" | ||
256 | optional = false | ||
257 | python-versions = "*" | ||
258 | |||
259 | [[package]] | ||
260 | name = "mypy" | ||
261 | version = "0.800" | ||
262 | description = "Optional static typing for Python" | ||
263 | category = "dev" | ||
264 | optional = false | ||
265 | python-versions = ">=3.5" | ||
266 | |||
267 | [package.dependencies] | ||
268 | mypy-extensions = ">=0.4.3,<0.5.0" | ||
269 | typed-ast = ">=1.4.0,<1.5.0" | ||
270 | typing-extensions = ">=3.7.4" | ||
271 | |||
272 | [package.extras] | ||
273 | dmypy = ["psutil (>=4.0)"] | ||
274 | |||
275 | [[package]] | ||
276 | name = "mypy-extensions" | ||
277 | version = "0.4.3" | ||
278 | description = "Experimental type system extensions for programs checked with the mypy typechecker." | ||
279 | category = "dev" | ||
280 | optional = false | ||
281 | python-versions = "*" | ||
282 | |||
283 | [[package]] | ||
208 | name = "packaging" | 284 | name = "packaging" |
209 | version = "20.8" | 285 | version = "20.8" |
210 | description = "Core utilities for Python packages" | 286 | description = "Core utilities for Python packages" |
@@ -216,6 +292,23 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" | |||
216 | pyparsing = ">=2.0.2" | 292 | pyparsing = ">=2.0.2" |
217 | 293 | ||
218 | [[package]] | 294 | [[package]] |
295 | name = "pdfminer-six" | ||
296 | version = "20201018" | ||
297 | description = "PDF parser and analyzer" | ||
298 | category = "main" | ||
299 | optional = false | ||
300 | python-versions = ">=3.4" | ||
301 | |||
302 | [package.dependencies] | ||
303 | chardet = {version = "*", markers = "python_version > \"3.0\""} | ||
304 | cryptography = "*" | ||
305 | sortedcontainers = "*" | ||
306 | |||
307 | [package.extras] | ||
308 | dev = ["nose", "tox"] | ||
309 | docs = ["sphinx", "sphinx-argparse"] | ||
310 | |||
311 | [[package]] | ||
219 | name = "pluggy" | 312 | name = "pluggy" |
220 | version = "0.13.1" | 313 | version = "0.13.1" |
221 | description = "plugin and hook calling mechanisms for python" | 314 | description = "plugin and hook calling mechanisms for python" |
@@ -273,6 +366,30 @@ python-versions = "*" | |||
273 | pyasn1 = ">=0.4.6,<0.5.0" | 366 | pyasn1 = ">=0.4.6,<0.5.0" |
274 | 367 | ||
275 | [[package]] | 368 | [[package]] |
369 | name = "pycodestyle" | ||
370 | version = "2.6.0" | ||
371 | description = "Python style guide checker" | ||
372 | category = "dev" | ||
373 | optional = false | ||
374 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" | ||
375 | |||
376 | [[package]] | ||
377 | name = "pycparser" | ||
378 | version = "2.20" | ||
379 | description = "C parser in Python" | ||
380 | category = "main" | ||
381 | optional = false | ||
382 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" | ||
383 | |||
384 | [[package]] | ||
385 | name = "pyflakes" | ||
386 | version = "2.2.0" | ||
387 | description = "passive checker of Python programs" | ||
388 | category = "dev" | ||
389 | optional = false | ||
390 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" | ||
391 | |||
392 | [[package]] | ||
276 | name = "pyparsing" | 393 | name = "pyparsing" |
277 | version = "2.4.7" | 394 | version = "2.4.7" |
278 | description = "Python parsing module" | 395 | description = "Python parsing module" |
@@ -288,19 +405,19 @@ category = "main" | |||
288 | optional = false | 405 | optional = false |
289 | python-versions = ">=3.6" | 406 | python-versions = ">=3.6" |
290 | 407 | ||
291 | [package.extras] | ||
292 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] | ||
293 | |||
294 | [package.dependencies] | 408 | [package.dependencies] |
295 | atomicwrites = ">=1.0" | 409 | atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} |
296 | attrs = ">=19.2.0" | 410 | attrs = ">=19.2.0" |
297 | colorama = "*" | 411 | colorama = {version = "*", markers = "sys_platform == \"win32\""} |
298 | iniconfig = "*" | 412 | iniconfig = "*" |
299 | packaging = "*" | 413 | packaging = "*" |
300 | pluggy = ">=0.12,<1.0.0a1" | 414 | pluggy = ">=0.12,<1.0.0a1" |
301 | py = ">=1.8.2" | 415 | py = ">=1.8.2" |
302 | toml = "*" | 416 | toml = "*" |
303 | 417 | ||
418 | [package.extras] | ||
419 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] | ||
420 | |||
304 | [[package]] | 421 | [[package]] |
305 | name = "python-dateutil" | 422 | name = "python-dateutil" |
306 | version = "2.8.1" | 423 | version = "2.8.1" |
@@ -336,16 +453,16 @@ category = "main" | |||
336 | optional = false | 453 | optional = false |
337 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" | 454 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" |
338 | 455 | ||
339 | [package.extras] | ||
340 | security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"] | ||
341 | socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7)", "win-inet-pton"] | ||
342 | |||
343 | [package.dependencies] | 456 | [package.dependencies] |
344 | certifi = ">=2017.4.17" | 457 | certifi = ">=2017.4.17" |
345 | chardet = ">=3.0.2,<5" | 458 | chardet = ">=3.0.2,<5" |
346 | idna = ">=2.5,<3" | 459 | idna = ">=2.5,<3" |
347 | urllib3 = ">=1.21.1,<1.27" | 460 | urllib3 = ">=1.21.1,<1.27" |
348 | 461 | ||
462 | [package.extras] | ||
463 | security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"] | ||
464 | socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] | ||
465 | |||
349 | [[package]] | 466 | [[package]] |
350 | name = "rsa" | 467 | name = "rsa" |
351 | version = "4.7" | 468 | version = "4.7" |
@@ -353,12 +470,23 @@ description = "Pure-Python RSA implementation" | |||
353 | category = "main" | 470 | category = "main" |
354 | optional = false | 471 | optional = false |
355 | python-versions = ">=3.5, <4" | 472 | python-versions = ">=3.5, <4" |
356 | marker = "python_version >= \"3.6\"" | ||
357 | 473 | ||
358 | [package.dependencies] | 474 | [package.dependencies] |
359 | pyasn1 = ">=0.1.3" | 475 | pyasn1 = ">=0.1.3" |
360 | 476 | ||
361 | [[package]] | 477 | [[package]] |
478 | name = "secretstorage" | ||
479 | version = "3.3.0" | ||
480 | description = "Python bindings to FreeDesktop.org Secret Service API" | ||
481 | category = "main" | ||
482 | optional = true | ||
483 | python-versions = ">=3.6" | ||
484 | |||
485 | [package.dependencies] | ||
486 | cryptography = ">=2.0" | ||
487 | jeepney = ">=0.6" | ||
488 | |||
489 | [[package]] | ||
362 | name = "six" | 490 | name = "six" |
363 | version = "1.15.0" | 491 | version = "1.15.0" |
364 | description = "Python 2 and 3 compatibility utilities" | 492 | description = "Python 2 and 3 compatibility utilities" |
@@ -367,13 +495,20 @@ optional = false | |||
367 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" | 495 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" |
368 | 496 | ||
369 | [[package]] | 497 | [[package]] |
498 | name = "sortedcontainers" | ||
499 | version = "2.3.0" | ||
500 | description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" | ||
501 | category = "main" | ||
502 | optional = false | ||
503 | python-versions = "*" | ||
504 | |||
505 | [[package]] | ||
370 | name = "soupsieve" | 506 | name = "soupsieve" |
371 | version = "2.1" | 507 | version = "2.1" |
372 | description = "A modern CSS selector implementation for Beautiful Soup." | 508 | description = "A modern CSS selector implementation for Beautiful Soup." |
373 | category = "main" | 509 | category = "main" |
374 | optional = false | 510 | optional = false |
375 | python-versions = ">=3.5" | 511 | python-versions = ">=3.5" |
376 | marker = "python_version >= \"3.0\"" | ||
377 | 512 | ||
378 | [[package]] | 513 | [[package]] |
379 | name = "toml" | 514 | name = "toml" |
@@ -384,6 +519,22 @@ optional = false | |||
384 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" | 519 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" |
385 | 520 | ||
386 | [[package]] | 521 | [[package]] |
522 | name = "typed-ast" | ||
523 | version = "1.4.2" | ||
524 | description = "a fork of Python 2 and 3 ast modules with type comment support" | ||
525 | category = "dev" | ||
526 | optional = false | ||
527 | python-versions = "*" | ||
528 | |||
529 | [[package]] | ||
530 | name = "typing-extensions" | ||
531 | version = "3.7.4.3" | ||
532 | description = "Backported and Experimental Type Hints for Python 3.5+" | ||
533 | category = "dev" | ||
534 | optional = false | ||
535 | python-versions = "*" | ||
536 | |||
537 | [[package]] | ||
387 | name = "uritemplate" | 538 | name = "uritemplate" |
388 | version = "3.0.1" | 539 | version = "3.0.1" |
389 | description = "URI templates" | 540 | description = "URI templates" |
@@ -402,12 +553,15 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" | |||
402 | [package.extras] | 553 | [package.extras] |
403 | brotli = ["brotlipy (>=0.6.0)"] | 554 | brotli = ["brotlipy (>=0.6.0)"] |
404 | secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] | 555 | secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] |
405 | socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7,<2.0)"] | 556 | socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] |
557 | |||
558 | [extras] | ||
559 | transferwise = ["requests", "SecretStorage"] | ||
406 | 560 | ||
407 | [metadata] | 561 | [metadata] |
408 | lock-version = "1.0" | 562 | lock-version = "1.1" |
409 | python-versions = "^3.9" | 563 | python-versions = "^3.9" |
410 | content-hash = "cf1dc1359aafb7aaf42ede043b1d28b1bcb215cff5851d7d9f5fe2012bb796d1" | 564 | content-hash = "605e14048928ef9c444b6e0b8d4722909cf65ba42094bffa743bb1881c61d898" |
411 | 565 | ||
412 | [metadata.files] | 566 | [metadata.files] |
413 | atomicwrites = [ | 567 | atomicwrites = [ |
@@ -425,13 +579,51 @@ beautifulsoup4 = [ | |||
425 | {file = "beautifulsoup4-4.9.3.tar.gz", hash = "sha256:84729e322ad1d5b4d25f805bfa05b902dd96450f43842c4e99067d5e1369eb25"}, | 579 | {file = "beautifulsoup4-4.9.3.tar.gz", hash = "sha256:84729e322ad1d5b4d25f805bfa05b902dd96450f43842c4e99067d5e1369eb25"}, |
426 | ] | 580 | ] |
427 | cachetools = [ | 581 | cachetools = [ |
428 | {file = "cachetools-4.2.0-py3-none-any.whl", hash = "sha256:c6b07a6ded8c78bf36730b3dc452dfff7d95f2a12a2fed856b1a0cb13ca78c61"}, | 582 | {file = "cachetools-4.2.1-py3-none-any.whl", hash = "sha256:1d9d5f567be80f7c07d765e21b814326d78c61eb0c3a637dffc0e5d1796cb2e2"}, |
429 | {file = "cachetools-4.2.0.tar.gz", hash = "sha256:3796e1de094f0eaca982441c92ce96c68c89cced4cd97721ab297ea4b16db90e"}, | 583 | {file = "cachetools-4.2.1.tar.gz", hash = "sha256:f469e29e7aa4cff64d8de4aad95ce76de8ea1125a16c68e0d93f65c3c3dc92e9"}, |
430 | ] | 584 | ] |
431 | certifi = [ | 585 | certifi = [ |
432 | {file = "certifi-2020.12.5-py2.py3-none-any.whl", hash = "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"}, | 586 | {file = "certifi-2020.12.5-py2.py3-none-any.whl", hash = "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"}, |
433 | {file = "certifi-2020.12.5.tar.gz", hash = "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c"}, | 587 | {file = "certifi-2020.12.5.tar.gz", hash = "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c"}, |
434 | ] | 588 | ] |
589 | cffi = [ | ||
590 | {file = "cffi-1.14.4-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:ebb253464a5d0482b191274f1c8bf00e33f7e0b9c66405fbffc61ed2c839c775"}, | ||
591 | {file = "cffi-1.14.4-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:2c24d61263f511551f740d1a065eb0212db1dbbbbd241db758f5244281590c06"}, | ||
592 | {file = "cffi-1.14.4-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:9f7a31251289b2ab6d4012f6e83e58bc3b96bd151f5b5262467f4bb6b34a7c26"}, | ||
593 | {file = "cffi-1.14.4-cp27-cp27m-win32.whl", hash = "sha256:5cf4be6c304ad0b6602f5c4e90e2f59b47653ac1ed9c662ed379fe48a8f26b0c"}, | ||
594 | {file = "cffi-1.14.4-cp27-cp27m-win_amd64.whl", hash = "sha256:f60567825f791c6f8a592f3c6e3bd93dd2934e3f9dac189308426bd76b00ef3b"}, | ||
595 | {file = "cffi-1.14.4-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:c6332685306b6417a91b1ff9fae889b3ba65c2292d64bd9245c093b1b284809d"}, | ||
596 | {file = "cffi-1.14.4-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:d9efd8b7a3ef378dd61a1e77367f1924375befc2eba06168b6ebfa903a5e59ca"}, | ||
597 | {file = "cffi-1.14.4-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:51a8b381b16ddd370178a65360ebe15fbc1c71cf6f584613a7ea08bfad946698"}, | ||
598 | {file = "cffi-1.14.4-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:1d2c4994f515e5b485fd6d3a73d05526aa0fcf248eb135996b088d25dfa1865b"}, | ||
599 | {file = "cffi-1.14.4-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:af5c59122a011049aad5dd87424b8e65a80e4a6477419c0c1015f73fb5ea0293"}, | ||
600 | {file = "cffi-1.14.4-cp35-cp35m-win32.whl", hash = "sha256:594234691ac0e9b770aee9fcdb8fa02c22e43e5c619456efd0d6c2bf276f3eb2"}, | ||
601 | {file = "cffi-1.14.4-cp35-cp35m-win_amd64.whl", hash = "sha256:64081b3f8f6f3c3de6191ec89d7dc6c86a8a43911f7ecb422c60e90c70be41c7"}, | ||
602 | {file = "cffi-1.14.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f803eaa94c2fcda012c047e62bc7a51b0bdabda1cad7a92a522694ea2d76e49f"}, | ||
603 | {file = "cffi-1.14.4-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:105abaf8a6075dc96c1fe5ae7aae073f4696f2905fde6aeada4c9d2926752362"}, | ||
604 | {file = "cffi-1.14.4-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0638c3ae1a0edfb77c6765d487fee624d2b1ee1bdfeffc1f0b58c64d149e7eec"}, | ||
605 | {file = "cffi-1.14.4-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:7c6b1dece89874d9541fc974917b631406233ea0440d0bdfbb8e03bf39a49b3b"}, | ||
606 | {file = "cffi-1.14.4-cp36-cp36m-win32.whl", hash = "sha256:155136b51fd733fa94e1c2ea5211dcd4c8879869008fc811648f16541bf99668"}, | ||
607 | {file = "cffi-1.14.4-cp36-cp36m-win_amd64.whl", hash = "sha256:6bc25fc545a6b3d57b5f8618e59fc13d3a3a68431e8ca5fd4c13241cd70d0009"}, | ||
608 | {file = "cffi-1.14.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a7711edca4dcef1a75257b50a2fbfe92a65187c47dab5a0f1b9b332c5919a3fb"}, | ||
609 | {file = "cffi-1.14.4-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:00e28066507bfc3fe865a31f325c8391a1ac2916219340f87dfad602c3e48e5d"}, | ||
610 | {file = "cffi-1.14.4-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:798caa2a2384b1cbe8a2a139d80734c9db54f9cc155c99d7cc92441a23871c03"}, | ||
611 | {file = "cffi-1.14.4-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:a5ed8c05548b54b998b9498753fb9cadbfd92ee88e884641377d8a8b291bcc01"}, | ||
612 | {file = "cffi-1.14.4-cp37-cp37m-win32.whl", hash = "sha256:00a1ba5e2e95684448de9b89888ccd02c98d512064b4cb987d48f4b40aa0421e"}, | ||
613 | {file = "cffi-1.14.4-cp37-cp37m-win_amd64.whl", hash = "sha256:9cc46bc107224ff5b6d04369e7c595acb700c3613ad7bcf2e2012f62ece80c35"}, | ||
614 | {file = "cffi-1.14.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:df5169c4396adc04f9b0a05f13c074df878b6052430e03f50e68adf3a57aa28d"}, | ||
615 | {file = "cffi-1.14.4-cp38-cp38-manylinux1_i686.whl", hash = "sha256:9ffb888f19d54a4d4dfd4b3f29bc2c16aa4972f1c2ab9c4ab09b8ab8685b9c2b"}, | ||
616 | {file = "cffi-1.14.4-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8d6603078baf4e11edc4168a514c5ce5b3ba6e3e9c374298cb88437957960a53"}, | ||
617 | {file = "cffi-1.14.4-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:d5ff0621c88ce83a28a10d2ce719b2ee85635e85c515f12bac99a95306da4b2e"}, | ||
618 | {file = "cffi-1.14.4-cp38-cp38-win32.whl", hash = "sha256:b4e248d1087abf9f4c10f3c398896c87ce82a9856494a7155823eb45a892395d"}, | ||
619 | {file = "cffi-1.14.4-cp38-cp38-win_amd64.whl", hash = "sha256:ec80dc47f54e6e9a78181ce05feb71a0353854cc26999db963695f950b5fb375"}, | ||
620 | {file = "cffi-1.14.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:840793c68105fe031f34d6a086eaea153a0cd5c491cde82a74b420edd0a2b909"}, | ||
621 | {file = "cffi-1.14.4-cp39-cp39-manylinux1_i686.whl", hash = "sha256:b18e0a9ef57d2b41f5c68beefa32317d286c3d6ac0484efd10d6e07491bb95dd"}, | ||
622 | {file = "cffi-1.14.4-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:045d792900a75e8b1e1b0ab6787dd733a8190ffcf80e8c8ceb2fb10a29ff238a"}, | ||
623 | {file = "cffi-1.14.4-cp39-cp39-win32.whl", hash = "sha256:ba4e9e0ae13fc41c6b23299545e5ef73055213e466bd107953e4a013a5ddd7e3"}, | ||
624 | {file = "cffi-1.14.4-cp39-cp39-win_amd64.whl", hash = "sha256:f032b34669220030f905152045dfa27741ce1a6db3324a5bc0b96b6c7420c87b"}, | ||
625 | {file = "cffi-1.14.4.tar.gz", hash = "sha256:1a465cbe98a7fd391d47dce4b8f7e5b921e6cd805ef421d04f5f66ba8f06086c"}, | ||
626 | ] | ||
435 | chardet = [ | 627 | chardet = [ |
436 | {file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"}, | 628 | {file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"}, |
437 | {file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"}, | 629 | {file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"}, |
@@ -440,6 +632,26 @@ colorama = [ | |||
440 | {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, | 632 | {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, |
441 | {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, | 633 | {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, |
442 | ] | 634 | ] |
635 | cryptography = [ | ||
636 | {file = "cryptography-3.3.1-cp27-cp27m-macosx_10_10_x86_64.whl", hash = "sha256:c366df0401d1ec4e548bebe8f91d55ebcc0ec3137900d214dd7aac8427ef3030"}, | ||
637 | {file = "cryptography-3.3.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:9f6b0492d111b43de5f70052e24c1f0951cb9e6022188ebcb1cc3a3d301469b0"}, | ||
638 | {file = "cryptography-3.3.1-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:a69bd3c68b98298f490e84519b954335154917eaab52cf582fa2c5c7efc6e812"}, | ||
639 | {file = "cryptography-3.3.1-cp27-cp27m-win32.whl", hash = "sha256:84ef7a0c10c24a7773163f917f1cb6b4444597efd505a8aed0a22e8c4780f27e"}, | ||
640 | {file = "cryptography-3.3.1-cp27-cp27m-win_amd64.whl", hash = "sha256:594a1db4511bc4d960571536abe21b4e5c3003e8750ab8365fafce71c5d86901"}, | ||
641 | {file = "cryptography-3.3.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:0003a52a123602e1acee177dc90dd201f9bb1e73f24a070db7d36c588e8f5c7d"}, | ||
642 | {file = "cryptography-3.3.1-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:83d9d2dfec70364a74f4e7c70ad04d3ca2e6a08b703606993407bf46b97868c5"}, | ||
643 | {file = "cryptography-3.3.1-cp36-abi3-macosx_10_10_x86_64.whl", hash = "sha256:dc42f645f8f3a489c3dd416730a514e7a91a59510ddaadc09d04224c098d3302"}, | ||
644 | {file = "cryptography-3.3.1-cp36-abi3-manylinux1_x86_64.whl", hash = "sha256:788a3c9942df5e4371c199d10383f44a105d67d401fb4304178020142f020244"}, | ||
645 | {file = "cryptography-3.3.1-cp36-abi3-manylinux2010_x86_64.whl", hash = "sha256:69e836c9e5ff4373ce6d3ab311c1a2eed274793083858d3cd4c7d12ce20d5f9c"}, | ||
646 | {file = "cryptography-3.3.1-cp36-abi3-manylinux2014_aarch64.whl", hash = "sha256:9e21301f7a1e7c03dbea73e8602905a4ebba641547a462b26dd03451e5769e7c"}, | ||
647 | {file = "cryptography-3.3.1-cp36-abi3-win32.whl", hash = "sha256:b4890d5fb9b7a23e3bf8abf5a8a7da8e228f1e97dc96b30b95685df840b6914a"}, | ||
648 | {file = "cryptography-3.3.1-cp36-abi3-win_amd64.whl", hash = "sha256:0e85aaae861d0485eb5a79d33226dd6248d2a9f133b81532c8f5aae37de10ff7"}, | ||
649 | {file = "cryptography-3.3.1.tar.gz", hash = "sha256:7e177e4bea2de937a584b13645cab32f25e3d96fc0bc4a4cf99c27dc77682be6"}, | ||
650 | ] | ||
651 | flake8 = [ | ||
652 | {file = "flake8-3.8.4-py2.py3-none-any.whl", hash = "sha256:749dbbd6bfd0cf1318af27bf97a14e28e5ff548ef8e5b1566ccfb25a11e7c839"}, | ||
653 | {file = "flake8-3.8.4.tar.gz", hash = "sha256:aadae8761ec651813c24be05c6f7b4680857ef6afaae4651a4eccaef97ce6c3b"}, | ||
654 | ] | ||
443 | google-api-core = [ | 655 | google-api-core = [ |
444 | {file = "google-api-core-1.25.0.tar.gz", hash = "sha256:d967beae8d8acdb88fb2f6f769e2ee0ee813042576a08891bded3b8e234150ae"}, | 656 | {file = "google-api-core-1.25.0.tar.gz", hash = "sha256:d967beae8d8acdb88fb2f6f769e2ee0ee813042576a08891bded3b8e234150ae"}, |
445 | {file = "google_api_core-1.25.0-py2.py3-none-any.whl", hash = "sha256:4656345cba9627ab1290eab51300a6397cc50370d99366133df1ae64b744e1eb"}, | 657 | {file = "google_api_core-1.25.0-py2.py3-none-any.whl", hash = "sha256:4656345cba9627ab1290eab51300a6397cc50370d99366133df1ae64b744e1eb"}, |
@@ -472,10 +684,50 @@ iniconfig = [ | |||
472 | {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, | 684 | {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, |
473 | {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, | 685 | {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, |
474 | ] | 686 | ] |
687 | jeepney = [ | ||
688 | {file = "jeepney-0.6.0-py3-none-any.whl", hash = "sha256:aec56c0eb1691a841795111e184e13cad504f7703b9a64f63020816afa79a8ae"}, | ||
689 | {file = "jeepney-0.6.0.tar.gz", hash = "sha256:7d59b6622675ca9e993a6bd38de845051d315f8b0c72cca3aef733a20b648657"}, | ||
690 | ] | ||
691 | mccabe = [ | ||
692 | {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, | ||
693 | {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, | ||
694 | ] | ||
695 | mypy = [ | ||
696 | {file = "mypy-0.800-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:e1c84c65ff6d69fb42958ece5b1255394714e0aac4df5ffe151bc4fe19c7600a"}, | ||
697 | {file = "mypy-0.800-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:947126195bfe4709c360e89b40114c6746ae248f04d379dca6f6ab677aa07641"}, | ||
698 | {file = "mypy-0.800-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:b95068a3ce3b50332c40e31a955653be245666a4bc7819d3c8898aa9fb9ea496"}, | ||
699 | {file = "mypy-0.800-cp35-cp35m-win_amd64.whl", hash = "sha256:ca7ad5aed210841f1e77f5f2f7d725b62c78fa77519312042c719ed2ab937876"}, | ||
700 | {file = "mypy-0.800-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:e32b7b282c4ed4e378bba8b8dfa08e1cfa6f6574067ef22f86bee5b1039de0c9"}, | ||
701 | {file = "mypy-0.800-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:e497a544391f733eca922fdcb326d19e894789cd4ff61d48b4b195776476c5cf"}, | ||
702 | {file = "mypy-0.800-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:5615785d3e2f4f03ab7697983d82c4b98af5c321614f51b8f1034eb9ebe48363"}, | ||
703 | {file = "mypy-0.800-cp36-cp36m-win_amd64.whl", hash = "sha256:2b216eacca0ec0ee124af9429bfd858d5619a0725ee5f88057e6e076f9eb1a7b"}, | ||
704 | {file = "mypy-0.800-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e3b8432f8df19e3c11235c4563a7250666dc9aa7cdda58d21b4177b20256ca9f"}, | ||
705 | {file = "mypy-0.800-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:d16c54b0dffb861dc6318a8730952265876d90c5101085a4bc56913e8521ba19"}, | ||
706 | {file = "mypy-0.800-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:0d2fc8beb99cd88f2d7e20d69131353053fbecea17904ee6f0348759302c52fa"}, | ||
707 | {file = "mypy-0.800-cp37-cp37m-win_amd64.whl", hash = "sha256:aa9d4901f3ee1a986a3a79fe079ffbf7f999478c281376f48faa31daaa814e86"}, | ||
708 | {file = "mypy-0.800-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:319ee5c248a7c3f94477f92a729b7ab06bf8a6d04447ef3aa8c9ba2aa47c6dcf"}, | ||
709 | {file = "mypy-0.800-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:74f5aa50d0866bc6fb8e213441c41e466c86678c800700b87b012ed11c0a13e0"}, | ||
710 | {file = "mypy-0.800-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:a301da58d566aca05f8f449403c710c50a9860782148332322decf73a603280b"}, | ||
711 | {file = "mypy-0.800-cp38-cp38-win_amd64.whl", hash = "sha256:b9150db14a48a8fa114189bfe49baccdff89da8c6639c2717750c7ae62316738"}, | ||
712 | {file = "mypy-0.800-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f5fdf935a46aa20aa937f2478480ebf4be9186e98e49cc3843af9a5795a49a25"}, | ||
713 | {file = "mypy-0.800-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:6f8425fecd2ba6007e526209bb985ce7f49ed0d2ac1cc1a44f243380a06a84fb"}, | ||
714 | {file = "mypy-0.800-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:5ff616787122774f510caeb7b980542a7cc2222be3f00837a304ea85cd56e488"}, | ||
715 | {file = "mypy-0.800-cp39-cp39-win_amd64.whl", hash = "sha256:90b6f46dc2181d74f80617deca611925d7e63007cf416397358aa42efb593e07"}, | ||
716 | {file = "mypy-0.800-py3-none-any.whl", hash = "sha256:3e0c159a7853e3521e3f582adb1f3eac66d0b0639d434278e2867af3a8c62653"}, | ||
717 | {file = "mypy-0.800.tar.gz", hash = "sha256:e0202e37756ed09daf4b0ba64ad2c245d357659e014c3f51d8cd0681ba66940a"}, | ||
718 | ] | ||
719 | mypy-extensions = [ | ||
720 | {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, | ||
721 | {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, | ||
722 | ] | ||
475 | packaging = [ | 723 | packaging = [ |
476 | {file = "packaging-20.8-py2.py3-none-any.whl", hash = "sha256:24e0da08660a87484d1602c30bb4902d74816b6985b93de36926f5bc95741858"}, | 724 | {file = "packaging-20.8-py2.py3-none-any.whl", hash = "sha256:24e0da08660a87484d1602c30bb4902d74816b6985b93de36926f5bc95741858"}, |
477 | {file = "packaging-20.8.tar.gz", hash = "sha256:78598185a7008a470d64526a8059de9aaa449238f280fc9eb6b13ba6c4109093"}, | 725 | {file = "packaging-20.8.tar.gz", hash = "sha256:78598185a7008a470d64526a8059de9aaa449238f280fc9eb6b13ba6c4109093"}, |
478 | ] | 726 | ] |
727 | pdfminer-six = [ | ||
728 | {file = "pdfminer.six-20201018-py3-none-any.whl", hash = "sha256:d78877ba8d8bf957f3bb636c4f73f4f6f30f56c461993877ac22c39c20837509"}, | ||
729 | {file = "pdfminer.six-20201018.tar.gz", hash = "sha256:b9aac0ebeafb21c08bf65f2039f4b2c5f78a3449d0a41df711d72445649e952a"}, | ||
730 | ] | ||
479 | pluggy = [ | 731 | pluggy = [ |
480 | {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, | 732 | {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, |
481 | {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, | 733 | {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, |
@@ -538,6 +790,18 @@ pyasn1-modules = [ | |||
538 | {file = "pyasn1_modules-0.2.8-py3.6.egg", hash = "sha256:cbac4bc38d117f2a49aeedec4407d23e8866ea4ac27ff2cf7fb3e5b570df19e0"}, | 790 | {file = "pyasn1_modules-0.2.8-py3.6.egg", hash = "sha256:cbac4bc38d117f2a49aeedec4407d23e8866ea4ac27ff2cf7fb3e5b570df19e0"}, |
539 | {file = "pyasn1_modules-0.2.8-py3.7.egg", hash = "sha256:c29a5e5cc7a3f05926aff34e097e84f8589cd790ce0ed41b67aed6857b26aafd"}, | 791 | {file = "pyasn1_modules-0.2.8-py3.7.egg", hash = "sha256:c29a5e5cc7a3f05926aff34e097e84f8589cd790ce0ed41b67aed6857b26aafd"}, |
540 | ] | 792 | ] |
793 | pycodestyle = [ | ||
794 | {file = "pycodestyle-2.6.0-py2.py3-none-any.whl", hash = "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367"}, | ||
795 | {file = "pycodestyle-2.6.0.tar.gz", hash = "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e"}, | ||
796 | ] | ||
797 | pycparser = [ | ||
798 | {file = "pycparser-2.20-py2.py3-none-any.whl", hash = "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705"}, | ||
799 | {file = "pycparser-2.20.tar.gz", hash = "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0"}, | ||
800 | ] | ||
801 | pyflakes = [ | ||
802 | {file = "pyflakes-2.2.0-py2.py3-none-any.whl", hash = "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92"}, | ||
803 | {file = "pyflakes-2.2.0.tar.gz", hash = "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"}, | ||
804 | ] | ||
541 | pyparsing = [ | 805 | pyparsing = [ |
542 | {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, | 806 | {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, |
543 | {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, | 807 | {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, |
@@ -566,10 +830,18 @@ rsa = [ | |||
566 | {file = "rsa-4.7-py3-none-any.whl", hash = "sha256:a8774e55b59fd9fc893b0d05e9bfc6f47081f46ff5b46f39ccf24631b7be356b"}, | 830 | {file = "rsa-4.7-py3-none-any.whl", hash = "sha256:a8774e55b59fd9fc893b0d05e9bfc6f47081f46ff5b46f39ccf24631b7be356b"}, |
567 | {file = "rsa-4.7.tar.gz", hash = "sha256:69805d6b69f56eb05b62daea3a7dbd7aa44324ad1306445e05da8060232d00f4"}, | 831 | {file = "rsa-4.7.tar.gz", hash = "sha256:69805d6b69f56eb05b62daea3a7dbd7aa44324ad1306445e05da8060232d00f4"}, |
568 | ] | 832 | ] |
833 | secretstorage = [ | ||
834 | {file = "SecretStorage-3.3.0-py3-none-any.whl", hash = "sha256:5c36f6537a523ec5f969ef9fad61c98eb9e017bc601d811e53aa25bece64892f"}, | ||
835 | {file = "SecretStorage-3.3.0.tar.gz", hash = "sha256:30cfdef28829dad64d6ea1ed08f8eff6aa115a77068926bcc9f5225d5a3246aa"}, | ||
836 | ] | ||
569 | six = [ | 837 | six = [ |
570 | {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"}, | 838 | {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"}, |
571 | {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"}, | 839 | {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"}, |
572 | ] | 840 | ] |
841 | sortedcontainers = [ | ||
842 | {file = "sortedcontainers-2.3.0-py2.py3-none-any.whl", hash = "sha256:37257a32add0a3ee490bb170b599e93095eed89a55da91fa9f48753ea12fd73f"}, | ||
843 | {file = "sortedcontainers-2.3.0.tar.gz", hash = "sha256:59cc937650cf60d677c16775597c89a960658a09cf7c1a668f86e1e4464b10a1"}, | ||
844 | ] | ||
573 | soupsieve = [ | 845 | soupsieve = [ |
574 | {file = "soupsieve-2.1-py3-none-any.whl", hash = "sha256:4bb21a6ee4707bf43b61230e80740e71bfe56e55d1f1f50924b087bb2975c851"}, | 846 | {file = "soupsieve-2.1-py3-none-any.whl", hash = "sha256:4bb21a6ee4707bf43b61230e80740e71bfe56e55d1f1f50924b087bb2975c851"}, |
575 | {file = "soupsieve-2.1.tar.gz", hash = "sha256:6dc52924dc0bc710a5d16794e6b3480b2c7c08b07729505feab2b2c16661ff6e"}, | 847 | {file = "soupsieve-2.1.tar.gz", hash = "sha256:6dc52924dc0bc710a5d16794e6b3480b2c7c08b07729505feab2b2c16661ff6e"}, |
@@ -578,6 +850,43 @@ toml = [ | |||
578 | {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, | 850 | {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, |
579 | {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, | 851 | {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, |
580 | ] | 852 | ] |
853 | typed-ast = [ | ||
854 | {file = "typed_ast-1.4.2-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:7703620125e4fb79b64aa52427ec192822e9f45d37d4b6625ab37ef403e1df70"}, | ||
855 | {file = "typed_ast-1.4.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:c9aadc4924d4b5799112837b226160428524a9a45f830e0d0f184b19e4090487"}, | ||
856 | {file = "typed_ast-1.4.2-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:9ec45db0c766f196ae629e509f059ff05fc3148f9ffd28f3cfe75d4afb485412"}, | ||
857 | {file = "typed_ast-1.4.2-cp35-cp35m-win32.whl", hash = "sha256:85f95aa97a35bdb2f2f7d10ec5bbdac0aeb9dafdaf88e17492da0504de2e6400"}, | ||
858 | {file = "typed_ast-1.4.2-cp35-cp35m-win_amd64.whl", hash = "sha256:9044ef2df88d7f33692ae3f18d3be63dec69c4fb1b5a4a9ac950f9b4ba571606"}, | ||
859 | {file = "typed_ast-1.4.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:c1c876fd795b36126f773db9cbb393f19808edd2637e00fd6caba0e25f2c7b64"}, | ||
860 | {file = "typed_ast-1.4.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:5dcfc2e264bd8a1db8b11a892bd1647154ce03eeba94b461effe68790d8b8e07"}, | ||
861 | {file = "typed_ast-1.4.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:8db0e856712f79c45956da0c9a40ca4246abc3485ae0d7ecc86a20f5e4c09abc"}, | ||
862 | {file = "typed_ast-1.4.2-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:d003156bb6a59cda9050e983441b7fa2487f7800d76bdc065566b7d728b4581a"}, | ||
863 | {file = "typed_ast-1.4.2-cp36-cp36m-win32.whl", hash = "sha256:4c790331247081ea7c632a76d5b2a265e6d325ecd3179d06e9cf8d46d90dd151"}, | ||
864 | {file = "typed_ast-1.4.2-cp36-cp36m-win_amd64.whl", hash = "sha256:d175297e9533d8d37437abc14e8a83cbc68af93cc9c1c59c2c292ec59a0697a3"}, | ||
865 | {file = "typed_ast-1.4.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cf54cfa843f297991b7388c281cb3855d911137223c6b6d2dd82a47ae5125a41"}, | ||
866 | {file = "typed_ast-1.4.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:b4fcdcfa302538f70929eb7b392f536a237cbe2ed9cba88e3bf5027b39f5f77f"}, | ||
867 | {file = "typed_ast-1.4.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:987f15737aba2ab5f3928c617ccf1ce412e2e321c77ab16ca5a293e7bbffd581"}, | ||
868 | {file = "typed_ast-1.4.2-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:37f48d46d733d57cc70fd5f30572d11ab8ed92da6e6b28e024e4a3edfb456e37"}, | ||
869 | {file = "typed_ast-1.4.2-cp37-cp37m-win32.whl", hash = "sha256:36d829b31ab67d6fcb30e185ec996e1f72b892255a745d3a82138c97d21ed1cd"}, | ||
870 | {file = "typed_ast-1.4.2-cp37-cp37m-win_amd64.whl", hash = "sha256:8368f83e93c7156ccd40e49a783a6a6850ca25b556c0fa0240ed0f659d2fe496"}, | ||
871 | {file = "typed_ast-1.4.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:963c80b583b0661918718b095e02303d8078950b26cc00b5e5ea9ababe0de1fc"}, | ||
872 | {file = "typed_ast-1.4.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e683e409e5c45d5c9082dc1daf13f6374300806240719f95dc783d1fc942af10"}, | ||
873 | {file = "typed_ast-1.4.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:84aa6223d71012c68d577c83f4e7db50d11d6b1399a9c779046d75e24bed74ea"}, | ||
874 | {file = "typed_ast-1.4.2-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:a38878a223bdd37c9709d07cd357bb79f4c760b29210e14ad0fb395294583787"}, | ||
875 | {file = "typed_ast-1.4.2-cp38-cp38-win32.whl", hash = "sha256:a2c927c49f2029291fbabd673d51a2180038f8cd5a5b2f290f78c4516be48be2"}, | ||
876 | {file = "typed_ast-1.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:c0c74e5579af4b977c8b932f40a5464764b2f86681327410aa028a22d2f54937"}, | ||
877 | {file = "typed_ast-1.4.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:07d49388d5bf7e863f7fa2f124b1b1d89d8aa0e2f7812faff0a5658c01c59aa1"}, | ||
878 | {file = "typed_ast-1.4.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:240296b27397e4e37874abb1df2a608a92df85cf3e2a04d0d4d61055c8305ba6"}, | ||
879 | {file = "typed_ast-1.4.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:d746a437cdbca200622385305aedd9aef68e8a645e385cc483bdc5e488f07166"}, | ||
880 | {file = "typed_ast-1.4.2-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:14bf1522cdee369e8f5581238edac09150c765ec1cb33615855889cf33dcb92d"}, | ||
881 | {file = "typed_ast-1.4.2-cp39-cp39-win32.whl", hash = "sha256:cc7b98bf58167b7f2db91a4327da24fb93368838eb84a44c472283778fc2446b"}, | ||
882 | {file = "typed_ast-1.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:7147e2a76c75f0f64c4319886e7639e490fee87c9d25cb1d4faef1d8cf83a440"}, | ||
883 | {file = "typed_ast-1.4.2.tar.gz", hash = "sha256:9fc0b3cb5d1720e7141d103cf4819aea239f7d136acf9ee4a69b047b7986175a"}, | ||
884 | ] | ||
885 | typing-extensions = [ | ||
886 | {file = "typing_extensions-3.7.4.3-py2-none-any.whl", hash = "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"}, | ||
887 | {file = "typing_extensions-3.7.4.3-py3-none-any.whl", hash = "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918"}, | ||
888 | {file = "typing_extensions-3.7.4.3.tar.gz", hash = "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c"}, | ||
889 | ] | ||
581 | uritemplate = [ | 890 | uritemplate = [ |
582 | {file = "uritemplate-3.0.1-py2.py3-none-any.whl", hash = "sha256:07620c3f3f8eed1f12600845892b0e036a2420acf513c53f7de0abd911a5894f"}, | 891 | {file = "uritemplate-3.0.1-py2.py3-none-any.whl", hash = "sha256:07620c3f3f8eed1f12600845892b0e036a2420acf513c53f7de0abd911a5894f"}, |
583 | {file = "uritemplate-3.0.1.tar.gz", hash = "sha256:5af8ad10cec94f215e3f48112de2022e1d5a37ed427fbd88652fa908f2ab7cae"}, | 892 | {file = "uritemplate-3.0.1.tar.gz", hash = "sha256:5af8ad10cec94f215e3f48112de2022e1d5a37ed427fbd88652fa908f2ab7cae"}, |
diff --git a/pyproject.toml b/pyproject.toml index da65f08..4f749a5 100644 --- a/pyproject.toml +++ b/pyproject.toml | |||
@@ -1,15 +1,23 @@ | |||
1 | [tool.poetry] | 1 | [tool.poetry] |
2 | name = "beancount-extras-kris7t" | 2 | name = "beancount-extras-kris7t" |
3 | version = "0.1.0" | 3 | version = "0.1.0" |
4 | description = "Miscellaneous plugins, importers and price sources for Beancount" | 4 | description = "Miscellaneous plugins and importers for Beancount" |
5 | authors = ["Kristóf Marussy <kristof@marussy.com>"] | 5 | authors = ["Kristóf Marussy <kristof@marussy.com>"] |
6 | license = "GPL-2.0-only" | 6 | license = "GPL-2.0-only" |
7 | 7 | ||
8 | [tool.poetry.dependencies] | 8 | [tool.poetry.dependencies] |
9 | python = "^3.9" | 9 | python = "^3.9" |
10 | beancount = { path = "beancount", develop = true } | 10 | beancount = { path = "beancount", develop = true } |
11 | "pdfminer.six" = "^20201018" | ||
12 | requests = { version = "^2.25.1", optional = true } | ||
13 | SecretStorage = { version = "^3.3.0", optional = true } | ||
11 | 14 | ||
12 | [tool.poetry.dev-dependencies] | 15 | [tool.poetry.dev-dependencies] |
16 | flake8 = "^3.8.4" | ||
17 | mypy = "^0.800" | ||
18 | |||
19 | [tool.poetry.extras] | ||
20 | transferwise = ["requests", "SecretStorage"] | ||
13 | 21 | ||
14 | [build-system] | 22 | [build-system] |
15 | requires = ["poetry>=0.12"] | 23 | requires = ["poetry>=0.12"] |