diff --git a/.gitignore b/.gitignore index 6c0c259..3adf8c3 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ eggs .project .pydevproject *.sublime* +.vscode \ No newline at end of file diff --git a/README.rst b/README.rst index 8c77a4d..858a46c 100644 --- a/README.rst +++ b/README.rst @@ -71,6 +71,15 @@ After installation, usage is simple:: Resulting ``statement.ofx`` is then ready to be imported to GnuCash or other financial program you use. +Plugins could have download capability in this case you can download +statements from bank site:: + + $ ofxstatement download -t --date-from 01/01/2019 --date-to 01/02/2019 out.xls + +Resulting file ``out.xls`` is then ready to be converted with the +``ofxstatement convert`` command. +Note, that download functionality is plugin specific. See particular plugin +documentation for more info. Known Plugins ============= @@ -201,7 +210,7 @@ imported .ofx statement with particular GnuCash account. To convert proprietary ``danske.csv`` to OFX ``danske.ofx``, run:: - $ ofxstatement -t danske:usd danske.csv danske.ofx + $ ofxstatement convert -t danske:usd danske.csv danske.ofx Note, that configuration parameters are plugin specific. See particular plugin documentation for more info. diff --git a/setup.py b/setup.py index 2239f44..5266730 100755 --- a/setup.py +++ b/setup.py @@ -37,7 +37,8 @@ }, package_dir={'': 'src'}, install_requires=['setuptools', - 'appdirs>=1.3.0' + 'appdirs>=1.3.0', + 'datetime' ], extras_require={'test': ["mock", "pytest", "pytest-cov"]}, tests_require=["mock"], diff --git a/src/ofxstatement/configuration.py b/src/ofxstatement/configuration.py index 8250479..c2cdcb5 100644 --- a/src/ofxstatement/configuration.py +++ b/src/ofxstatement/configuration.py @@ -20,7 +20,7 @@ def read(location=None): if not os.path.exists(location): return None - config = configparser.SafeConfigParser() + config = configparser.ConfigParser() config.read(location) return config diff --git a/src/ofxstatement/downloader.py b/src/ofxstatement/downloader.py new file mode 100644 index 0000000..0ccb653 --- /dev/null +++ b/src/ofxstatement/downloader.py @@ -0,0 +1,16 @@ +class Downloader(object): + """Abstract statement downloader. + + Defines interface for all parser implementation + """ + + def download(self): + """download statement file + + Return Statement object + + May raise exceptions.DownloadError if there are problems + in downloading statement file. + """ + raise NotImplementedError + diff --git a/src/ofxstatement/exceptions.py b/src/ofxstatement/exceptions.py index 848d4ae..f508398 100644 --- a/src/ofxstatement/exceptions.py +++ b/src/ofxstatement/exceptions.py @@ -8,3 +8,9 @@ class ParseError(Exception): def __init__(self, lineno, message): self.lineno = lineno self.message = message + +class DownloadError(Exception): + """Raised by downloader to indicate problem on web scraper + """ + def __init__(self, message): + self.message = message diff --git a/src/ofxstatement/plugin.py b/src/ofxstatement/plugin.py index 4702790..9e953d0 100644 --- a/src/ofxstatement/plugin.py +++ b/src/ofxstatement/plugin.py @@ -45,4 +45,7 @@ def __init__(self, ui, settings): self.settings = settings def get_parser(self, filename): - raise NotImplementedError() + raise NotImplementedError + + def get_downloader(self, filename, date_from, date_to): + raise NotImplementedError diff --git a/src/ofxstatement/tests/test_tool.py b/src/ofxstatement/tests/test_tool.py index 8318fa4..a679a0c 100644 --- a/src/ofxstatement/tests/test_tool.py +++ b/src/ofxstatement/tests/test_tool.py @@ -88,6 +88,133 @@ def parse(self): self.assertEqual( self.log.getvalue().splitlines(), ['ERROR: Parse error on line 23: Catastrophic error']) + + def test_download_date_wrong(self): + outputfname = os.path.join(self.tmpdir, "output") + args = mock.Mock(type="test", date_from="01/13/2019", + date_to="01/02/2019", output=outputfname) + + config = {"test": {"plugin": "sample"}} + + sample_plugin = mock.Mock() + sample_plugin.get_parser.return_value = parser + + configpatch = mock.patch("ofxstatement.configuration.read", + return_value=config) + + pluginpatch = mock.patch("ofxstatement.plugin.get_plugin", + return_value=sample_plugin) + + with configpatch, pluginpatch: + ret = tool.download(args) + + self.assertEqual(ret, 1) + self.assertEqual( + self.log.getvalue().splitlines(), + ["ERROR: Wrong date format: %s" % "01/13/2019"]) + + def test_download_date_consistency(self): + outputfname = os.path.join(self.tmpdir, "output") + args = mock.Mock(type="test", date_from="02/01/2019", + date_to="01/01/2019", output=outputfname) + + config = {"test": {"plugin": "sample"}} + + sample_plugin = mock.Mock() + sample_plugin.get_parser.return_value = parser + + configpatch = mock.patch("ofxstatement.configuration.read", + return_value=config) + + pluginpatch = mock.patch("ofxstatement.plugin.get_plugin", + return_value=sample_plugin) + + with configpatch, pluginpatch: + ret = tool.download(args) + + self.assertEqual(ret, 1) + self.assertEqual( + self.log.getvalue().splitlines(), + ["ERROR: End date before start date"]) + + def test_download_configured(self): + outputfname = os.path.join(self.tmpdir, "output") + args = mock.Mock(type="test", date_from="01/01/2019", date_to="01/02/2019", output=outputfname) + + config = {"test": {"plugin": "sample"}} + + parser = mock.Mock() + parser.parse.return_value = statement.Statement() + + sample_plugin = mock.Mock() + sample_plugin.get_parser.return_value = parser + + configpatch = mock.patch("ofxstatement.configuration.read", + return_value=config) + + pluginpatch = mock.patch("ofxstatement.plugin.get_plugin", + return_value=sample_plugin) + + with configpatch, pluginpatch: + ret = tool.download(args) + + self.assertEqual(ret, 0) + self.assertEqual( + self.log.getvalue().splitlines(), + ["INFO: [%s] Download started" % "test", + "INFO: Download completed: %s" % outputfname]) + + def test_download_noconf(self): + outputfname = os.path.join(self.tmpdir, "output") + args = mock.Mock(type="test", date_from="01/01/2019", date_to="01/02/2019", output=outputfname) + + parser = mock.Mock() + parser.parse.return_value = statement.Statement() + + sample_plugin = mock.Mock() + sample_plugin.get_parser.return_value = parser + + noconfigpatch = mock.patch("ofxstatement.configuration.read", + return_value=None) + pluginpatch = mock.patch("ofxstatement.plugin.get_plugin", + return_value=sample_plugin) + + with noconfigpatch, pluginpatch: + ret = tool.download(args) + + self.assertEqual(ret, 0) + + self.assertEqual( + self.log.getvalue().splitlines(), + ["INFO: [%s] Download started" % "test", + "INFO: Download completed: %s" % outputfname]) + + def test_download_no_download(self): + outputfname = os.path.join(self.tmpdir, "output") + args = mock.Mock(type="test", date_from="01/01/2019", date_to="01/02/2019", output=outputfname) + + config = {"test": {"plugin": "sample"}} + + parser = mock.Mock() + parser.parse.return_value = statement.Statement() + + sample_plugin = mock.Mock() + sample_plugin.get_parser.return_value = parser + sample_plugin.get_downloader.side_effect = NotImplementedError + + configpatch = mock.patch("ofxstatement.configuration.read", + return_value=config) + + pluginpatch = mock.patch("ofxstatement.plugin.get_plugin", + return_value=sample_plugin) + + with configpatch, pluginpatch: + ret = tool.download(args) + + self.assertEqual(ret, 1) + self.assertEqual( + self.log.getvalue().splitlines(), + ["ERROR: Plugin '%s' has no download capability" % "sample"]) def test_list_plugins_plugins(self): pl1 = mock.Mock(__doc__="Plugin one") diff --git a/src/ofxstatement/tool.py b/src/ofxstatement/tool.py index 644394c..43b192d 100644 --- a/src/ofxstatement/tool.py +++ b/src/ofxstatement/tool.py @@ -8,6 +8,7 @@ import pkg_resources +from datetime import datetime from ofxstatement import ui, configuration, plugin, ofx, exceptions @@ -62,6 +63,26 @@ def make_args_parser(): "default editor")) parser_edit.set_defaults(func=edit_config) + # download + parser_download = subparsers.add_parser("download", + help="download statement file.") + + parser_download.add_argument("-t", "--type", + required=True, + help=("bank type. This is a section in " + "config file, or plugin name if you " + "have no config file.")) + parser_download.add_argument("--date-from", + required=True, metavar="DD/MM/YYYY", + help=("Start date for web data " + "retrieval from the bank account")) + parser_download.add_argument("--date-to", + required=True, metavar="DD/MM/YYYY", + help=("End date for web data " + "retrieval from the bank account")) + parser_download.add_argument("output", help="output file to produce") + parser_download.set_defaults(func=download) + return parser @@ -138,6 +159,66 @@ def convert(args): log.info("Conversion completed: %s" % args.input) return 0 # success +def download(args): + appui = ui.UI() + config = configuration.read() + + # Check valid date + try: + date_from = datetime.strptime(args.date_from, "%d/%m/%Y") + date_to = datetime.strptime(args.date_to, "%d/%m/%Y") + except ValueError: + log.error("Wrong date format: %s" % args.date_from) + return 1 + + # Check date consistency + if date_to < date_from: + log.error("End date before start date") + return 1 + + if config is None: + # No configuration mode + settings = {} + pname = args.type + else: + # Configuration is loaded + if args.type not in config: + log.error("No section '%s' in config file." % args.type) + log.error("Edit configuration using ofxstatement edit-config and " + "add section [%s]." % args.type) + return 1 # error + + settings = dict(config[args.type]) + + pname = settings.get('plugin', None) + if not pname: + log.error("Specify 'plugin' setting for section [%s]" % args.type) + return 1 # error + + # pick and configure plugin + try: + p = plugin.get_plugin(pname, appui, settings) + except plugin.PluginNotRegistered: + log.error("No plugin named '%s' is found" % pname) + return 1 # error + + # open downloader class + try: + downloader = p.get_downloader(args.output, date_from, date_to) + except NotImplementedError: + log.error("Plugin '%s' has no download capability" % pname) + return 1 # error + + # process the input and produce output + try: + log.info("[%s] Download started" % args.type) + downloader.download() + except exceptions.DownloadError as e: + log.error("Download error: %s" % (e.message)) + return 2 # error + + log.info("Download completed: %s" % args.output) + return 0 # success def run(args=None): parser = make_args_parser()