From c6ace4f710f8c5057fe2cfbf941ab3a225ea8a45 Mon Sep 17 00:00:00 2001 From: Sungho Cho Date: Fri, 6 Dec 2019 15:30:48 -0500 Subject: [PATCH] This work is from the previous pr, I have merged it all together so its possible to continue their fine work. Add original currency and amount fields to Bill model Add default currency to Project model Add minor fix to String length Reorganize the new migration file Crete a migration file for Project.default_currency Change models.py to have Project.default_currency Added new fields to to_serialize() Update bill view to display original currencies and original amounts Minor change to new labels Add new fields to tests Include default_currency field in tests.py Datafield entry in forms adding a stray file added requests to the requirements file Added another exception for catching errors related to mail Testing to find the issue revert change Adding a removed change to list_bills.html template added currency conversion to the CurrencyConversion class fixed typo in function name Edit BillForm to have currency dropdown and convert functionality Show currency dropdown in BillForm Fix minor issues with BillForm task Edit tests.py to include missing original_currency and amount fields Fix failing tests Fix other failing tests Final fix to tests Ran black on the project directory Make small ammendments to tests Temporarily set maxDiff as None Fix test failure in test_export() formatted util file so it could pass formatting tests changed the structure of the table in the bill view to be more reader friendly, by adding the different curriencies in their respective columns Cleaning up pr with comments from previous prs Signed-off-by: dark0dave Now caching respone for one day to ensure it is not call often Signed-off-by: dark0dave Updated migration and currency converter to help existing bills Signed-off-by: dark0dave --- .gitignore | 6 +- ihatemoney/currency_convertor.py | 46 ++++++++++++ ihatemoney/forms.py | 62 ++++++++++++++++ .../versions/927ed575acbd_add_currencies.py | 73 +++++++++++++++++++ ihatemoney/models.py | 21 +++++- ihatemoney/run.py | 4 + ihatemoney/templates/forms.html | 5 ++ ihatemoney/templates/list_bills.html | 23 +++++- ihatemoney/tests/tests.py | 48 +++++++++++- ihatemoney/web.py | 19 ++++- setup.cfg | 2 + 11 files changed, 299 insertions(+), 10 deletions(-) create mode 100644 ihatemoney/currency_convertor.py create mode 100644 ihatemoney/migrations/versions/927ed575acbd_add_currencies.py diff --git a/.gitignore b/.gitignore index d8d18940..9e3c42ce 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ -ihatemoney.cfg *.pyc *.egg-info dist @@ -13,3 +12,8 @@ build .pytest_cache ihatemoney/budget.db .idea/ +.envrc +.DS_Store +.idea +.python-version + diff --git a/ihatemoney/currency_convertor.py b/ihatemoney/currency_convertor.py new file mode 100644 index 00000000..efb34b8a --- /dev/null +++ b/ihatemoney/currency_convertor.py @@ -0,0 +1,46 @@ +import requests +from cachetools import cached, TTLCache + + +class Singleton(type): + _instances = {} + + def __call__(cls, *args, **kwargs): + if cls not in cls._instances: + cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs) + return cls._instances[cls] + + +class CurrencyConverter(object, metaclass=Singleton): + # Get exchange rates + default = "No Currency" + api_url = "https://api.exchangeratesapi.io/latest?base=USD" + + def __init__(self): + pass + + @cached(cache=TTLCache(maxsize=1, ttl=86400)) + def get_rates(self): + rates = requests.get(self.api_url).json()["rates"] + rates[self.default] = 1.0 + return rates + + def get_currencies(self): + rates = [rate for rate in self.get_rates()] + rates.sort(key=lambda rate: "" if rate == self.default else rate) + return rates + + def exchange_currency(self, amount, source_currency, dest_currency): + if ( + source_currency == dest_currency + or source_currency == self.default + or dest_currency == self.default + ): + return amount + + rates = self.get_rates() + source_rate = rates[source_currency] + dest_rate = rates[dest_currency] + new_amount = (float(amount) / source_rate) * dest_rate + # round to two digits because we are dealing with money + return round(new_amount, 2) diff --git a/ihatemoney/forms.py b/ihatemoney/forms.py index 989b3022..9277c92f 100644 --- a/ihatemoney/forms.py +++ b/ihatemoney/forms.py @@ -5,12 +5,15 @@ import email_validator from flask import request from flask_babel import lazy_gettext as _ from flask_wtf.file import FileAllowed, FileField, FileRequired +import copy + from flask_wtf.form import FlaskForm from jinja2 import Markup from werkzeug.security import check_password_hash, generate_password_hash from wtforms.fields.core import SelectField, SelectMultipleField from wtforms.fields.html5 import DateField, DecimalField, URLField from wtforms.fields.simple import BooleanField, PasswordField, StringField, SubmitField +from wtforms.fields.core import Label from wtforms.validators import ( DataRequired, Email, @@ -22,6 +25,7 @@ from wtforms.validators import ( from ihatemoney.models import LoggingMode, Person, Project from ihatemoney.utils import eval_arithmetic_expression, slugify +from ihatemoney.currency_convertor import CurrencyConverter def strip_filter(string): @@ -31,6 +35,18 @@ def strip_filter(string): return string +def get_editprojectform_for(project, **kwargs): + """Return an instance of EditProjectForm configured for a particular project. + """ + form = EditProjectForm(**kwargs) + choices = copy.copy(form.default_currency.choices) + choices.sort( + key=lambda rates: "" if rates[0] == project.default_currency else rates[0] + ) + form.default_currency.choices = choices + return form + + def get_billform_for(project, set_default=True, **kwargs): """Return an instance of BillForm configured for a particular project. @@ -39,6 +55,23 @@ def get_billform_for(project, set_default=True, **kwargs): """ form = BillForm(**kwargs) + if form.original_currency.data == "None": + form.original_currency.data = project.default_currency + + if form.original_currency.data != CurrencyConverter.default: + choices = copy.copy(form.original_currency.choices) + choices.remove((CurrencyConverter.default, CurrencyConverter.default)) + choices.sort( + key=lambda rates: "" if rates[0] == project.default_currency else rates[0] + ) + form.original_currency.choices = choices + else: + form.original_currency.render_kw = {"default": True} + form.original_currency.data = CurrencyConverter.default + + form.original_currency.label = Label( + "original_currency", "Currency (Default: %s)" % (project.default_currency) + ) active_members = [(m.id, m.name) for m in project.active_members] form.payed_for.choices = form.payer.choices = active_members @@ -89,6 +122,15 @@ class EditProjectForm(FlaskForm): contact_email = StringField(_("Email"), validators=[DataRequired(), Email()]) project_history = BooleanField(_("Enable project history")) ip_recording = BooleanField(_("Use IP tracking for project history")) + currency_helper = CurrencyConverter() + default_currency = SelectField( + _("Default Currency"), + choices=[ + (currency_name, currency_name) + for currency_name in currency_helper.get_currencies() + ], + validators=[DataRequired()], + ) @property def logging_preference(self): @@ -112,6 +154,7 @@ class EditProjectForm(FlaskForm): password=generate_password_hash(self.password.data), contact_email=self.contact_email.data, logging_preference=self.logging_preference, + default_currency=self.default_currency.data, ) return project @@ -125,6 +168,7 @@ class EditProjectForm(FlaskForm): project.contact_email = self.contact_email.data project.logging_preference = self.logging_preference + project.default_currency = self.default_currency.data return project @@ -199,6 +243,15 @@ class BillForm(FlaskForm): what = StringField(_("What?"), validators=[DataRequired()]) payer = SelectField(_("Payer"), validators=[DataRequired()], coerce=int) amount = CalculatorStringField(_("Amount paid"), validators=[DataRequired()]) + currency_helper = CurrencyConverter() + original_currency = SelectField( + _("Currency"), + choices=[ + (currency_name, currency_name) + for currency_name in currency_helper.get_currencies() + ], + validators=[DataRequired()], + ) external_link = URLField( _("External link"), validators=[Optional()], @@ -217,6 +270,10 @@ class BillForm(FlaskForm): bill.external_link = self.external_link.data bill.date = self.date.data bill.owers = [Person.query.get(ower, project) for ower in self.payed_for.data] + bill.original_currency = self.original_currency.data + bill.converted_amount = self.currency_helper.exchange_currency( + bill.amount, bill.original_currency, project.default_currency + ) return bill def fake_form(self, bill, project): @@ -226,6 +283,10 @@ class BillForm(FlaskForm): bill.external_link = "" bill.date = self.date bill.owers = [Person.query.get(ower, project) for ower in self.payed_for] + bill.original_currency = CurrencyConverter.default + bill.converted_amount = self.currency_helper.exchange_currency( + bill.amount, bill.original_currency, project.default_currency + ) return bill @@ -234,6 +295,7 @@ class BillForm(FlaskForm): self.amount.data = bill.amount self.what.data = bill.what self.external_link.data = bill.external_link + self.original_currency.data = bill.original_currency self.date.data = bill.date self.payed_for.data = [int(ower.id) for ower in bill.owers] diff --git a/ihatemoney/migrations/versions/927ed575acbd_add_currencies.py b/ihatemoney/migrations/versions/927ed575acbd_add_currencies.py new file mode 100644 index 00000000..b70d9025 --- /dev/null +++ b/ihatemoney/migrations/versions/927ed575acbd_add_currencies.py @@ -0,0 +1,73 @@ +"""Add currencies + +Revision ID: 927ed575acbd +Revises: cb038f79982e +Create Date: 2020-04-25 14:49:41.136602 + +""" + +# revision identifiers, used by Alembic. +revision = "927ed575acbd" +down_revision = "cb038f79982e" + +from alembic import op +import sqlalchemy as sa +from ihatemoney.currency_convertor import CurrencyConverter + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("bill", sa.Column("converted_amount", sa.Float(), nullable=True)) + op.add_column( + "bill", + sa.Column( + "original_currency", + sa.String(length=3), + server_default=CurrencyConverter.default, + nullable=True, + ), + ) + op.add_column( + "bill_version", + sa.Column("converted_amount", sa.Float(), autoincrement=False, nullable=True), + ) + op.add_column( + "bill_version", + sa.Column( + "original_currency", sa.String(length=3), autoincrement=False, nullable=True + ), + ) + op.add_column( + "project", + sa.Column( + "default_currency", + sa.String(length=3), + server_default=CurrencyConverter.default, + nullable=True, + ), + ) + op.add_column( + "project_version", + sa.Column( + "default_currency", sa.String(length=3), autoincrement=False, nullable=True + ), + ) + # ### end Alembic commands ### + op.execute( + """ + UPDATE bill + SET converted_amount = amount + WHERE converted_amount IS NULL + """ + ) + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("project_version", "default_currency") + op.drop_column("project", "default_currency") + op.drop_column("bill_version", "original_currency") + op.drop_column("bill_version", "converted_amount") + op.drop_column("bill", "original_currency") + op.drop_column("bill", "converted_amount") + # ### end Alembic commands ### diff --git a/ihatemoney/models.py b/ihatemoney/models.py index 742bc8ca..9e474c60 100644 --- a/ihatemoney/models.py +++ b/ihatemoney/models.py @@ -71,6 +71,7 @@ class Project(db.Model): members = db.relationship("Person", backref="project") query_class = ProjectQuery + default_currency = db.Column(db.String(3)) @property def _to_serialize(self): @@ -80,6 +81,7 @@ class Project(db.Model): "contact_email": self.contact_email, "logging_preference": self.logging_preference.value, "members": [], + "default_currency": self.default_currency, } balance = self.balance @@ -128,7 +130,10 @@ class Project(db.Model): { "member": member, "paid": sum( - [bill.amount for bill in self.get_member_bills(member.id).all()] + [ + bill.converted_amount + for bill in self.get_member_bills(member.id).all() + ] ), "spent": sum( [ @@ -151,7 +156,7 @@ class Project(db.Model): """ monthly = defaultdict(lambda: defaultdict(float)) for bill in self.get_bills().all(): - monthly[bill.date.year][bill.date.month] += bill.amount + monthly[bill.date.year][bill.date.month] += bill.converted_amount return monthly @property @@ -432,6 +437,9 @@ class Bill(db.Model): what = db.Column(db.UnicodeText) external_link = db.Column(db.UnicodeText) + original_currency = db.Column(db.String(3)) + converted_amount = db.Column(db.Float) + archive = db.Column(db.Integer, db.ForeignKey("archive.id")) @property @@ -445,9 +453,11 @@ class Bill(db.Model): "creation_date": self.creation_date, "what": self.what, "external_link": self.external_link, + "original_currency": self.original_currency, + "converted_amount": self.converted_amount, } - def pay_each(self): + def pay_each_default(self, amount): """Compute what each share has to pay""" if self.owers: weights = ( @@ -455,13 +465,16 @@ class Bill(db.Model): .join(billowers, Bill) .filter(Bill.id == self.id) ).scalar() - return self.amount / weights + return amount / weights else: return 0 def __str__(self): return self.what + def pay_each(self): + return self.pay_each_default(self.converted_amount) + def __repr__(self): return ( f" + {{ input(form.default_currency) }}
{{ _("delete") }} @@ -122,6 +124,9 @@ {{ input(form.what, inline=True) }} {{ input(form.payer, inline=True, class="form-control custom-select") }} {{ input(form.amount, inline=True) }} + {% if not form.original_currency.render_kw %} + {{ input(form.original_currency, inline=True) }} + {% endif %} {{ input(form.external_link, inline=True) }}
diff --git a/ihatemoney/templates/list_bills.html b/ihatemoney/templates/list_bills.html index 0f2a68a5..261adea8 100644 --- a/ihatemoney/templates/list_bills.html +++ b/ihatemoney/templates/list_bills.html @@ -111,7 +111,19 @@
- + + + {% for bill in bills.items %} @@ -130,7 +142,14 @@ {%- else -%} {{ bill.owers|join(', ', 'name') }} {%- endif %} - + +
{{ _("When?") }}{{ _("Who paid?") }}{{ _("For what?") }}{{ _("For whom?") }}{{ _("How much?") }}{{ _("Actions") }}
{{ _("When?") }} + {{ _("Who paid?") }} + {{ _("For what?") }} + {{ _("For whom?") }} + {{ _("How much?") }} + {% if g.project.default_currency != "No Currency" %} + {{ _("Amount in ") }}{{ g.project.default_currency }} + {%- else -%} + {{ _("Amount") }} + {% endif %} + {{ _("Actions") }}
{{ "%0.2f"|format(bill.amount) }} ({{ "%0.2f"|format(bill.pay_each()) }} {{ _("each") }}) + {% if bill.original_currency != "No Currency" %} + {{ "%0.2f"|format(bill.amount) }} {{bill.original_currency}} ({{ "%0.2f"|format(bill.pay_each_default(bill.amount)) }} {{bill.original_currency}} {{ _(" each") }}) + {%- else -%} + {{ "%0.2f"|format(bill.amount) }} ({{ "%0.2f"|format(bill.pay_each_default(bill.amount)) }} {{ _(" each") }}) + {% endif %} + {{ "%0.2f"|format(bill.converted_amount) }} {{ _('edit') }} {{ _('delete') }} diff --git a/ihatemoney/tests/tests.py b/ihatemoney/tests/tests.py index 62cb0485..32121723 100644 --- a/ihatemoney/tests/tests.py +++ b/ihatemoney/tests/tests.py @@ -6,7 +6,7 @@ import json import os from time import sleep import unittest -from unittest.mock import patch +from unittest.mock import patch, MagicMock from flask import session from flask_testing import TestCase @@ -15,6 +15,7 @@ from werkzeug.security import check_password_hash, generate_password_hash from ihatemoney import history, models, utils from ihatemoney.manage import DeleteProject, GenerateConfig, GeneratePasswordHash +from ihatemoney.currency_convertor import CurrencyConverter from ihatemoney.run import create_app, db, load_configuration from ihatemoney.versioning import LoggingMode @@ -59,6 +60,7 @@ class BaseTestCase(TestCase): "id": name, "password": name, "contact_email": f"{name}@notmyidea.org", + "default_currency": "USD", }, ) @@ -68,6 +70,7 @@ class BaseTestCase(TestCase): name=str(name), password=generate_password_hash(name), contact_email=f"{name}@notmyidea.org", + default_currency="USD", ) models.db.session.add(project) models.db.session.commit() @@ -254,6 +257,7 @@ class BudgetTestCase(IhatemoneyTestCase): "id": "raclette", "password": "party", "contact_email": "raclette@notmyidea.org", + "default_currency": "USD", }, ) @@ -273,6 +277,7 @@ class BudgetTestCase(IhatemoneyTestCase): "id": "raclette", # already used ! "password": "party", "contact_email": "raclette@notmyidea.org", + "default_currency": "USD", }, ) @@ -290,6 +295,7 @@ class BudgetTestCase(IhatemoneyTestCase): "id": "raclette", "password": "party", "contact_email": "raclette@notmyidea.org", + "default_currency": "USD", }, ) @@ -310,6 +316,7 @@ class BudgetTestCase(IhatemoneyTestCase): "id": "raclette", "password": "party", "contact_email": "raclette@notmyidea.org", + "default_currency": "USD", }, ) @@ -329,6 +336,7 @@ class BudgetTestCase(IhatemoneyTestCase): "id": "raclette", "password": "party", "contact_email": "raclette@notmyidea.org", + "default_currency": "USD", }, ) @@ -841,6 +849,7 @@ class BudgetTestCase(IhatemoneyTestCase): "contact_email": "alexis@notmyidea.org", "password": "didoudida", "logging_preference": LoggingMode.ENABLED.value, + "default_currency": "USD", } resp = self.client.post("/raclette/edit", data=new_data, follow_redirects=True) @@ -849,6 +858,7 @@ class BudgetTestCase(IhatemoneyTestCase): self.assertEqual(project.name, new_data["name"]) self.assertEqual(project.contact_email, new_data["contact_email"]) + self.assertEqual(project.default_currency, new_data["default_currency"]) self.assertTrue(check_password_hash(project.password, new_data["password"])) # Editing a project with a wrong email address should fail @@ -1099,6 +1109,7 @@ class BudgetTestCase(IhatemoneyTestCase): "payer": 1, "payed_for": [1, 2, 3, 4], "amount": "10.0", + "original_currency": "USD", }, ) @@ -1110,6 +1121,7 @@ class BudgetTestCase(IhatemoneyTestCase): "payer": 2, "payed_for": [1, 3], "amount": "200", + "original_currency": "USD", }, ) @@ -1121,6 +1133,7 @@ class BudgetTestCase(IhatemoneyTestCase): "payer": 3, "payed_for": [2], "amount": "13.33", + "original_currency": "USD", }, ) @@ -1425,6 +1438,7 @@ class APITestCase(IhatemoneyTestCase): "id": id, "password": password, "contact_email": contact, + "default_currency": "USD", }, ) @@ -1486,6 +1500,7 @@ class APITestCase(IhatemoneyTestCase): "id": "raclette", "password": "raclette", "contact_email": "not-an-email", + "default_currency": "USD", }, ) @@ -1514,6 +1529,7 @@ class APITestCase(IhatemoneyTestCase): "members": [], "name": "raclette", "contact_email": "raclette@notmyidea.org", + "default_currency": "USD", "id": "raclette", "logging_preference": 1, } @@ -1525,6 +1541,7 @@ class APITestCase(IhatemoneyTestCase): "/api/projects/raclette", data={ "contact_email": "yeah@notmyidea.org", + "default_currency": "USD", "password": "raclette", "name": "The raclette party", "project_history": "y", @@ -1542,6 +1559,7 @@ class APITestCase(IhatemoneyTestCase): expected = { "name": "The raclette party", "contact_email": "yeah@notmyidea.org", + "default_currency": "USD", "members": [], "id": "raclette", "logging_preference": 1, @@ -1554,6 +1572,7 @@ class APITestCase(IhatemoneyTestCase): "/api/projects/raclette", data={ "contact_email": "yeah@notmyidea.org", + "default_currency": "USD", "password": "tartiflette", "name": "The raclette party", }, @@ -1776,6 +1795,8 @@ class APITestCase(IhatemoneyTestCase): "amount": 25.0, "date": "2011-08-10", "id": 1, + "converted_amount": 25.0, + "original_currency": "USD", "external_link": "https://raclette.fr", } @@ -1845,6 +1866,8 @@ class APITestCase(IhatemoneyTestCase): "amount": 25.0, "date": "2011-09-10", "external_link": "https://raclette.fr", + "converted_amount": 25.0, + "original_currency": "USD", "id": 1, } @@ -1922,6 +1945,8 @@ class APITestCase(IhatemoneyTestCase): "date": "2011-08-10", "id": id, "external_link": "", + "original_currency": "USD", + "converted_amount": expected_amount, } got = json.loads(req.data.decode("utf-8")) @@ -2064,6 +2089,8 @@ class APITestCase(IhatemoneyTestCase): "date": "2011-08-10", "id": 1, "external_link": "", + "converted_amount": 25.0, + "original_currency": "USD", } got = json.loads(req.data.decode("utf-8")) self.assertEqual( @@ -2106,6 +2133,7 @@ class APITestCase(IhatemoneyTestCase): "id": "raclette", "name": "raclette", "logging_preference": 1, + "default_currency": "USD", } self.assertStatus(200, req) @@ -2850,5 +2878,23 @@ class HistoryTestCase(IhatemoneyTestCase): self.assertEqual(len(history_list), 6) +class TestCurrencyConverter(unittest.TestCase): + converter = CurrencyConverter() + mock_data = {"USD": 1, "EUR": 0.8115} + converter.get_rates = MagicMock(return_value=mock_data) + + def test_only_one_instance(self): + one = id(CurrencyConverter()) + two = id(CurrencyConverter()) + self.assertEqual(one, two) + + def test_get_currencies(self): + self.assertCountEqual(self.converter.get_currencies(), ["USD", "EUR"]) + + def test_exchange_currency(self): + result = self.converter.exchange_currency(100, "USD", "EUR") + self.assertEqual(result, 81.15) + + if __name__ == "__main__": unittest.main() diff --git a/ihatemoney/web.py b/ihatemoney/web.py index 18ce0c7a..bbc98c4d 100644 --- a/ihatemoney/web.py +++ b/ihatemoney/web.py @@ -37,10 +37,10 @@ from sqlalchemy_continuum import Operation from werkzeug.exceptions import NotFound from werkzeug.security import check_password_hash, generate_password_hash +from ihatemoney.currency_convertor import CurrencyConverter from ihatemoney.forms import ( AdminAuthenticationForm, AuthenticationForm, - EditProjectForm, InviteForm, MemberForm, PasswordReminder, @@ -48,6 +48,7 @@ from ihatemoney.forms import ( ResetPasswordForm, UploadForm, get_billform_for, + get_editprojectform_for, ) from ihatemoney.history import get_history, get_history_queries from ihatemoney.models import Bill, LoggingMode, Person, Project, db @@ -376,7 +377,7 @@ def reset_password(): @main.route("//edit", methods=["GET", "POST"]) def edit_project(): - edit_form = EditProjectForm() + edit_form = get_editprojectform_for(g.project) import_form = UploadForm() # Import form if import_form.validate_on_submit(): @@ -391,6 +392,18 @@ def edit_project(): # Edit form if edit_form.validate_on_submit(): project = edit_form.update(g.project) + # Update converted currency + if project.default_currency != CurrencyConverter.default: + for bill in project.get_bills(): + + if bill.original_currency == CurrencyConverter.default: + bill.original_currency = project.default_currency + + bill.converted_amount = CurrencyConverter().exchange_currency( + bill.amount, bill.original_currency, project.default_currency + ) + db.session.add(bill) + db.session.add(project) db.session.commit() @@ -478,6 +491,7 @@ def import_project(file, project): form.date = parse(b["date"]) form.payer = id_dict[b["payer_name"]] form.payed_for = owers_id + form.original_currency = b.get("original_currency") db.session.add(form.fake_form(bill, project)) @@ -543,6 +557,7 @@ def demo(): name="demonstration", password=generate_password_hash("demo"), contact_email="demo@notmyidea.org", + default_currency="EUR", ) db.session.add(project) db.session.commit() diff --git a/setup.cfg b/setup.cfg index 50a24a41..d632d515 100644 --- a/setup.cfg +++ b/setup.cfg @@ -23,6 +23,7 @@ include_package_data = True zip_safe = False install_requires = blinker==1.4 + cachetools==4.1.0 debts==0.5 email_validator==1.0.5 Flask-Babel==1.0.0 @@ -37,6 +38,7 @@ install_requires = Flask==1.1.2 itsdangerous==1.1.0 Jinja2==2.11.2 + requests==2.22.0 SQLAlchemy-Continuum==1.3.9 [options.extras_require]