Add LCL automatic file download

This commit is contained in:
Gabriel Augendre 2021-11-20 15:00:43 +01:00
parent 08c710b178
commit 96fbeb257b
13 changed files with 557 additions and 115 deletions

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
geckodriver.log
# Created by https://www.gitignore.io/api/osx,pycharm,python # Created by https://www.gitignore.io/api/osx,pycharm,python
# Edit at https://www.gitignore.io/?templates=osx,pycharm,python # Edit at https://www.gitignore.io/?templates=osx,pycharm,python

View file

@ -0,0 +1 @@
from .lcl import LclDownloader

View file

@ -0,0 +1,82 @@
import time
from pathlib import Path
import click
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.select import Select
from ofx_processor.utils.config import (
get_config,
get_config_file_name,
handle_config_file_error,
)
class LclDownloader:
def __init__(self, download_folder: Path = None):
self.config = get_config("lcl")
if not self.config.bank_identifier or not self.config.bank_password:
handle_config_file_error(
get_config_file_name(), "Missing credentials in config file"
)
if not download_folder:
download_folder = Path.home() / "Downloads"
self.download_folder = download_folder.resolve()
options = webdriver.FirefoxOptions()
options.headless = True
options.set_preference("browser.download.dir", str(self.download_folder))
options.set_preference(
"browser.helperApps.neverAsk.saveToDisk", "application/x-ofx"
)
self.selenium = webdriver.Firefox(options=options)
self.selenium.implicitly_wait(10)
def download(self) -> str:
selenium = self.selenium
click.secho("Logging in to LCL...", fg="blue")
selenium.get("https://monespace.lcl.fr/connexion")
login_input = selenium.find_element(By.ID, "identifier")
login_input.send_keys(self.config.bank_identifier)
self._click(By.CLASS_NAME, "app-cta-button")
for char in self.config.bank_password:
self._click(By.CSS_SELECTOR, f".pad-button[value='{char}']")
self._click(By.CLASS_NAME, "app-cta-button")
click.secho("Logged in!", fg="green")
click.secho("Navigating through archives...", fg="blue")
self._click(By.ID, "linkSynthese")
self._click(By.CLASS_NAME, "picDl")
self._select(By.ID, "change", index=1)
self._select(By.ID, "DS", index=20)
self._click(By.ID, "MON04")
self._click(By.ID, "Valider")
click.secho("Found it!", fg="green")
selenium.get("about:downloads")
return self._get_last_download_file_name()
def _click(self, by: By, value: str):
self.selenium.find_element(by, value).click()
def _select(self, by: By, value: str, index: int):
Select(self.selenium.find_element(by, value)).select_by_index(index)
def _get_last_download_file_name(self, wait_seconds: int = 30):
end_time = time.time() + wait_seconds
while time.time() < end_time:
try:
file_name = self.selenium.execute_script(
"return document.querySelector('#contentAreaDownloadsView .downloadMainArea .downloadContainer description:nth-of-type(1)').value"
)
if file_name:
return self.download_folder / file_name
except:
pass
time.sleep(1)
if __name__ == "__main__":
filename = LclDownloader().download()
print(filename)

View file

@ -1,6 +1,7 @@
import click import click
from ofx_processor.utils import ynab from ofx_processor import downloaders
from ofx_processor.utils import config
from ofx_processor.utils.utils import OrderedGroup, discover_processors from ofx_processor.utils.utils import OrderedGroup, discover_processors
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
@ -15,7 +16,7 @@ def cli():
""" """
cli.add_command(ynab.config, name="config") cli.add_command(config.config, name="config")
discover_processors(cli) discover_processors(cli)
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -4,6 +4,7 @@ from datetime import datetime
import click import click
import dateparser import dateparser
from ofx_processor.downloaders import LclDownloader
from ofx_processor.utils.base_ofx import OfxBaseLine, OfxBaseProcessor from ofx_processor.utils.base_ofx import OfxBaseLine, OfxBaseProcessor
@ -79,6 +80,14 @@ class LclProcessor(OfxBaseProcessor):
return transactions return transactions
def main(filename, keep): def main(filename, keep, download):
"""Import LCL bank statement (OFX file).""" """Import LCL bank statement (OFX file)."""
if download:
if filename:
click.secho(
"You can't specify a file name with auto download. "
"Specified file name will be ignored.",
fg="yellow",
)
filename = LclDownloader().download()
LclProcessor(filename).push_to_ynab(keep) LclProcessor(filename).push_to_ynab(keep)

View file

@ -47,7 +47,7 @@ class BaseProcessor:
account_name = None account_name = None
def __init__(self, filename): def __init__(self, filename):
self.filename = filename self.filename = str(filename)
self.iterable = self.parse_file() self.iterable = self.parse_file()
self.transaction_ids = defaultdict(int) self.transaction_ids = defaultdict(int)

View file

@ -0,0 +1,93 @@
import configparser
import os
import sys
from dataclasses import dataclass
from typing import Optional
import click
DEFAULT_CONFIG_DIR = click.get_app_dir("ofx_processor")
DEFAULT_CONFIG_FILENAME = "config.ini"
def get_default_config():
default_config = configparser.ConfigParser()
default_config["DEFAULT"] = {
"token": "<YOUR API TOKEN>",
"budget": "<YOUR BUDGET ID>",
}
default_config["bpvf"] = {"account": "<YOUR ACCOUNT ID>"}
default_config["revolut"] = {"account": "<YOUR ACCOUNT ID>"}
default_config["ce"] = {"account": "<YOUR ACCOUNT ID>"}
default_config["lcl"] = {
"account": "<YOUR ACCOUNT ID>",
"bank_identifier": "login",
"bank_password": "password",
}
return default_config
def get_config_file_name():
config_file = os.path.join(DEFAULT_CONFIG_DIR, DEFAULT_CONFIG_FILENAME)
return config_file
@click.group()
def config():
"""Manage configuration."""
@config.command("edit")
def edit_config():
config_file = get_config_file_name()
click.edit(filename=config_file)
@dataclass(frozen=True)
class Config:
account: str
budget_id: str
token: str
bank_identifier: Optional[str] = None
bank_password: Optional[str] = None
def get_config(account: str) -> Config:
config = configparser.ConfigParser()
config_file = get_config_file_name()
if not os.path.isfile(config_file):
os.makedirs(DEFAULT_CONFIG_DIR, exist_ok=True)
config = get_default_config()
with open(config_file, "w") as file_:
config.write(file_)
click.secho("Editing config file...")
click.pause()
click.edit(filename=config_file)
try:
config.read(config_file)
except configparser.Error as e:
return handle_config_file_error(config_file, e)
try:
section = config[account]
budget_id = section["budget"]
token = section["token"]
account = section["account"]
bank_identifier = section.get("bank_identifier")
bank_password = section.get("bank_password")
except KeyError as e:
return handle_config_file_error(config_file, e)
return Config(account, budget_id, token, bank_identifier, bank_password)
def handle_config_file_error(config_file, e):
click.secho(f"Error while parsing config file: {str(e)}", fg="red", bold=True)
click.secho("Opening the file...")
click.pause()
click.edit(filename=config_file)
click.secho("Exiting...", fg="red", bold=True)
sys.exit(1)

View file

@ -1,11 +1,34 @@
import collections import collections
import importlib import importlib
import inspect
import pkgutil import pkgutil
import click import click
from ofx_processor import processors from ofx_processor import processors
ARG_TO_OPTION = {
"keep": click.option(
"--keep/--no-keep",
help="Keep the file after processing it.",
default=False,
show_default=True,
),
"download": click.option(
"--download/--no-download",
help="Download the file automatically.",
default=False,
show_default=True,
),
"filename": click.option(
"-f",
"--filename",
help="Use specified file.",
default="",
show_default=True,
),
}
def discover_processors(cli: click.Group): def discover_processors(cli: click.Group):
""" """
@ -41,13 +64,10 @@ def discover_processors(cli: click.Group):
# Apply default decorators # Apply default decorators
method = getattr(module, "main") method = getattr(module, "main")
method = click.option( method_args = inspect.getfullargspec(method).args
"--keep/--no-keep", for arg in method_args:
help="Keep the file after processing it.", if arg in ARG_TO_OPTION:
default=False, method = ARG_TO_OPTION[arg](method)
show_default=True,
)(method)
method = click.argument("filename")(method)
method = click.command(cls.command_name)(method) method = click.command(cls.command_name)(method)
cli.add_command(method) cli.add_command(method)

View file

@ -1,82 +1,29 @@
import configparser
import os
import sys
import click import click
import requests import requests
from ofx_processor.utils.config import get_config
BASE_URL = "https://api.youneedabudget.com/v1" BASE_URL = "https://api.youneedabudget.com/v1"
DEFAULT_CONFIG_DIR = click.get_app_dir("ofx_processor")
DEFAULT_CONFIG_FILENAME = "config.ini"
def get_default_config():
default_config = configparser.ConfigParser()
default_config["DEFAULT"] = {
"token": "<YOUR API TOKEN>",
"budget": "<YOUR BUDGET ID>",
}
default_config["bpvf"] = {"account": "<YOUR ACCOUNT ID>"}
default_config["revolut"] = {"account": "<YOUR ACCOUNT ID>"}
default_config["ce"] = {"account": "<YOUR ACCOUNT ID>"}
return default_config
def get_config_file_name():
config_file = os.path.join(DEFAULT_CONFIG_DIR, DEFAULT_CONFIG_FILENAME)
return config_file
@click.group()
def config():
"""Manage configuration."""
@config.command("edit")
def edit_config():
config_file = get_config_file_name()
click.edit(filename=config_file)
def push_transactions(transactions, account): def push_transactions(transactions, account):
if not transactions: if not transactions:
click.secho("No transaction, nothing to do.", fg="yellow") click.secho("No transaction, nothing to do.", fg="yellow")
return return
config = configparser.ConfigParser() config = get_config(account)
config_file = get_config_file_name()
if not os.path.isfile(config_file):
os.makedirs(DEFAULT_CONFIG_DIR, exist_ok=True)
config = get_default_config()
with open(config_file, "w") as file_:
config.write(file_)
click.secho("Editing config file...")
click.pause()
click.edit(filename=config_file)
try: url = f"{BASE_URL}/budgets/{config.budget_id}/transactions"
config.read(config_file)
except configparser.Error as e:
return handle_config_file_error(config_file, e)
try:
section = config[account]
budget_id = section["budget"]
token = section["token"]
account = section["account"]
except KeyError as e:
return handle_config_file_error(config_file, e)
url = f"{BASE_URL}/budgets/{budget_id}/transactions"
for transaction in transactions: for transaction in transactions:
transaction["account_id"] = account transaction["account_id"] = config.account
transaction["cleared"] = "cleared" transaction["cleared"] = "cleared"
data = {"transactions": transactions} data = {"transactions": transactions}
headers = {"Authorization": f"Bearer {token}"} headers = {"Authorization": f"Bearer {config.token}"}
res = requests.post(url, json=data, headers=headers) res = requests.post(url, json=data, headers=headers)
res.raise_for_status() if res.status_code >= 400:
click.secho(f"Error pushing transactions: {res.text}", fg="red")
return
data = res.json()["data"] data = res.json()["data"]
created = set() created = set()
@ -95,12 +42,3 @@ def push_transactions(transactions, account):
click.secho( click.secho(
f"{len(duplicates)} transactions ignored (duplicates).", fg="yellow" f"{len(duplicates)} transactions ignored (duplicates).", fg="yellow"
) )
def handle_config_file_error(config_file, e):
click.secho(f"Error while parsing config file: {str(e)}", fg="red", bold=True)
click.secho("Opening the file...")
click.pause()
click.edit(filename=config_file)
click.secho("Exiting...", fg="red", bold=True)
sys.exit(1)

278
poetry.lock generated
View file

@ -1,3 +1,11 @@
[[package]]
name = "async-generator"
version = "1.10"
description = "Async generators and context managers for Python 3.5+"
category = "main"
optional = false
python-versions = ">=3.5"
[[package]] [[package]]
name = "atomicwrites" name = "atomicwrites"
version = "1.4.0" version = "1.4.0"
@ -10,7 +18,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
name = "attrs" name = "attrs"
version = "21.2.0" version = "21.2.0"
description = "Classes Without Boilerplate" description = "Classes Without Boilerplate"
category = "dev" category = "main"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
@ -51,6 +59,17 @@ category = "main"
optional = false optional = false
python-versions = "*" python-versions = "*"
[[package]]
name = "cffi"
version = "1.15.0"
description = "Foreign Function Interface for Python calling C code."
category = "main"
optional = false
python-versions = "*"
[package.dependencies]
pycparser = "*"
[[package]] [[package]]
name = "cfgv" name = "cfgv"
version = "3.3.1" version = "3.3.1"
@ -103,6 +122,25 @@ tomli = {version = "*", optional = true, markers = "extra == \"toml\""}
[package.extras] [package.extras]
toml = ["tomli"] toml = ["tomli"]
[[package]]
name = "cryptography"
version = "35.0.0"
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
cffi = ">=1.12"
[package.extras]
docs = ["sphinx (>=1.6.5,!=1.8.0,!=3.1.0,!=3.1.1)", "sphinx-rtd-theme"]
docstest = ["doc8", "pyenchant (>=1.6.11)", "twine (>=1.12.0)", "sphinxcontrib-spelling (>=4.0.1)"]
pep8test = ["black", "flake8", "flake8-import-order", "pep8-naming"]
sdist = ["setuptools_rust (>=0.11.4)"]
ssh = ["bcrypt (>=3.1.5)"]
test = ["pytest (>=6.2.0)", "pytest-cov", "pytest-subtests", "pytest-xdist", "pretend", "iso8601", "pytz", "hypothesis (>=1.11.4,!=3.79.2)"]
[[package]] [[package]]
name = "dateparser" name = "dateparser"
version = "1.1.0" version = "1.1.0"
@ -142,6 +180,14 @@ python-versions = ">=3.6"
docs = ["furo (>=2021.8.17b43)", "sphinx (>=4.1)", "sphinx-autodoc-typehints (>=1.12)"] docs = ["furo (>=2021.8.17b43)", "sphinx (>=4.1)", "sphinx-autodoc-typehints (>=1.12)"]
testing = ["covdefaults (>=1.2.0)", "coverage (>=4)", "pytest (>=4)", "pytest-cov", "pytest-timeout (>=1.4.2)"] testing = ["covdefaults (>=1.2.0)", "coverage (>=4)", "pytest (>=4)", "pytest-cov", "pytest-timeout (>=1.4.2)"]
[[package]]
name = "h11"
version = "0.12.0"
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
category = "main"
optional = false
python-versions = ">=3.6"
[[package]] [[package]]
name = "identify" name = "identify"
version = "2.3.7" version = "2.3.7"
@ -193,6 +239,17 @@ category = "main"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
[[package]]
name = "outcome"
version = "1.1.0"
description = "Capture the outcome of Python function calls."
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
attrs = ">=19.2.0"
[[package]] [[package]]
name = "packaging" name = "packaging"
version = "21.3" version = "21.3"
@ -252,6 +309,30 @@ category = "dev"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[[package]]
name = "pycparser"
version = "2.21"
description = "C parser in Python"
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[[package]]
name = "pyopenssl"
version = "21.0.0"
description = "Python wrapper module around the OpenSSL library"
category = "main"
optional = false
python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*"
[package.dependencies]
cryptography = ">=3.3"
six = ">=1.5.2"
[package.extras]
docs = ["sphinx", "sphinx-rtd-theme"]
test = ["flaky", "pretend", "pytest (>=3.0.1)"]
[[package]] [[package]]
name = "pyparsing" name = "pyparsing"
version = "3.0.6" version = "3.0.6"
@ -364,6 +445,19 @@ urllib3 = ">=1.21.1,<1.27"
socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"]
use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"] use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"]
[[package]]
name = "selenium"
version = "4.0.0"
description = ""
category = "main"
optional = false
python-versions = "~=3.7"
[package.dependencies]
trio = ">=0.17,<1.0"
trio-websocket = ">=0.9,<1.0"
urllib3 = {version = ">=1.26,<2.0", extras = ["secure"]}
[[package]] [[package]]
name = "six" name = "six"
version = "1.16.0" version = "1.16.0"
@ -372,6 +466,22 @@ category = "main"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
[[package]]
name = "sniffio"
version = "1.2.0"
description = "Sniff out which async library your code is running under"
category = "main"
optional = false
python-versions = ">=3.5"
[[package]]
name = "sortedcontainers"
version = "2.4.0"
description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set"
category = "main"
optional = false
python-versions = "*"
[[package]] [[package]]
name = "toml" name = "toml"
version = "0.10.2" version = "0.10.2"
@ -410,6 +520,36 @@ virtualenv = ">=16.0.0,<20.0.0 || >20.0.0,<20.0.1 || >20.0.1,<20.0.2 || >20.0.2,
docs = ["pygments-github-lexers (>=0.0.5)", "sphinx (>=2.0.0)", "sphinxcontrib-autoprogram (>=0.1.5)", "towncrier (>=18.5.0)"] docs = ["pygments-github-lexers (>=0.0.5)", "sphinx (>=2.0.0)", "sphinxcontrib-autoprogram (>=0.1.5)", "towncrier (>=18.5.0)"]
testing = ["flaky (>=3.4.0)", "freezegun (>=0.3.11)", "psutil (>=5.6.1)", "pytest (>=4.0.0)", "pytest-cov (>=2.5.1)", "pytest-mock (>=1.10.0)", "pytest-randomly (>=1.0.0)", "pytest-xdist (>=1.22.2)", "pathlib2 (>=2.3.3)"] testing = ["flaky (>=3.4.0)", "freezegun (>=0.3.11)", "psutil (>=5.6.1)", "pytest (>=4.0.0)", "pytest-cov (>=2.5.1)", "pytest-mock (>=1.10.0)", "pytest-randomly (>=1.0.0)", "pytest-xdist (>=1.22.2)", "pathlib2 (>=2.3.3)"]
[[package]]
name = "trio"
version = "0.19.0"
description = "A friendly Python library for async concurrency and I/O"
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
async-generator = ">=1.9"
attrs = ">=19.2.0"
cffi = {version = ">=1.14", markers = "os_name == \"nt\" and implementation_name != \"pypy\""}
idna = "*"
outcome = "*"
sniffio = "*"
sortedcontainers = "*"
[[package]]
name = "trio-websocket"
version = "0.9.2"
description = "WebSocket library for Trio"
category = "main"
optional = false
python-versions = ">=3.5"
[package.dependencies]
async-generator = ">=1.10"
trio = ">=0.11"
wsproto = ">=0.14"
[[package]] [[package]]
name = "tzdata" name = "tzdata"
version = "2021.5" version = "2021.5"
@ -443,6 +583,12 @@ category = "main"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4"
[package.dependencies]
certifi = {version = "*", optional = true, markers = "extra == \"secure\""}
cryptography = {version = ">=1.3.4", optional = true, markers = "extra == \"secure\""}
idna = {version = ">=2.0.0", optional = true, markers = "extra == \"secure\""}
pyOpenSSL = {version = ">=0.14", optional = true, markers = "extra == \"secure\""}
[package.extras] [package.extras]
brotli = ["brotlipy (>=0.6.0)"] brotli = ["brotlipy (>=0.6.0)"]
secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"]
@ -467,12 +613,27 @@ six = ">=1.9.0,<2"
docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=21.3)"] docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=21.3)"]
testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)"] testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)"]
[[package]]
name = "wsproto"
version = "1.0.0"
description = "WebSockets state-machine based protocol implementation"
category = "main"
optional = false
python-versions = ">=3.6.1"
[package.dependencies]
h11 = ">=0.9.0,<1"
[metadata] [metadata]
lock-version = "1.1" lock-version = "1.1"
python-versions = ">=3.8,<4" python-versions = ">=3.8,<4"
content-hash = "a841839780c92b0bfb8e131978a42fa9e9b7b75e27e7c8c3c9bc30e75ef17b96" content-hash = "daea4d42f45c7dbccd934234140164a94ae8fa803f74b732b069026601b46a85"
[metadata.files] [metadata.files]
async-generator = [
{file = "async_generator-1.10-py3-none-any.whl", hash = "sha256:01c7bf666359b4967d2cda0000cc2e4af16a0ae098cbffcb8472fb9e8ad6585b"},
{file = "async_generator-1.10.tar.gz", hash = "sha256:6ebb3d106c12920aaae42ccb6f787ef5eefdcdd166ea3d628fa8476abe712144"},
]
atomicwrites = [ atomicwrites = [
{file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"},
{file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"},
@ -507,6 +668,58 @@ certifi = [
{file = "certifi-2021.10.8-py2.py3-none-any.whl", hash = "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"}, {file = "certifi-2021.10.8-py2.py3-none-any.whl", hash = "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"},
{file = "certifi-2021.10.8.tar.gz", hash = "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872"}, {file = "certifi-2021.10.8.tar.gz", hash = "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872"},
] ]
cffi = [
{file = "cffi-1.15.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:c2502a1a03b6312837279c8c1bd3ebedf6c12c4228ddbad40912d671ccc8a962"},
{file = "cffi-1.15.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:23cfe892bd5dd8941608f93348c0737e369e51c100d03718f108bf1add7bd6d0"},
{file = "cffi-1.15.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:41d45de54cd277a7878919867c0f08b0cf817605e4eb94093e7516505d3c8d14"},
{file = "cffi-1.15.0-cp27-cp27m-win32.whl", hash = "sha256:4a306fa632e8f0928956a41fa8e1d6243c71e7eb59ffbd165fc0b41e316b2474"},
{file = "cffi-1.15.0-cp27-cp27m-win_amd64.whl", hash = "sha256:e7022a66d9b55e93e1a845d8c9eba2a1bebd4966cd8bfc25d9cd07d515b33fa6"},
{file = "cffi-1.15.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:14cd121ea63ecdae71efa69c15c5543a4b5fbcd0bbe2aad864baca0063cecf27"},
{file = "cffi-1.15.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:d4d692a89c5cf08a8557fdeb329b82e7bf609aadfaed6c0d79f5a449a3c7c023"},
{file = "cffi-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0104fb5ae2391d46a4cb082abdd5c69ea4eab79d8d44eaaf79f1b1fd806ee4c2"},
{file = "cffi-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:91ec59c33514b7c7559a6acda53bbfe1b283949c34fe7440bcf917f96ac0723e"},
{file = "cffi-1.15.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f5c7150ad32ba43a07c4479f40241756145a1f03b43480e058cfd862bf5041c7"},
{file = "cffi-1.15.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:00c878c90cb53ccfaae6b8bc18ad05d2036553e6d9d1d9dbcf323bbe83854ca3"},
{file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:abb9a20a72ac4e0fdb50dae135ba5e77880518e742077ced47eb1499e29a443c"},
{file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a5263e363c27b653a90078143adb3d076c1a748ec9ecc78ea2fb916f9b861962"},
{file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f54a64f8b0c8ff0b64d18aa76675262e1700f3995182267998c31ae974fbc382"},
{file = "cffi-1.15.0-cp310-cp310-win32.whl", hash = "sha256:c21c9e3896c23007803a875460fb786118f0cdd4434359577ea25eb556e34c55"},
{file = "cffi-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:5e069f72d497312b24fcc02073d70cb989045d1c91cbd53979366077959933e0"},
{file = "cffi-1.15.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:64d4ec9f448dfe041705426000cc13e34e6e5bb13736e9fd62e34a0b0c41566e"},
{file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2756c88cbb94231c7a147402476be2c4df2f6078099a6f4a480d239a8817ae39"},
{file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b96a311ac60a3f6be21d2572e46ce67f09abcf4d09344c49274eb9e0bf345fc"},
{file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75e4024375654472cc27e91cbe9eaa08567f7fbdf822638be2814ce059f58032"},
{file = "cffi-1.15.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:59888172256cac5629e60e72e86598027aca6bf01fa2465bdb676d37636573e8"},
{file = "cffi-1.15.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:27c219baf94952ae9d50ec19651a687b826792055353d07648a5695413e0c605"},
{file = "cffi-1.15.0-cp36-cp36m-win32.whl", hash = "sha256:4958391dbd6249d7ad855b9ca88fae690783a6be9e86df65865058ed81fc860e"},
{file = "cffi-1.15.0-cp36-cp36m-win_amd64.whl", hash = "sha256:f6f824dc3bce0edab5f427efcfb1d63ee75b6fcb7282900ccaf925be84efb0fc"},
{file = "cffi-1.15.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:06c48159c1abed75c2e721b1715c379fa3200c7784271b3c46df01383b593636"},
{file = "cffi-1.15.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c2051981a968d7de9dd2d7b87bcb9c939c74a34626a6e2f8181455dd49ed69e4"},
{file = "cffi-1.15.0-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:fd8a250edc26254fe5b33be00402e6d287f562b6a5b2152dec302fa15bb3e997"},
{file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91d77d2a782be4274da750752bb1650a97bfd8f291022b379bb8e01c66b4e96b"},
{file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:45db3a33139e9c8f7c09234b5784a5e33d31fd6907800b316decad50af323ff2"},
{file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:263cc3d821c4ab2213cbe8cd8b355a7f72a8324577dc865ef98487c1aeee2bc7"},
{file = "cffi-1.15.0-cp37-cp37m-win32.whl", hash = "sha256:17771976e82e9f94976180f76468546834d22a7cc404b17c22df2a2c81db0c66"},
{file = "cffi-1.15.0-cp37-cp37m-win_amd64.whl", hash = "sha256:3415c89f9204ee60cd09b235810be700e993e343a408693e80ce7f6a40108029"},
{file = "cffi-1.15.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4238e6dab5d6a8ba812de994bbb0a79bddbdf80994e4ce802b6f6f3142fcc880"},
{file = "cffi-1.15.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0808014eb713677ec1292301ea4c81ad277b6cdf2fdd90fd540af98c0b101d20"},
{file = "cffi-1.15.0-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:57e9ac9ccc3101fac9d6014fba037473e4358ef4e89f8e181f8951a2c0162024"},
{file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b6c2ea03845c9f501ed1313e78de148cd3f6cad741a75d43a29b43da27f2e1e"},
{file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:10dffb601ccfb65262a27233ac273d552ddc4d8ae1bf93b21c94b8511bffe728"},
{file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:786902fb9ba7433aae840e0ed609f45c7bcd4e225ebb9c753aa39725bb3e6ad6"},
{file = "cffi-1.15.0-cp38-cp38-win32.whl", hash = "sha256:da5db4e883f1ce37f55c667e5c0de439df76ac4cb55964655906306918e7363c"},
{file = "cffi-1.15.0-cp38-cp38-win_amd64.whl", hash = "sha256:181dee03b1170ff1969489acf1c26533710231c58f95534e3edac87fff06c443"},
{file = "cffi-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:45e8636704eacc432a206ac7345a5d3d2c62d95a507ec70d62f23cd91770482a"},
{file = "cffi-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:31fb708d9d7c3f49a60f04cf5b119aeefe5644daba1cd2a0fe389b674fd1de37"},
{file = "cffi-1.15.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6dc2737a3674b3e344847c8686cf29e500584ccad76204efea14f451d4cc669a"},
{file = "cffi-1.15.0-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:74fdfdbfdc48d3f47148976f49fab3251e550a8720bebc99bf1483f5bfb5db3e"},
{file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffaa5c925128e29efbde7301d8ecaf35c8c60ffbcd6a1ffd3a552177c8e5e796"},
{file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f7d084648d77af029acb79a0ff49a0ad7e9d09057a9bf46596dac9514dc07df"},
{file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef1f279350da2c586a69d32fc8733092fd32cc8ac95139a00377841f59a3f8d8"},
{file = "cffi-1.15.0-cp39-cp39-win32.whl", hash = "sha256:2a23af14f408d53d5e6cd4e3d9a24ff9e05906ad574822a10563efcef137979a"},
{file = "cffi-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:3773c4d81e6e818df2efbc7dd77325ca0dcb688116050fb2b3011218eda36139"},
{file = "cffi-1.15.0.tar.gz", hash = "sha256:920f0d66a896c2d99f0adbb391f990a84091179542c205fa53ce5787aff87954"},
]
cfgv = [ cfgv = [
{file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"}, {file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"},
{file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"}, {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"},
@ -572,6 +785,28 @@ coverage = [
{file = "coverage-6.1.2-pp36.pp37.pp38-none-any.whl", hash = "sha256:eab14fdd410500dae50fd14ccc332e65543e7b39f6fc076fe90603a0e5d2f929"}, {file = "coverage-6.1.2-pp36.pp37.pp38-none-any.whl", hash = "sha256:eab14fdd410500dae50fd14ccc332e65543e7b39f6fc076fe90603a0e5d2f929"},
{file = "coverage-6.1.2.tar.gz", hash = "sha256:d9a635114b88c0ab462e0355472d00a180a5fbfd8511e7f18e4ac32652e7d972"}, {file = "coverage-6.1.2.tar.gz", hash = "sha256:d9a635114b88c0ab462e0355472d00a180a5fbfd8511e7f18e4ac32652e7d972"},
] ]
cryptography = [
{file = "cryptography-35.0.0-cp36-abi3-macosx_10_10_x86_64.whl", hash = "sha256:d57e0cdc1b44b6cdf8af1d01807db06886f10177469312fbde8f44ccbb284bc9"},
{file = "cryptography-35.0.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:ced40344e811d6abba00295ced98c01aecf0c2de39481792d87af4fa58b7b4d6"},
{file = "cryptography-35.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:54b2605e5475944e2213258e0ab8696f4f357a31371e538ef21e8d61c843c28d"},
{file = "cryptography-35.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:7b7ceeff114c31f285528ba8b390d3e9cfa2da17b56f11d366769a807f17cbaa"},
{file = "cryptography-35.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d69645f535f4b2c722cfb07a8eab916265545b3475fdb34e0be2f4ee8b0b15e"},
{file = "cryptography-35.0.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a2d0e0acc20ede0f06ef7aa58546eee96d2592c00f450c9acb89c5879b61992"},
{file = "cryptography-35.0.0-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:07bb7fbfb5de0980590ddfc7f13081520def06dc9ed214000ad4372fb4e3c7f6"},
{file = "cryptography-35.0.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:7eba2cebca600a7806b893cb1d541a6e910afa87e97acf2021a22b32da1df52d"},
{file = "cryptography-35.0.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:18d90f4711bf63e2fb21e8c8e51ed8189438e6b35a6d996201ebd98a26abbbe6"},
{file = "cryptography-35.0.0-cp36-abi3-win32.whl", hash = "sha256:c10c797ac89c746e488d2ee92bd4abd593615694ee17b2500578b63cad6b93a8"},
{file = "cryptography-35.0.0-cp36-abi3-win_amd64.whl", hash = "sha256:7075b304cd567694dc692ffc9747f3e9cb393cc4aa4fb7b9f3abd6f5c4e43588"},
{file = "cryptography-35.0.0-pp36-pypy36_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a688ebcd08250eab5bb5bca318cc05a8c66de5e4171a65ca51db6bd753ff8953"},
{file = "cryptography-35.0.0-pp36-pypy36_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d99915d6ab265c22873f1b4d6ea5ef462ef797b4140be4c9d8b179915e0985c6"},
{file = "cryptography-35.0.0-pp36-pypy36_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:928185a6d1ccdb816e883f56ebe92e975a262d31cc536429041921f8cb5a62fd"},
{file = "cryptography-35.0.0-pp37-pypy37_pp73-macosx_10_10_x86_64.whl", hash = "sha256:ebeddd119f526bcf323a89f853afb12e225902a24d29b55fe18dd6fcb2838a76"},
{file = "cryptography-35.0.0-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:22a38e96118a4ce3b97509443feace1d1011d0571fae81fc3ad35f25ba3ea999"},
{file = "cryptography-35.0.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb80e8a1f91e4b7ef8b33041591e6d89b2b8e122d787e87eeb2b08da71bb16ad"},
{file = "cryptography-35.0.0-pp37-pypy37_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:abb5a361d2585bb95012a19ed9b2c8f412c5d723a9836418fab7aaa0243e67d2"},
{file = "cryptography-35.0.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:1ed82abf16df40a60942a8c211251ae72858b25b7421ce2497c2eb7a1cee817c"},
{file = "cryptography-35.0.0.tar.gz", hash = "sha256:9933f28f70d0517686bd7de36166dda42094eac49415459d9bdf5e7df3e0086d"},
]
dateparser = [ dateparser = [
{file = "dateparser-1.1.0-py2.py3-none-any.whl", hash = "sha256:fec344db1f73d005182e214c0ff27313c748bbe0c1638ce9d48a809ddfdab2a0"}, {file = "dateparser-1.1.0-py2.py3-none-any.whl", hash = "sha256:fec344db1f73d005182e214c0ff27313c748bbe0c1638ce9d48a809ddfdab2a0"},
{file = "dateparser-1.1.0.tar.gz", hash = "sha256:faa2b97f51f3b5ff1ba2f17be90de2b733fb6191f89b4058787473e8202f3044"}, {file = "dateparser-1.1.0.tar.gz", hash = "sha256:faa2b97f51f3b5ff1ba2f17be90de2b733fb6191f89b4058787473e8202f3044"},
@ -584,6 +819,10 @@ filelock = [
{file = "filelock-3.4.0-py3-none-any.whl", hash = "sha256:2e139a228bcf56dd8b2274a65174d005c4a6b68540ee0bdbb92c76f43f29f7e8"}, {file = "filelock-3.4.0-py3-none-any.whl", hash = "sha256:2e139a228bcf56dd8b2274a65174d005c4a6b68540ee0bdbb92c76f43f29f7e8"},
{file = "filelock-3.4.0.tar.gz", hash = "sha256:93d512b32a23baf4cac44ffd72ccf70732aeff7b8050fcaf6d3ec406d954baf4"}, {file = "filelock-3.4.0.tar.gz", hash = "sha256:93d512b32a23baf4cac44ffd72ccf70732aeff7b8050fcaf6d3ec406d954baf4"},
] ]
h11 = [
{file = "h11-0.12.0-py3-none-any.whl", hash = "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6"},
{file = "h11-0.12.0.tar.gz", hash = "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042"},
]
identify = [ identify = [
{file = "identify-2.3.7-py2.py3-none-any.whl", hash = "sha256:12f204544d1b718d5be2c82d95d30ff163614d7e15ae180bfbe923614a5f9a9f"}, {file = "identify-2.3.7-py2.py3-none-any.whl", hash = "sha256:12f204544d1b718d5be2c82d95d30ff163614d7e15ae180bfbe923614a5f9a9f"},
{file = "identify-2.3.7.tar.gz", hash = "sha256:eda2c9ad2d53fcc1ff110fc9816206a9ec236a5cc8469f815fc8d8193d58363a"}, {file = "identify-2.3.7.tar.gz", hash = "sha256:eda2c9ad2d53fcc1ff110fc9816206a9ec236a5cc8469f815fc8d8193d58363a"},
@ -608,6 +847,10 @@ nodeenv = [
ofxtools = [ ofxtools = [
{file = "ofxtools-0.9.4.tar.gz", hash = "sha256:e27f6c6f7ca2f8b195ee7fa14384760e2ea521d89501e6652e900aa4f92bff81"}, {file = "ofxtools-0.9.4.tar.gz", hash = "sha256:e27f6c6f7ca2f8b195ee7fa14384760e2ea521d89501e6652e900aa4f92bff81"},
] ]
outcome = [
{file = "outcome-1.1.0-py2.py3-none-any.whl", hash = "sha256:c7dd9375cfd3c12db9801d080a3b63d4b0a261aa996c4c13152380587288d958"},
{file = "outcome-1.1.0.tar.gz", hash = "sha256:e862f01d4e626e63e8f92c38d1f8d5546d3f9cce989263c521b2e7990d186967"},
]
packaging = [ packaging = [
{file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"},
{file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"},
@ -628,6 +871,14 @@ py = [
{file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"},
{file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"},
] ]
pycparser = [
{file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"},
{file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"},
]
pyopenssl = [
{file = "pyOpenSSL-21.0.0-py2.py3-none-any.whl", hash = "sha256:8935bd4920ab9abfebb07c41a4f58296407ed77f04bd1a92914044b848ba1ed6"},
{file = "pyOpenSSL-21.0.0.tar.gz", hash = "sha256:5e2d8c5e46d0d865ae933bef5230090bdaf5506281e9eec60fa250ee80600cb3"},
]
pyparsing = [ pyparsing = [
{file = "pyparsing-3.0.6-py3-none-any.whl", hash = "sha256:04ff808a5b90911829c55c4e26f75fa5ca8a2f5f36aa3a51f68e27033341d3e4"}, {file = "pyparsing-3.0.6-py3-none-any.whl", hash = "sha256:04ff808a5b90911829c55c4e26f75fa5ca8a2f5f36aa3a51f68e27033341d3e4"},
{file = "pyparsing-3.0.6.tar.gz", hash = "sha256:d9bdec0013ef1eb5a84ab39a3b3868911598afa494f5faa038647101504e2b81"}, {file = "pyparsing-3.0.6.tar.gz", hash = "sha256:d9bdec0013ef1eb5a84ab39a3b3868911598afa494f5faa038647101504e2b81"},
@ -742,10 +993,21 @@ requests = [
{file = "requests-2.26.0-py2.py3-none-any.whl", hash = "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24"}, {file = "requests-2.26.0-py2.py3-none-any.whl", hash = "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24"},
{file = "requests-2.26.0.tar.gz", hash = "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7"}, {file = "requests-2.26.0.tar.gz", hash = "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7"},
] ]
selenium = [
{file = "selenium-4.0.0-py3-none-any.whl", hash = "sha256:c942b166a21ce9c9065ad249b54059e926d39f9000167b5ca0fa4950d2ef9a82"},
]
six = [ six = [
{file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
{file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
] ]
sniffio = [
{file = "sniffio-1.2.0-py3-none-any.whl", hash = "sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663"},
{file = "sniffio-1.2.0.tar.gz", hash = "sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de"},
]
sortedcontainers = [
{file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"},
{file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"},
]
toml = [ toml = [
{file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"},
{file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
@ -758,6 +1020,14 @@ tox = [
{file = "tox-3.24.4-py2.py3-none-any.whl", hash = "sha256:5e274227a53dc9ef856767c21867377ba395992549f02ce55eb549f9fb9a8d10"}, {file = "tox-3.24.4-py2.py3-none-any.whl", hash = "sha256:5e274227a53dc9ef856767c21867377ba395992549f02ce55eb549f9fb9a8d10"},
{file = "tox-3.24.4.tar.gz", hash = "sha256:c30b57fa2477f1fb7c36aa1d83292d5c2336cd0018119e1b1c17340e2c2708ca"}, {file = "tox-3.24.4.tar.gz", hash = "sha256:c30b57fa2477f1fb7c36aa1d83292d5c2336cd0018119e1b1c17340e2c2708ca"},
] ]
trio = [
{file = "trio-0.19.0-py3-none-any.whl", hash = "sha256:c27c231e66336183c484fbfe080fa6cc954149366c15dc21db8b7290081ec7b8"},
{file = "trio-0.19.0.tar.gz", hash = "sha256:895e318e5ec5e8cea9f60b473b6edb95b215e82d99556a03eb2d20c5e027efe1"},
]
trio-websocket = [
{file = "trio-websocket-0.9.2.tar.gz", hash = "sha256:a3d34de8fac26023eee701ed1e7bf4da9a8326b61a62934ec9e53b64970fd8fe"},
{file = "trio_websocket-0.9.2-py3-none-any.whl", hash = "sha256:5b558f6e83cc20a37c3b61202476c5295d1addf57bd65543364e0337e37ed2bc"},
]
tzdata = [ tzdata = [
{file = "tzdata-2021.5-py2.py3-none-any.whl", hash = "sha256:3eee491e22ebfe1e5cfcc97a4137cd70f092ce59144d81f8924a844de05ba8f5"}, {file = "tzdata-2021.5-py2.py3-none-any.whl", hash = "sha256:3eee491e22ebfe1e5cfcc97a4137cd70f092ce59144d81f8924a844de05ba8f5"},
{file = "tzdata-2021.5.tar.gz", hash = "sha256:68dbe41afd01b867894bbdfd54fa03f468cfa4f0086bfb4adcd8de8f24f3ee21"}, {file = "tzdata-2021.5.tar.gz", hash = "sha256:68dbe41afd01b867894bbdfd54fa03f468cfa4f0086bfb4adcd8de8f24f3ee21"},
@ -774,3 +1044,7 @@ virtualenv = [
{file = "virtualenv-20.10.0-py2.py3-none-any.whl", hash = "sha256:4b02e52a624336eece99c96e3ab7111f469c24ba226a53ec474e8e787b365814"}, {file = "virtualenv-20.10.0-py2.py3-none-any.whl", hash = "sha256:4b02e52a624336eece99c96e3ab7111f469c24ba226a53ec474e8e787b365814"},
{file = "virtualenv-20.10.0.tar.gz", hash = "sha256:576d05b46eace16a9c348085f7d0dc8ef28713a2cabaa1cf0aea41e8f12c9218"}, {file = "virtualenv-20.10.0.tar.gz", hash = "sha256:576d05b46eace16a9c348085f7d0dc8ef28713a2cabaa1cf0aea41e8f12c9218"},
] ]
wsproto = [
{file = "wsproto-1.0.0-py3-none-any.whl", hash = "sha256:d8345d1808dd599b5ffb352c25a367adb6157e664e140dbecba3f9bc007edb9f"},
{file = "wsproto-1.0.0.tar.gz", hash = "sha256:868776f8456997ad0d9720f7322b746bbe9193751b5b290b7f924659377c8c38"},
]

View file

@ -34,6 +34,7 @@ ofxtools = "^0.9.4"
click = "^8.0.3" click = "^8.0.3"
dateparser = "^1.1.0" dateparser = "^1.1.0"
requests = "^2.24.0" requests = "^2.24.0"
selenium = "^4.0.0"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
pytest = "^6.0.1" pytest = "^6.0.1"

View file

@ -7,8 +7,9 @@ from unittest.mock import call
from click.testing import CliRunner from click.testing import CliRunner
import ofx_processor.utils.config
from ofx_processor.utils import utils, ynab from ofx_processor.utils import utils, ynab
from ofx_processor.utils.ynab import config from ofx_processor.utils.config import config
class UtilsTestCase(unittest.TestCase): class UtilsTestCase(unittest.TestCase):
@ -38,13 +39,14 @@ class ConfigTestCase(unittest.TestCase):
# This is run at import time and the cli module is already imported before this test # This is run at import time and the cli module is already imported before this test
# so we need to re-run the add_command to make it available. # so we need to re-run the add_command to make it available.
cli.add_command(ynab.config, name="config") cli.add_command(ofx_processor.utils.config.config, name="config")
utils.discover_processors(cli) utils.discover_processors(cli)
self.cli = cli self.cli = cli
@mock.patch("click.edit") @mock.patch("click.edit")
@mock.patch( @mock.patch(
"ofx_processor.utils.ynab.DEFAULT_CONFIG_DIR", os.path.join("tests", "samples") "ofx_processor.utils.config.DEFAULT_CONFIG_DIR",
os.path.join("tests", "samples"),
) )
def test_config_edit(self, edit): def test_config_edit(self, edit):
runner = CliRunner() runner = CliRunner()
@ -55,11 +57,12 @@ class ConfigTestCase(unittest.TestCase):
@mock.patch("click.edit") @mock.patch("click.edit")
@mock.patch( @mock.patch(
"ofx_processor.utils.ynab.DEFAULT_CONFIG_FILENAME", "ofx_processor.utils.config.DEFAULT_CONFIG_FILENAME",
"config_broken_duplicate_key.ini", "config_broken_duplicate_key.ini",
) )
@mock.patch( @mock.patch(
"ofx_processor.utils.ynab.DEFAULT_CONFIG_DIR", os.path.join("tests", "samples") "ofx_processor.utils.config.DEFAULT_CONFIG_DIR",
os.path.join("tests", "samples"),
) )
def test_broken_config_file(self, edit): def test_broken_config_file(self, edit):
broken_files = [ broken_files = [
@ -70,12 +73,12 @@ class ConfigTestCase(unittest.TestCase):
"config_broken_missing_token.ini", "config_broken_missing_token.ini",
] ]
for name in broken_files: for name in broken_files:
with mock.patch("ofx_processor.utils.ynab.DEFAULT_CONFIG_FILENAME", name): with mock.patch("ofx_processor.utils.config.DEFAULT_CONFIG_FILENAME", name):
runner = CliRunner() runner = CliRunner()
result = runner.invoke( result = runner.invoke(
self.cli, ["revolut", "tests/samples/revolut.csv", "--keep"] self.cli, ["revolut", "-f", "tests/samples/revolut.csv", "--keep"]
) )
expected_filename = ynab.get_config_file_name() expected_filename = ofx_processor.utils.config.get_config_file_name()
self.assertIn( self.assertIn(
"Error while parsing config file", "Error while parsing config file",
result.output, result.output,
@ -83,36 +86,43 @@ class ConfigTestCase(unittest.TestCase):
) )
edit.assert_called_with(filename=expected_filename) edit.assert_called_with(filename=expected_filename)
@mock.patch("ofx_processor.utils.ynab.DEFAULT_CONFIG_FILENAME", "file.ini") @mock.patch("ofx_processor.utils.config.DEFAULT_CONFIG_FILENAME", "file.ini")
@mock.patch( @mock.patch(
"ofx_processor.utils.ynab.DEFAULT_CONFIG_DIR", "ofx_processor.utils.config.DEFAULT_CONFIG_DIR",
os.path.join("some", "config", "folder"), os.path.join("some", "config", "folder"),
) )
def test_get_config_file_name(self): def test_get_config_file_name(self):
expected = os.path.join("some", "config", "folder", "file.ini") expected = os.path.join("some", "config", "folder", "file.ini")
self.assertEqual(ynab.get_config_file_name(), expected) self.assertEqual(ofx_processor.utils.config.get_config_file_name(), expected)
@mock.patch("requests.post")
@mock.patch("click.edit") @mock.patch("click.edit")
@mock.patch("os.makedirs") @mock.patch("os.makedirs")
@mock.patch("ofx_processor.utils.ynab.DEFAULT_CONFIG_FILENAME", "notfound.ini") @mock.patch("ofx_processor.utils.config.DEFAULT_CONFIG_FILENAME", "notfound.ini")
@mock.patch( @mock.patch(
"ofx_processor.utils.ynab.DEFAULT_CONFIG_DIR", os.path.join("tests", "samples") "ofx_processor.utils.config.DEFAULT_CONFIG_DIR",
os.path.join("tests", "samples"),
) )
def test_missing_config_file(self, makedirs, edit): def test_missing_config_file(self, makedirs, edit, *args):
runner = CliRunner() runner = CliRunner()
with mock.patch("ofx_processor.utils.ynab.open", mock.mock_open()) as mock_file: with mock.patch(
"ofx_processor.utils.config.open", mock.mock_open()
) as mock_file:
result = runner.invoke( result = runner.invoke(
self.cli, ["revolut", "tests/samples/revolut.csv", "--keep"] self.cli, ["revolut", "-f", "tests/samples/revolut.csv", "--keep"]
) )
mock_file.assert_called_once() mock_file.assert_called_once()
makedirs.assert_called_once_with(ynab.DEFAULT_CONFIG_DIR, exist_ok=True) makedirs.assert_called_once_with(
ofx_processor.utils.config.DEFAULT_CONFIG_DIR, exist_ok=True
)
self.assertIn("Editing config file", result.output) self.assertIn("Editing config file", result.output)
expected_filename = ynab.get_config_file_name() expected_filename = ofx_processor.utils.config.get_config_file_name()
edit.assert_called_once_with(filename=expected_filename) edit.assert_called_once_with(filename=expected_filename)
# post.assert_not_called()
@mock.patch( @mock.patch(
"ofx_processor.utils.ynab.DEFAULT_CONFIG_DIR", os.path.join("tests", "samples") "ofx_processor.utils.config.DEFAULT_CONFIG_DIR", os.path.join("tests", "samples")
) )
class DataTestCase(unittest.TestCase): class DataTestCase(unittest.TestCase):
def setUp(self): def setUp(self):
@ -120,12 +130,13 @@ class DataTestCase(unittest.TestCase):
# This is run at import time and the cli module is already imported before this test # This is run at import time and the cli module is already imported before this test
# so we need to re-run the add_command to make it available. # so we need to re-run the add_command to make it available.
cli.add_command(ynab.config, name="config") cli.add_command(ofx_processor.utils.config.config, name="config")
utils.discover_processors(cli) utils.discover_processors(cli)
self.cli = cli self.cli = cli
@mock.patch("requests.post") @mock.patch("requests.post")
def test_file_is_deleted_by_default(self, *args): def test_file_is_deleted_by_default(self, post):
post.return_value.status_code = 201
runner = CliRunner() runner = CliRunner()
revolut_csv = "tests/samples/revolut.csv" revolut_csv = "tests/samples/revolut.csv"
backup_file = "tests/samples/backup.csv" backup_file = "tests/samples/backup.csv"
@ -135,7 +146,7 @@ class DataTestCase(unittest.TestCase):
try: try:
self.assertTrue(os.path.isfile(revolut_csv)) self.assertTrue(os.path.isfile(revolut_csv))
result = runner.invoke(self.cli, ["revolut", revolut_csv]) result = runner.invoke(self.cli, ["revolut", "-f", revolut_csv])
self.assertEqual(result.exit_code, 0) self.assertEqual(result.exit_code, 0)
self.assertFalse(os.path.isfile(revolut_csv)) self.assertFalse(os.path.isfile(revolut_csv))
finally: finally:
@ -144,7 +155,8 @@ class DataTestCase(unittest.TestCase):
os.unlink(backup_file) os.unlink(backup_file)
@mock.patch("requests.post") @mock.patch("requests.post")
def test_file_is_kept_with_option(self, *args): def test_file_is_kept_with_option(self, post):
post.return_value.status_code = 201
runner = CliRunner() runner = CliRunner()
revolut_csv = "tests/samples/revolut.csv" revolut_csv = "tests/samples/revolut.csv"
backup_file = "tests/samples/backup.csv" backup_file = "tests/samples/backup.csv"
@ -154,7 +166,9 @@ class DataTestCase(unittest.TestCase):
try: try:
self.assertTrue(os.path.isfile(revolut_csv)) self.assertTrue(os.path.isfile(revolut_csv))
result = runner.invoke(self.cli, ["revolut", revolut_csv, "--keep"]) result = runner.invoke(
self.cli, ["revolut", "--filename", revolut_csv, "--keep"]
)
self.assertEqual(result.exit_code, 0) self.assertEqual(result.exit_code, 0)
self.assertTrue(os.path.isfile(revolut_csv)) self.assertTrue(os.path.isfile(revolut_csv))
finally: finally:
@ -164,6 +178,7 @@ class DataTestCase(unittest.TestCase):
@mock.patch("requests.post") @mock.patch("requests.post")
def test_revolut_sends_data_only_created(self, post): def test_revolut_sends_data_only_created(self, post):
post.return_value.status_code = 201
post.return_value.json.return_value = { post.return_value.json.return_value = {
"data": { "data": {
"transactions": [ "transactions": [
@ -198,7 +213,7 @@ class DataTestCase(unittest.TestCase):
runner = CliRunner() runner = CliRunner()
result = runner.invoke( result = runner.invoke(
self.cli, ["revolut", "tests/samples/revolut.csv", "--keep"] self.cli, ["revolut", "-f", "tests/samples/revolut.csv", "--keep"]
) )
self.assertEqual(result.exit_code, 0) self.assertEqual(result.exit_code, 0)
@ -208,6 +223,7 @@ class DataTestCase(unittest.TestCase):
@mock.patch("requests.post") @mock.patch("requests.post")
def test_revolut_sends_data_some_created_some_duplicates(self, post): def test_revolut_sends_data_some_created_some_duplicates(self, post):
post.return_value.status_code = 201
post.return_value.json.return_value = { post.return_value.json.return_value = {
"data": { "data": {
"transactions": [ "transactions": [
@ -243,7 +259,7 @@ class DataTestCase(unittest.TestCase):
runner = CliRunner() runner = CliRunner()
result = runner.invoke( result = runner.invoke(
self.cli, ["revolut", "tests/samples/revolut.csv", "--keep"] self.cli, ["revolut", "-f", "tests/samples/revolut.csv", "--keep"]
) )
self.assertEqual(result.exit_code, 0) self.assertEqual(result.exit_code, 0)
@ -253,6 +269,7 @@ class DataTestCase(unittest.TestCase):
@mock.patch("requests.post") @mock.patch("requests.post")
def test_revolut_sends_data_only_duplicates(self, post): def test_revolut_sends_data_only_duplicates(self, post):
post.return_value.status_code = 201
post.return_value.json.return_value = { post.return_value.json.return_value = {
"data": { "data": {
"transactions": [], "transactions": [],
@ -273,7 +290,7 @@ class DataTestCase(unittest.TestCase):
runner = CliRunner() runner = CliRunner()
result = runner.invoke( result = runner.invoke(
self.cli, ["revolut", "tests/samples/revolut.csv", "--keep"] self.cli, ["revolut", "-f", "tests/samples/revolut.csv", "--keep"]
) )
self.assertEqual(result.exit_code, 0) self.assertEqual(result.exit_code, 0)
@ -283,6 +300,7 @@ class DataTestCase(unittest.TestCase):
@mock.patch("requests.post") @mock.patch("requests.post")
def test_bpvf_sends_to_ynab(self, post): def test_bpvf_sends_to_ynab(self, post):
post.return_value.status_code = 201
with open("tests/samples/bpvf_transactions.json", encoding="utf-8") as f: with open("tests/samples/bpvf_transactions.json", encoding="utf-8") as f:
expected_data = json.load(f) expected_data = json.load(f)
@ -290,7 +308,7 @@ class DataTestCase(unittest.TestCase):
expected_url = f"{ynab.BASE_URL}/budgets/<YOUR BUDGET ID>/transactions" expected_url = f"{ynab.BASE_URL}/budgets/<YOUR BUDGET ID>/transactions"
runner = CliRunner() runner = CliRunner()
runner.invoke(self.cli, ["bpvf", "tests/samples/bpvf.ofx", "--keep"]) runner.invoke(self.cli, ["bpvf", "-f", "tests/samples/bpvf.ofx", "--keep"])
post.assert_called_once_with( post.assert_called_once_with(
expected_url, json=expected_data, headers=expected_headers expected_url, json=expected_data, headers=expected_headers
@ -298,6 +316,7 @@ class DataTestCase(unittest.TestCase):
@mock.patch("requests.post") @mock.patch("requests.post")
def test_ce_sends_to_ynab(self, post): def test_ce_sends_to_ynab(self, post):
post.return_value.status_code = 201
with open("tests/samples/ce_transactions.json", encoding="utf-8") as f: with open("tests/samples/ce_transactions.json", encoding="utf-8") as f:
expected_data = json.load(f) expected_data = json.load(f)
@ -305,7 +324,7 @@ class DataTestCase(unittest.TestCase):
expected_url = f"{ynab.BASE_URL}/budgets/<YOUR BUDGET ID>/transactions" expected_url = f"{ynab.BASE_URL}/budgets/<YOUR BUDGET ID>/transactions"
runner = CliRunner() runner = CliRunner()
runner.invoke(self.cli, ["ce", "tests/samples/ce.ofx", "--keep"]) runner.invoke(self.cli, ["ce", "-f", "tests/samples/ce.ofx", "--keep"])
post.assert_called_once_with( post.assert_called_once_with(
expected_url, json=expected_data, headers=expected_headers expected_url, json=expected_data, headers=expected_headers
@ -313,6 +332,7 @@ class DataTestCase(unittest.TestCase):
@mock.patch("requests.post") @mock.patch("requests.post")
def test_lcl_sends_to_ynab(self, post): def test_lcl_sends_to_ynab(self, post):
post.return_value.status_code = 201
with open("tests/samples/lcl_transactions.json", encoding="utf-8") as f: with open("tests/samples/lcl_transactions.json", encoding="utf-8") as f:
expected_data = json.load(f) expected_data = json.load(f)
@ -320,7 +340,7 @@ class DataTestCase(unittest.TestCase):
expected_url = f"{ynab.BASE_URL}/budgets/<YOUR BUDGET ID>/transactions" expected_url = f"{ynab.BASE_URL}/budgets/<YOUR BUDGET ID>/transactions"
runner = CliRunner() runner = CliRunner()
runner.invoke(self.cli, ["lcl", "tests/samples/lcl.ofx", "--keep"]) runner.invoke(self.cli, ["lcl", "-f", "tests/samples/lcl.ofx", "--keep"])
post.assert_called_once_with( post.assert_called_once_with(
expected_url, json=expected_data, headers=expected_headers expected_url, json=expected_data, headers=expected_headers

View file

@ -2,13 +2,15 @@ import json
import unittest import unittest
from unittest import mock from unittest import mock
import ofx_processor.utils.config
from ofx_processor.utils import ynab from ofx_processor.utils import ynab
class YNABIntegrationTestCase(unittest.TestCase): class YNABIntegrationTestCase(unittest.TestCase):
@mock.patch("requests.post") @mock.patch("requests.post")
def test_data_sent_to_ynab(self, post): def test_data_sent_to_ynab(self, post):
ynab.DEFAULT_CONFIG_DIR = "tests/samples" post.return_value.status_code = 201
ofx_processor.utils.config.DEFAULT_CONFIG_DIR = "tests/samples"
with open("tests/samples/revolut_expected.json", encoding="utf-8") as f: with open("tests/samples/revolut_expected.json", encoding="utf-8") as f:
transactions = json.load(f) transactions = json.load(f)