From b818d256e777a45433176b301415906576f47245 Mon Sep 17 00:00:00 2001 From: Gabriel Augendre Date: Fri, 8 May 2020 18:02:25 +0200 Subject: [PATCH] Delete file by default and add keep option + regroup decorators --- ofx_processor/processors/bpvf.py | 13 ++-- ofx_processor/processors/ce.py | 13 ++-- ofx_processor/processors/lcl.py | 11 ++-- ofx_processor/processors/revolut.py | 11 ++-- ofx_processor/utils/base_processor.py | 5 +- ofx_processor/utils/utils.py | 27 ++++++++- pyproject.toml | 2 +- tests/test_end_to_end.py | 87 +++++++++++++++++++-------- 8 files changed, 111 insertions(+), 58 deletions(-) diff --git a/ofx_processor/processors/bpvf.py b/ofx_processor/processors/bpvf.py index 2b403a3..1057b98 100644 --- a/ofx_processor/processors/bpvf.py +++ b/ofx_processor/processors/bpvf.py @@ -1,7 +1,5 @@ import re -import click - from ofx_processor.utils.base_ofx import OfxBaseLine, OfxBaseProcessor @@ -32,10 +30,9 @@ class BpvfLine(OfxBaseLine): class BpvfProcessor(OfxBaseProcessor): line_class = BpvfLine account_name = "bpvf" + command_name = "bpvf" - @staticmethod - @click.command("bpvf") - @click.argument("ofx_filename") - def main(ofx_filename): - """Import BPVF bank statement (OFX file).""" - BpvfProcessor(ofx_filename).push_to_ynab() + +def main(filename, keep): + """Import BPVF bank statement (OFX file).""" + BpvfProcessor(filename).push_to_ynab(keep) diff --git a/ofx_processor/processors/ce.py b/ofx_processor/processors/ce.py index 39daf8c..23b6f61 100644 --- a/ofx_processor/processors/ce.py +++ b/ofx_processor/processors/ce.py @@ -1,7 +1,5 @@ import re -import click - from ofx_processor.utils.base_ofx import OfxBaseProcessor, OfxBaseLine @@ -28,11 +26,10 @@ class CeLine(OfxBaseLine): class CeProcessor(OfxBaseProcessor): account_name = "ce" + command_name = "ce" line_class = CeLine - @staticmethod - @click.command("ce") - @click.argument("ofx_filename") - def main(ofx_filename): - """Import CE bank statement (OFX file).""" - CeProcessor(ofx_filename).push_to_ynab() + +def main(filename, keep): + """Import CE bank statement (OFX file).""" + CeProcessor(filename).push_to_ynab(keep) diff --git a/ofx_processor/processors/lcl.py b/ofx_processor/processors/lcl.py index d6bde92..e3be24f 100644 --- a/ofx_processor/processors/lcl.py +++ b/ofx_processor/processors/lcl.py @@ -12,6 +12,7 @@ class LclLine(OfxBaseLine): class LclProcessor(OfxBaseProcessor): line_class = LclLine account_name = "lcl" + command_name = "lcl" def parse_file(self): # The first line of this file needs to be removed. @@ -35,9 +36,7 @@ class LclProcessor(OfxBaseProcessor): return transactions - @staticmethod - @click.command("lcl") - @click.argument("ofx_filename") - def main(ofx_filename): - """Import LCL bank statement (OFX file).""" - LclProcessor(ofx_filename).push_to_ynab() + +def main(filename, keep): + """Import LCL bank statement (OFX file).""" + LclProcessor(filename).push_to_ynab(keep) diff --git a/ofx_processor/processors/revolut.py b/ofx_processor/processors/revolut.py index 20e30f4..c43ec8d 100644 --- a/ofx_processor/processors/revolut.py +++ b/ofx_processor/processors/revolut.py @@ -48,6 +48,7 @@ class RevolutLine(BaseLine): class RevolutProcessor(BaseProcessor): line_class = RevolutLine account_name = "revolut" + command_name = "revolut" def parse_file(self): try: @@ -58,9 +59,7 @@ class RevolutProcessor(BaseProcessor): click.secho("File not found", fg="red") sys.exit(1) - @staticmethod - @click.command("revolut") - @click.argument("csv_filename") - def main(csv_filename): - """Import Revolut bank statement (CSV file).""" - RevolutProcessor(csv_filename).push_to_ynab() + +def main(filename, keep): + """Import Revolut bank statement (CSV file).""" + RevolutProcessor(filename).push_to_ynab(keep) diff --git a/ofx_processor/utils/base_processor.py b/ofx_processor/utils/base_processor.py index 4b55606..d543076 100644 --- a/ofx_processor/utils/base_processor.py +++ b/ofx_processor/utils/base_processor.py @@ -1,3 +1,4 @@ +import os from collections import defaultdict import click @@ -54,10 +55,12 @@ class BaseProcessor: def parse_file(self): return [] # pragma: nocover - def push_to_ynab(self): + def push_to_ynab(self, keep=True): transactions = self.get_transactions() click.secho(f"Processed {len(transactions)} transactions total.", fg="blue") ynab.push_transactions(transactions, account=self.account_name) + if not keep: + os.unlink(self.filename) def get_transactions(self): return list(map(self._get_transaction, self.iterable)) diff --git a/ofx_processor/utils/utils.py b/ofx_processor/utils/utils.py index e6b72b8..b928617 100644 --- a/ofx_processor/utils/utils.py +++ b/ofx_processor/utils/utils.py @@ -14,8 +14,11 @@ def discover_processors(cli: click.Group): To be discovered, processors must: * Be in the `processors` package. * Declare a Processor class - * Declare a static main function in this class, - which must be a click command + * Declare a main function in the module, outside of the class. + The main function must not be a click command, decorators will be added on the fly. + The main function must accept two parameters: + * filename: str, containing the name of the file to process, as passed on the command line + * keep: boolean, whether to keep the file after processing it or not :param cli: The main CLI to add discovered processors to. """ @@ -29,7 +32,25 @@ def discover_processors(cli: click.Group): and "Base" not in item ): cls = getattr(module, item) - cli.add_command(cls.main) + assert hasattr( + module, "main" + ), "There must be a main function in the processor module." + assert hasattr( + cls, "command_name" + ), "You must add a command_name member to your processor class." + + # Apply default decorators + method = getattr(module, "main") + method = click.option( + "--keep/--no-keep", + help="Keep the file after processing it.", + default=False, + show_default=True, + )(method) + method = click.argument("filename")(method) + method = click.command(cls.command_name)(method) + + cli.add_command(method) class OrderedGroup(click.Group): diff --git a/pyproject.toml b/pyproject.toml index 2f305df..4eae2d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "ofx-processor" -version = "1.1.1" +version = "2.0.0" description = "Personal ofx processor" readme = "README.md" license = "GPL-3.0-or-later" diff --git a/tests/test_end_to_end.py b/tests/test_end_to_end.py index 05cc305..d3f0add 100644 --- a/tests/test_end_to_end.py +++ b/tests/test_end_to_end.py @@ -1,15 +1,12 @@ import json import os +import shutil import unittest from unittest import mock from unittest.mock import call from click.testing import CliRunner -from ofx_processor.processors.bpvf import BpvfProcessor -from ofx_processor.processors.ce import CeProcessor -from ofx_processor.processors.lcl import LclProcessor -from ofx_processor.processors.revolut import RevolutProcessor from ofx_processor.utils import utils from ofx_processor.utils import ynab from ofx_processor.utils.ynab import config @@ -21,25 +18,19 @@ class UtilsTestCase(unittest.TestCase): ofx_processor.main.cli because it tests import time stuff. """ - @staticmethod - def test_discover_processors(): - ce_main = CeProcessor.main - bpvf_main = BpvfProcessor.main - revolut_main = RevolutProcessor.main - lcl_main = LclProcessor.main + def test_discover_processors(self): + expected_names = ["config", "bpvf", "lcl", "ce", "revolut"] runner = CliRunner() with mock.patch("click.core.Group.add_command") as add_command: from ofx_processor.main import cli + calls = [call(config, name="config")] runner.invoke(cli, ["--help"]) - calls = [ - call(ce_main), - call(bpvf_main), - call(revolut_main), - call(lcl_main), - call(config, name="config"), - ] add_command.assert_has_calls(calls, any_order=True) + self.assertEqual( + set(map(lambda item: item.args[0].name, add_command.call_args_list)), + set(expected_names), + ) class ConfigTestCase(unittest.TestCase): @@ -83,7 +74,7 @@ class ConfigTestCase(unittest.TestCase): with mock.patch("ofx_processor.utils.ynab.DEFAULT_CONFIG_FILENAME", name): runner = CliRunner() result = runner.invoke( - self.cli, ["revolut", "tests/samples/revolut.csv"] + self.cli, ["revolut", "tests/samples/revolut.csv", "--keep"] ) expected_filename = ynab.get_config_file_name() self.assertIn("Error while parsing config file", result.output) @@ -107,7 +98,9 @@ class ConfigTestCase(unittest.TestCase): def test_missing_config_file(self, makedirs, edit): runner = CliRunner() with mock.patch("ofx_processor.utils.ynab.open", mock.mock_open()) as mock_file: - result = runner.invoke(self.cli, ["revolut", "tests/samples/revolut.csv"]) + result = runner.invoke( + self.cli, ["revolut", "tests/samples/revolut.csv", "--keep"] + ) mock_file.assert_called_once() makedirs.assert_called_once_with(ynab.DEFAULT_CONFIG_DIR, exist_ok=True) self.assertIn("Editing config file", result.output) @@ -128,6 +121,44 @@ class DataTestCase(unittest.TestCase): utils.discover_processors(cli) self.cli = cli + @mock.patch("requests.post") + def test_file_is_deleted_by_default(self, *args): + runner = CliRunner() + revolut_csv = "tests/samples/revolut.csv" + backup_file = "tests/samples/backup.csv" + + # backup the file, other tests will need it + shutil.copyfile(revolut_csv, backup_file) + + try: + self.assertTrue(os.path.isfile(revolut_csv)) + result = runner.invoke(self.cli, ["revolut", revolut_csv]) + self.assertEqual(result.exit_code, 0) + self.assertFalse(os.path.isfile(revolut_csv)) + finally: + # restore the file so other tests can rely on it, whatever happens + shutil.copyfile(backup_file, revolut_csv) + os.unlink(backup_file) + + @mock.patch("requests.post") + def test_file_is_kept_with_option(self, *args): + runner = CliRunner() + revolut_csv = "tests/samples/revolut.csv" + backup_file = "tests/samples/backup.csv" + + # backup the file, other tests will need it + shutil.copyfile(revolut_csv, backup_file) + + try: + self.assertTrue(os.path.isfile(revolut_csv)) + result = runner.invoke(self.cli, ["revolut", revolut_csv, "--keep"]) + self.assertEqual(result.exit_code, 0) + self.assertTrue(os.path.isfile(revolut_csv)) + finally: + # restore the file so other tests can rely on it, whatever happens + shutil.copyfile(backup_file, revolut_csv) + os.unlink(backup_file) + @mock.patch("requests.post") def test_revolut_sends_data_only_created(self, post): post.return_value.json.return_value = { @@ -162,7 +193,9 @@ class DataTestCase(unittest.TestCase): } runner = CliRunner() - result = runner.invoke(self.cli, ["revolut", "tests/samples/revolut.csv"]) + result = runner.invoke( + self.cli, ["revolut", "tests/samples/revolut.csv", "--keep"] + ) self.assertEqual(result.exit_code, 0) self.assertIn("Processed 9 transactions total.", result.output) @@ -204,7 +237,9 @@ class DataTestCase(unittest.TestCase): } runner = CliRunner() - result = runner.invoke(self.cli, ["revolut", "tests/samples/revolut.csv"]) + result = runner.invoke( + self.cli, ["revolut", "tests/samples/revolut.csv", "--keep"] + ) self.assertEqual(result.exit_code, 0) self.assertIn("Processed 9 transactions total.", result.output) @@ -231,7 +266,9 @@ class DataTestCase(unittest.TestCase): } runner = CliRunner() - result = runner.invoke(self.cli, ["revolut", "tests/samples/revolut.csv"]) + result = runner.invoke( + self.cli, ["revolut", "tests/samples/revolut.csv", "--keep"] + ) self.assertEqual(result.exit_code, 0) self.assertIn("Processed 9 transactions total.", result.output) @@ -247,7 +284,7 @@ class DataTestCase(unittest.TestCase): expected_url = f"{ynab.BASE_URL}/budgets//transactions" runner = CliRunner() - runner.invoke(self.cli, ["bpvf", "tests/samples/bpvf.ofx"]) + runner.invoke(self.cli, ["bpvf", "tests/samples/bpvf.ofx", "--keep"]) post.assert_called_once_with( expected_url, json=expected_data, headers=expected_headers @@ -262,7 +299,7 @@ class DataTestCase(unittest.TestCase): expected_url = f"{ynab.BASE_URL}/budgets//transactions" runner = CliRunner() - runner.invoke(self.cli, ["ce", "tests/samples/ce.ofx"]) + runner.invoke(self.cli, ["ce", "tests/samples/ce.ofx", "--keep"]) post.assert_called_once_with( expected_url, json=expected_data, headers=expected_headers @@ -277,7 +314,7 @@ class DataTestCase(unittest.TestCase): expected_url = f"{ynab.BASE_URL}/budgets//transactions" runner = CliRunner() - runner.invoke(self.cli, ["lcl", "tests/samples/lcl.ofx"]) + runner.invoke(self.cli, ["lcl", "tests/samples/lcl.ofx", "--keep"]) post.assert_called_once_with( expected_url, json=expected_data, headers=expected_headers