pax_global_header00006660000000000000000000000064146202247220014513gustar00rootroot0000000000000052 comment=c166c259294114b03f12cd18151066e4b2ff9b5a django-historical-currencies-0.0.3/000077500000000000000000000000001462022472200172545ustar00rootroot00000000000000django-historical-currencies-0.0.3/.github/000077500000000000000000000000001462022472200206145ustar00rootroot00000000000000django-historical-currencies-0.0.3/.github/workflows/000077500000000000000000000000001462022472200226515ustar00rootroot00000000000000django-historical-currencies-0.0.3/.github/workflows/analysis.yml000066400000000000000000000011341462022472200252160ustar00rootroot00000000000000name: Static Analysis on: [push, pull_request] jobs: check: runs-on: ubuntu-latest strategy: fail-fast: false matrix: tox_env: - black - codespell - flake8 - mypy name: ${{ matrix.tox_env }} steps: - uses: actions/checkout@v4 - name: Setup Python "3.12" uses: actions/setup-python@v5 with: python-version: "3.12" - name: Install tox run: python -m pip install tox - name: Run tox run: python -m tox --skip-missing-interpreters false -e ${{ matrix.tox_env }} django-historical-currencies-0.0.3/.github/workflows/django.yml000066400000000000000000000047631462022472200246500ustar00rootroot00000000000000name: Django CI on: [push, pull_request] jobs: tests: runs-on: ubuntu-latest name: Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }} (Allowed Failures - ${{ matrix.django-version == 'main' }} ) strategy: max-parallel: 4 matrix: python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12'] django-version: ['2.2.0', '3.0.0', '3.1.0', '3.2.0', '4.0.0', '4.1.0', '4.2.0', '5.0.0', 'main'] exclude: - django-version: '4.0.0' python-version: '3.7' - django-version: '4.1.0' python-version: '3.7' - django-version: '4.2.0' python-version: '3.7' - django-version: '5.0.0' python-version: '3.7' - django-version: 'main' python-version: '3.7' - django-version: '5.0.0' python-version: '3.8' - django-version: 'main' python-version: '3.8' - django-version: '5.0.0' python-version: '3.9' - django-version: 'main' python-version: '3.9' - django-version: '2.2.0' python-version: '3.10' - django-version: '2.2.0' python-version: '3.11' - django-version: '2.2.0' python-version: '3.12' - django-version: '3.0.0' python-version: '3.12' - django-version: '3.1.0' python-version: '3.12' - django-version: '3.2.0' python-version: '3.12' - django-version: '4.0.0' python-version: '3.12' steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: allow-prereleases: true python-version: ${{ matrix.python-version }} - name: Install Dependencies continue-on-error: ${{ matrix.django-version == 'main' }} run: | python -m pip install --upgrade pip pip install -e . - name: Install Django Release run: | pip install -U django~=${{ matrix.django-version }} if: matrix.django-version != 'main' - name: Install Django Main continue-on-error: ${{ matrix.django-version == 'main' }} run: | pip install -U 'https://github.com/django/django/archive/main.tar.gz' if: matrix.django-version == 'main' - name: Run Tests continue-on-error: ${{ matrix.django-version == 'main' }} run: | export PYTHONWARNINGS=always python runtests.py django-historical-currencies-0.0.3/.github/workflows/release.yml000066400000000000000000000012571462022472200250210ustar00rootroot00000000000000name: Publish Releases on: release: types: - published jobs: pypi-publish: name: Upload release to PyPI runs-on: ubuntu-latest environment: release permissions: id-token: write steps: - name: Check out code uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v4 with: python-version: '3.x' - name: Install dependencies run: | python -m pip install --upgrade pip pip install build - name: Build package run: python -m build - name: Publish package distributions to PyPI uses: pypa/gh-action-pypi-publish@release/v1 django-historical-currencies-0.0.3/.gitignore000066400000000000000000000001251462022472200212420ustar00rootroot00000000000000!/.github/ !/.gitignore *~ .* /*.egg-info/ /dist/ /tests/db.sqlite /ve/ __pycache__/ django-historical-currencies-0.0.3/CHANGELOG.md000066400000000000000000000006631462022472200210720ustar00rootroot00000000000000# Changelog ## 0.0.3 * `exchange.latest_rate`: Always return 1 if the source and target currencies are identical. * Declare that we are typed (and run mypy in CI). ## 0.0.2post1 * Include the template required by `{% currency_choices_options %}` in the Python package. ## 0.0.2 * Add `currency_choices` template tags for rendering currency selectors. ## 0.0.1post1 * Improve package metadata. ## 0.0.1 * Initial release. django-historical-currencies-0.0.3/LICENSE000066400000000000000000000013661462022472200202670ustar00rootroot00000000000000Copyright 2022-2023, Stefano Rivera Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. django-historical-currencies-0.0.3/MANIFEST.in000066400000000000000000000001121462022472200210040ustar00rootroot00000000000000include LICENSE recursive-include historical_currencies/templates/ *.html django-historical-currencies-0.0.3/README.md000066400000000000000000000054341462022472200205410ustar00rootroot00000000000000# Django currencies with historical exchange rates This is a fairly minimal Django app that renders amounts of currencies, stores historical exchange rates in the database, and performs currency conversion. When working with any historical multi-currency data, one often needs to be able to perform exchange rate calculations with historical rates. This allows the conversion to be accurately reproduced in the future. Exchange Rates are stored in a simple database table, with 1 row per rate per date. Conversions can be done directly, or across a base currency using 2 rates. Formatting is defined by [`iso4217`](https://pypi.org/project/iso4217/). Exchange Rate data can be sourced from: * The [European ECB Reference Rates](https://www.ecb.europa.eu/stats/policy_and_exchange_rates/euro_reference_exchange_rates/html/index.en.html). Public, provides rates for 41 currencies against EUR. * [openexchangerates.org](https://openexchangerates.org/). Requires (free) registration, provides 169 currencies against USD. ## Installation 1. Install `django-historical-currencies` in your Python environment. 1. Add `historical_currencies` to `INSTALLED_APPS` in your `settings.py`. 1. Import yesterday's exchange rates to get started: `manage.py import_ecb_exchangerates --daily`. 1. Configure a periodic task (e.g. cron, systemd timer, celery beat) to import daily exchange rates. ## Settings * `MAX_EXCHANGE_RATE_AGE`: How many days old can an exchange rate be treated as current? Optional settings, only required for OpenExchangeRates.org import: * `OPEN_EXCHANGE_RATES_APP_ID`: OpenExchangeRates App ID. * `OPEN_EXCHANGE_RATES_BASE_CURRENCY`: Base currency. ## Usage In code, amounts can be converted using the `historical_currencies.exchange.exchange()` method. In templates, this module represents financial amounts as tuple of `(Decimal, str(currency-code))`. The recommended approach is to add properties to your Django models to return this tuple for amounts. In a template the amount can be displayed or converted: ``` {% load currency_format %} Assuming my_amount = (Decimal(10), 'USD') Display: {{ my_amount|currency }} -> 10.00 USD Exchange at the latest rate: {{ my_amount|exchange:"EUR" }} -> 9.06 EUR ``` ### Currency Selectors: There are two template tags to help render currency selectors. A high-level one that renders HTML: ``` {% load currency_choices %} ``` And a low-level one that returns a list of currencies: ``` {% load currency_choices %} ``` ## License This Django app is available under the terms of the ISC license, see `LICENSE`. django-historical-currencies-0.0.3/historical_currencies/000077500000000000000000000000001462022472200236375ustar00rootroot00000000000000django-historical-currencies-0.0.3/historical_currencies/__init__.py000066400000000000000000000000261462022472200257460ustar00rootroot00000000000000__version__ = "0.0.3" django-historical-currencies-0.0.3/historical_currencies/admin.py000066400000000000000000000004001462022472200252730ustar00rootroot00000000000000from django.contrib import admin from historical_currencies.models import ExchangeRate @admin.register(ExchangeRate) class ExchangeRateAdmin(admin.ModelAdmin): list_display = ("date", "currency", "base_currency", "rate") date_hierarchy = "date" django-historical-currencies-0.0.3/historical_currencies/apps.py000066400000000000000000000002431462022472200251530ustar00rootroot00000000000000from django.apps import AppConfig class CurrenciesConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "historical_currencies" django-historical-currencies-0.0.3/historical_currencies/choices.py000066400000000000000000000012771462022472200256350ustar00rootroot00000000000000from iso4217 import Currency def iter_choices(exclude_special=True): """Iterator that produces a list of (unsorted) currency choices""" for currency in Currency: # Generally X currencies aren't currencies (except the ones that are) if ( exclude_special and currency.code.startswith("X") and currency.code not in ("XAF", "XCD", "XOF", "XPF") ): continue yield ( currency.code, f"{currency.code} ({currency.currency_name})", ) def currency_choices(**kwargs): """A list of CURRENCY: Descriptive Name for use in Django choices""" return sorted(list(iter_choices(**kwargs))) django-historical-currencies-0.0.3/historical_currencies/exceptions.py000066400000000000000000000000631462022472200263710ustar00rootroot00000000000000class ExchangeRateUnavailable(Exception): pass django-historical-currencies-0.0.3/historical_currencies/exchange.py000066400000000000000000000103051462022472200257720ustar00rootroot00000000000000import datetime from functools import lru_cache from decimal import Decimal from typing import Iterator, List, Optional, Tuple from django.db.models import Q from django.conf import settings from historical_currencies.exceptions import ExchangeRateUnavailable from historical_currencies.models import ExchangeRate TWOPLACES = Decimal(10) ** -2 try: from functools import cache except ImportError: # Python < 3.9 cache = lru_cache(maxsize=None) @lru_cache() def latest_rate( currency_from: str, currency_to: str, date: datetime.date, ) -> Tuple[datetime.date, Decimal]: """The latest exchange rate from currency_from to currency_to as of date. currency_from and currency_to must have a direct conversion available """ if currency_from == currency_to: return (date, Decimal(1)) rates = list(_iter_available_rates(currency_from, currency_to, date)) rates.sort(reverse=True) if not rates: raise ExchangeRateUnavailable( f"No exchange rate available between {currency_from} and {currency_to} for {date}" ) return rates[0] @cache def _possible_base_currencies() -> List[str]: return [ rate["base_currency"] for rate in ExchangeRate.objects.values("base_currency").distinct() ] def _iter_available_rates( currency_from: str, currency_to: str, date: datetime.date, ) -> Iterator[Tuple[datetime.date, Decimal]]: """Helper to find the latest exchange rate from currency_from to currency_to as of date. Look for direct conversion, or indirect conversion via a single base currency. Yield all the combinations we found. """ oldest_acceptable_rate = date - datetime.timedelta( days=settings.MAX_EXCHANGE_RATE_AGE ) direct_rate = ( ExchangeRate.objects.filter(date__lte=date, date__gte=oldest_acceptable_rate) .filter( Q(currency=currency_from, base_currency=currency_to) | Q(currency=currency_to, base_currency=currency_from) ) .order_by("-date") .first() ) if direct_rate: rate = direct_rate.rate if direct_rate.base_currency == currency_to: rate = 1 / rate yield direct_rate.date, rate for base_currency in _possible_base_currencies(): rate_date = None rate_from = None rate_to = None for ex_rate in ( ExchangeRate.objects.filter( date__lte=date, date__gte=oldest_acceptable_rate ) .filter( Q(currency=currency_from, base_currency=base_currency) | Q(currency=currency_to, base_currency=base_currency) ) .order_by("-date") ): if rate_date != ex_rate.date: rate_date = ex_rate.date rate_from = ex_rate.rate if ex_rate.currency == currency_from else None rate_to = ex_rate.rate if ex_rate.currency == currency_to else None elif ex_rate.currency == currency_from and rate_to is not None: rate_from = ex_rate.rate assert rate_date is not None yield rate_date, rate_to / rate_from continue elif ex_rate.currency == currency_to and rate_from is not None: rate_to = ex_rate.rate assert rate_date is not None yield rate_date, rate_to / rate_from continue def exchange( amount: Decimal, currency_from: str, currency_to: str, date: Optional[datetime.date] = None, ) -> Decimal: """Exchange amount of currency_from to currency_to as of date""" if date is None: date = datetime.date.today() if currency_from == currency_to: # No need to pollute the latest_rate cache rate_date = date rate = Decimal(1) else: rate_date, rate = latest_rate(currency_from, currency_to, date) amount *= rate if date - rate_date > datetime.timedelta(days=settings.MAX_EXCHANGE_RATE_AGE): raise ExchangeRateUnavailable( f"The last available rate for {currency_from}:{currency_to} before " f"{date} is {rate_date} which is too old" ) return amount.quantize(TWOPLACES) django-historical-currencies-0.0.3/historical_currencies/formatting.py000066400000000000000000000004321462022472200263620ustar00rootroot00000000000000from decimal import Decimal from iso4217 import Currency def render_amount(amount, currency_code): currency = Currency(currency_code) quantization = Decimal(10) ** -currency.exponent quantized = amount.quantize(quantization) return f"{quantized} {currency.code}" django-historical-currencies-0.0.3/historical_currencies/management/000077500000000000000000000000001462022472200257535ustar00rootroot00000000000000django-historical-currencies-0.0.3/historical_currencies/management/__init__.py000066400000000000000000000000001462022472200300520ustar00rootroot00000000000000django-historical-currencies-0.0.3/historical_currencies/management/commands/000077500000000000000000000000001462022472200275545ustar00rootroot00000000000000django-historical-currencies-0.0.3/historical_currencies/management/commands/__init__.py000066400000000000000000000000001462022472200316530ustar00rootroot00000000000000import_ecb_exchangerates.py000066400000000000000000000040331462022472200350730ustar00rootroot00000000000000django-historical-currencies-0.0.3/historical_currencies/management/commandsimport xml.etree.ElementTree as ET from urllib.request import urlopen from django.core.management.base import BaseCommand, CommandError from historical_currencies.models import ExchangeRate NAMESPACE = { "eurofxref": "http://www.ecb.int/vocabulary/2002-08-01/eurofxref", "gesmes": "http://www.gesmes.org/xml/2002-08-01", } class Command(BaseCommand): help = "Import daily or historical exchange rate data from the ECB" def add_arguments(self, parser): parser.add_argument( "--url", metavar="URL", help="Load from a custom URL", ) parser.add_argument( "--daily", dest="url", action="store_const", const="https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml", help="Load the daily exchange rates", ) parser.add_argument( "--historical", dest="url", action="store_const", const="https://www.ecb.europa.eu/stats/eurofxref/eurofxref-hist.xml", help="Load the full set of historical exchange rates", ) def iter_rates(self, url): with urlopen(url) as f: data = ET.parse(f) root = data.getroot() for day in root.iterfind("./eurofxref:Cube/eurofxref:Cube[@time]", NAMESPACE): date = day.get("time") for rate in day.iterfind("eurofxref:Cube", NAMESPACE): currency = rate.get("currency") exchange_rate = rate.get("rate") yield ExchangeRate( date=date, base_currency="EUR", currency=currency, rate=exchange_rate, ) def handle(self, *args, **options): if not options["url"]: raise CommandError( "A URL must be provided with --daily, --historical, or --url" ) ExchangeRate.objects.bulk_create( self.iter_rates(options["url"]), ignore_conflicts=True, ) import_openexchangerates.py000066400000000000000000000125061462022472200351500ustar00rootroot00000000000000django-historical-currencies-0.0.3/historical_currencies/management/commandsimport json from datetime import date, timedelta from urllib.parse import urlencode, urlunparse from urllib.request import urlopen from django.conf import settings from django.core.management.base import BaseCommand, CommandError from django.utils.dateparse import parse_date from historical_currencies.models import ExchangeRate class Command(BaseCommand): help = "Import daily or historical exchange rate data from OpenExchangeRates.org" def add_arguments(self, parser): daterange = parser.add_mutually_exclusive_group() daterange.add_argument( "--yesterday", action="store_true", help="Load yesterday's daily exchange rates", ) daterange.add_argument( "--update", action="store_true", help="Load exchange rates since the latest in the database", ) daterange.add_argument( "--since", metavar="DATE", type=parse_date, help="Load exchange rates since DATE", ) daterange.add_argument( "--date", metavar="DATE", type=parse_date, help="Load exchange rates for DATE (only)", ) def iter_month_ranges(self, start_date, end_date): month_start = start_date month_end = month_start + timedelta(days=31) while month_end < end_date: yield (month_start, month_end) month_start = month_end + timedelta(days=1) month_end = month_start + timedelta(days=31) yield (month_start, end_date) def iter_days(self, start_date, end_date): day = start_date while day <= end_date: yield day day += timedelta(days=1) def oxr_request(self, endpoint, query=None): if query is None: query = {} query["app_id"] = settings.OPEN_EXCHANGE_RATES_APP_ID url = urlunparse( ( "https", "openexchangerates.org", f"api/{endpoint}", None, urlencode(query), None, ) ) with urlopen(url) as f: if f.status != 200: raise Exception("Request failed") return json.load(f) def check_usage(self, start_date, end_date): usage = self.oxr_request("usage.json")["data"] self.plan = usage["plan"] requests_remaining = usage["usage"]["requests_remaining"] days_to_query = (end_date - start_date).days if days_to_query > requests_remaining: raise Exception( f"Insufficient quota: days: {days_to_query}, " f"requests remaining: {requests_remaining}" ) if ( not self.plan["features"]["base"] and settings.OPEN_EXCHANGE_RATES_BASE_CURRENCY != "USD" ): raise Exception( "OpenExchangeRates plan doesn't support non-USD " "base currency" ) def iter_historical_rates(self, day): rates = self.oxr_request( f"historical/{day.isoformat()}.json", { "base": settings.OPEN_EXCHANGE_RATES_BASE_CURRENCY, }, ) for currency, rate in rates["rates"].items(): yield ExchangeRate( date=day, base_currency=rates["base"], currency=currency, rate=rate, ) def iter_time_series_rates(self, start_date, end_date): historic_rates = self.oxr_request( "time-series.json", { "start": start_date.isoformat(), "end": end_date.isoformat(), "base": settings.OPEN_EXCHANGE_RATES_BASE_CURRENCY, }, ) # FIXME: Untested for day, rates in historic_rates["rates"].items(): for currency, rate in rates.items(): yield ExchangeRate( date=day, base_currency=rates["base"], currency=currency, rate=rate, ) def handle(self, *args, **options): yesterday = date.today() - timedelta(days=1) if options["yesterday"]: daterange = (yesterday, yesterday) elif options["update"]: latest = ExchangeRate.objects.all().order_by("-date").first() if latest.date >= yesterday: print("Already up to date") return daterange = (latest.date + timedelta(days=1), yesterday) elif options["since"]: daterange = (options["since"], yesterday) elif options["date"]: daterange = (options["date"], options["date"]) else: raise CommandError("No date range specified") self.check_usage(*daterange) if self.plan["features"]["time-series"]: for month_range in self.iter_month_ranges(*daterange): ExchangeRate.objects.bulk_create( self.iter_time_series_rates(*month_range), ignore_conflicts=True, ) else: for day in self.iter_days(*daterange): ExchangeRate.objects.bulk_create( self.iter_historical_rates(day), ignore_conflicts=True, ) django-historical-currencies-0.0.3/historical_currencies/migrations/000077500000000000000000000000001462022472200260135ustar00rootroot00000000000000django-historical-currencies-0.0.3/historical_currencies/migrations/0001_initial.py000066400000000000000000000017171462022472200304640ustar00rootroot00000000000000# Generated by Django 4.1.1 on 2022-09-23 12:23 from django.db import migrations, models class Migration(migrations.Migration): initial = True dependencies = [] operations = [ migrations.CreateModel( name="ExchangeRate", fields=[ ( "id", models.BigAutoField( auto_created=True, primary_key=True, serialize=False, verbose_name="ID", ), ), ("date", models.DateField()), ("currency", models.CharField(max_length=3)), ("base_currency", models.CharField(max_length=3)), ("rate", models.DecimalField(decimal_places=5, max_digits=15)), ], options={ "unique_together": {("date", "currency", "base_currency")}, }, ), ] django-historical-currencies-0.0.3/historical_currencies/migrations/__init__.py000066400000000000000000000000001462022472200301120ustar00rootroot00000000000000django-historical-currencies-0.0.3/historical_currencies/models.py000066400000000000000000000022421462022472200254740ustar00rootroot00000000000000from datetime import date, timedelta from django.conf import settings from django.core.checks import Error, Tags, register from django.db import models class ExchangeRate(models.Model): date = models.DateField() currency = models.CharField(max_length=3) base_currency = models.CharField(max_length=3) rate = models.DecimalField(decimal_places=5, max_digits=15) class Meta: unique_together = [ ["date", "currency", "base_currency"], ] def __str__(self): return f"Exchange Rate: {self.base_currency}-{self.currency} @ {self.date}: {self.rate}" @register(Tags.database, deploy=True) def check_fresh_exchange_rate_data(app_configs, **kwargs): errors = [] oldest_acceptable_rate = date.today() - timedelta( days=settings.MAX_EXCHANGE_RATE_AGE ) rates = ExchangeRate.objects.filter(date__gte=oldest_acceptable_rate) if not rates.exists(): errors.append( Error( "no current exchange rates", hint="Run manage.py import_ecb_exchangerates to populate exchange rates", id="currencies.E001", ) ) return errors django-historical-currencies-0.0.3/historical_currencies/py.typed000066400000000000000000000000001462022472200253240ustar00rootroot00000000000000django-historical-currencies-0.0.3/historical_currencies/templates/000077500000000000000000000000001462022472200256355ustar00rootroot00000000000000django-historical-currencies-0.0.3/historical_currencies/templates/historical_currencies/000077500000000000000000000000001462022472200322205ustar00rootroot00000000000000currency_choices_options.html000066400000000000000000000002471462022472200401340ustar00rootroot00000000000000django-historical-currencies-0.0.3/historical_currencies/templates/historical_currencies{% for code, name in currency_choices %} {% endfor %} django-historical-currencies-0.0.3/historical_currencies/templatetags/000077500000000000000000000000001462022472200263315ustar00rootroot00000000000000django-historical-currencies-0.0.3/historical_currencies/templatetags/__init__.py000066400000000000000000000000001462022472200304300ustar00rootroot00000000000000django-historical-currencies-0.0.3/historical_currencies/templatetags/currency_choices.py000066400000000000000000000012151462022472200322310ustar00rootroot00000000000000from django import template from historical_currencies.choices import currency_choices register = template.Library() @register.simple_tag def currency_choices_list(exclude_special=True): """Return a list of tuples (code, name), for rendering form fields.""" return currency_choices(exclude_special=exclude_special) @register.inclusion_tag("historical_currencies/currency_choices_options.html") def currency_choices_options(exclude_special=True, selected=None): """Return a rendered set of ', rendered, ) def test_currency_choices_options_selected(self): rendered = self.render("{% currency_choices_options selected='USD' %}\n", {}) self.assertInHTML( f'', rendered, ) django-historical-currencies-0.0.3/pyproject.toml000066400000000000000000000020571462022472200221740ustar00rootroot00000000000000[build-system] requires = ['setuptools>=61'] build-backend = 'setuptools.build_meta' [project] name = "django-historical-currencies" authors = [ {name = "Stefano Rivera", email = "stefano@rivera.za.net"}, ] description = "Django currencies with historical exchange rates" readme = "README.md" requires-python = ">=3.7" keywords = ["django", "currencies", "exchange", "xe"] license = {text = "ISC"} classifiers = [ "Framework :: Django", "Programming Language :: Python :: 3", "Typing :: Typed", ] dependencies = [ 'django', 'iso4217', ] dynamic = ["version"] [project.urls] Repository = "https://github.com/stefanor/django-historical-currencies/" Changelog = "https://github.com/stefanor/django-historical-currencies/blob/master/CHANGELOG.md" [tool.setuptools.packages.find] include = ["historical_currencies*"] [tool.setuptools.dynamic] version = {attr = "historical_currencies.__version__"} [tool.mypy] plugins = [ "mypy_django_plugin.main", ] [tool.django-stubs] django_settings_module = "tests.test_settings" strict_settings = false django-historical-currencies-0.0.3/runtests.py000066400000000000000000000013711462022472200215170ustar00rootroot00000000000000#!/usr/bin/env python import argparse import os import sys import django from django.conf import settings from django.test.utils import get_runner def main(): p = argparse.ArgumentParser() p.add_argument( "--internet", action="store_true", help="Run tests that make requests on the Internet", ) args = p.parse_args() exclude_tags = [] if not args.internet: exclude_tags.append("internet") os.environ["DJANGO_SETTINGS_MODULE"] = "tests.test_settings" django.setup() TestRunner = get_runner(settings) test_runner = TestRunner(exclude_tags=exclude_tags) failures = test_runner.run_tests(["historical_currencies"]) sys.exit(bool(failures)) if __name__ == "__main__": main() django-historical-currencies-0.0.3/tests/000077500000000000000000000000001462022472200204165ustar00rootroot00000000000000django-historical-currencies-0.0.3/tests/__init__.py000066400000000000000000000000001462022472200225150ustar00rootroot00000000000000django-historical-currencies-0.0.3/tests/test_settings.py000066400000000000000000000011121462022472200236620ustar00rootroot00000000000000import os from pathlib import Path TESTS_DIR = Path(__file__).resolve().parent SECRET_KEY = "fake-key" INSTALLED_APPS = [ "historical_currencies", ] DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", "NAME": str(TESTS_DIR / "db.sqlite3"), } } TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", "DIRS": [], "APP_DIRS": True, }, ] MAX_EXCHANGE_RATE_AGE = 30 OPEN_EXCHANGE_RATES_APP_ID = os.environ.get("OPEN_EXCHANGE_RATES_APP_ID", "") OPEN_EXCHANGE_RATES_BASE_CURRENCY = "USD" django-historical-currencies-0.0.3/tox.ini000066400000000000000000000037741462022472200206020ustar00rootroot00000000000000[tox] envlist = clean, {py37,py38,py39,py310,py311,py312}-{django22,django32,django42,django50}-tests, report, pyupgrade, flake8, black, codespell, mypy, skip_missing_interpreters = True [testenv] skip_install = true commands = {envpython} -m coverage run --append runtests.py {posargs} deps = -e. coverage django22: Django>=2.2,<2.3 django32: Django>=3.2,<3.3 django42: Django>=4.2,<4.3 django50: Django>=5.0,<5.1 [testenv:py312-django22-tests] platform = none # Poor man's skip [testenv:clean] commands = {envpython} -m coverage erase deps = coverage [testenv:report] commands = {envpython} -m coverage report --show-missing --include "historical_currencies/*" deps = coverage [testenv:report_xml] commands = {envpython} -m coverage report xml --skip-empty --include "historical_currencies/*" deps = coverage [testenv:pyupgrade] commands = pyup-dirs --recursive --keep-percent-format --py37-plus historical_currencies tests runtests.py {posargs} deps = pyupgrade-directories [testenv:flake8] # Not clean, yet ignore_outcome = True commands = {envpython} -m flake8 --unused-arguments-ignore-variadic-names --unused-arguments-ignore-stub-functions historical_currencies/ tests/ {posargs} deps = flake8 flake8-absolute-import flake8-builtins flake8-docstrings flake8-import-order flake8-logging-format flake8-rst-docstrings flake8-unused-arguments [testenv:black] commands = {envpython} -m black --check --diff historical_currencies/ tests/ runtests.py {posargs} deps = black [testenv:format] commands = {envpython} -m black --diff historical_currencies/ tests/ runtests.py {posargs} {envpython} -m black historical_currencies/ tests/ runtests.py {posargs} deps = black [testenv:codespell] commands = codespell -L zar historical_currencies/ tests/ runtests.py {posargs} deps = codespell [testenv:mypy] commands = mypy historical_currencies/ tests/ runtests.py {posargs} deps = -e. mypy django-stubs[compatible-mypy]<5