diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..77f2034 --- /dev/null +++ b/.gitignore @@ -0,0 +1,95 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# IPython Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# dotenv +.env + +# virtualenv +venv/ +ENV/ + +# Spyder project settings +.spyderproject + +# Rope project settings +.ropeproject + +# emacs recovery files +*~ + +# Mac stuff +.DS_Store diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..8e66f18 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2017 Vimeo, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0060273 --- /dev/null +++ b/Makefile @@ -0,0 +1,11 @@ +.PHONY: install test pylint + +install: + python setup.py install + pip install pylint + +test: + PYTHONPATH=PYTHONPATH:money py.test money + +pylint: + PYTHONPATH=PYTHONPATH:money python $$(which pylint) money --output-format colorized diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..d71954a --- /dev/null +++ b/README.rst @@ -0,0 +1,124 @@ +======== +py-money +======== + +Money class for Python 3. Unlike other Python money classes, this class enforces that all monetary amounts are represented with the correct number of decimal places for the currency. For example, 3.678 USD is invalid, as is 5.5 JPY. + +Installation +============ + +Install the latest release with: + +:: + + pip install py-money + +Usage +===== + +A Money object can be created with an amount (specified as a string) and a currency from the Currency class. + +.. code:: python + + >>> from money.money import Money + >>> from money.currency import Currency + >>> m = Money('9.95', Currency.GBP) + >>> m + GBP 9.95 + +Money is immutable and supports most mathematical and logical operators. + +.. code:: python + + >>> m = Money('10.00', Currency.USD) + >>> m / 2 + USD 5.00 + >>> m + Money('3.00', Currency.USD) + USD 8.00 + >>> m > Money('5.55', Currency.USD) + True + +Money will automatically round to the correct number of decimal places for the currency. + +.. code:: python + + >>> m = Money('9.95', Currency.EUR) + >>> m * 0.15 + EUR 1.49 + >>> m = Money('10', Currency.JPY) + >>> m / 3 + JPY 3 + +Money can be formatted for different locales. + +.. code:: python + + >>> Money('3.24', Currency.USD).format('en_US') + '$3.24' + >>> Money('9.95', Currency.EUR).format('en_UK') + '€5.56' + >>> Money('94', Currency.JPY).format('ja_JP') + '¥94' + +Money does not support conversion between currencies and probably never will. Mathetmatical and logical operations between two money objects are only allowed if both objects are of the same currency. Otherwise, an error will be thrown. + +Money will throw an error if you try to construct an object with an invalid amount for the currency (eg, 3.678 USD or 5.5 JPY). + +For more examples, check out the test file! + +Is this the money library for me? +================================= + +If you're just trying to do simple mathematical operations on money in different currencies, this library is probably perfect for you! Perhaps you're just running a store online and you need to compute sales tax. + +.. code:: python + + >>> subtotal = Money('9.95', Currency.USD) + >>> tax = subtotal * 0.07 + >>> total = tax + subtotal + >>> subtotal.format('en_US') + '$9.95' + >>> tax.format('en_US') + '$0.70' + >>> total.format('en_US') + '$10.65' + +All rounding will be done correctly, and you can open up in multiple countries with ease! + +If you're doing complicated money operations that require many digits of precision for some reason (or you're running a gas station and charging that extra nine tenths of a cent), this library is not for you. + +A word of warning: rounding is performed after each multiplication or division operation. While this is exactly what you want when computing sales tax, it may cause confusion if you're not expecting it. + +.. code:: python + + >>> m = Money('9.95', Currency.USD) + >>> m * 0.5 * 2 + USD 9.96 + >>> m * (0.5 * 2) + USD 9.95 + >>> m * 1 + USD 9.95 + +To avoid confusion, make sure you simplify your expressions! + +Future improvements +=================== +Support may be added one day for setting rounding modes. Foreign exchange rates will probably never be supported. + +Contributing +============ +Pull requests are welcome! Please include tests. You can run the tests from the root directory with + +:: + + make test + +You can run pylint from the root directory with + +:: + + make pylint + +Acknowledgements +================ +Much of the code is borrowed from https://github.com/carlospalol/money. Much of the logic for handling foreign currencies is taken from https://github.com/sebastianbergmann/money. Money formatting is powered by `Babel `_. diff --git a/money/__init__.py b/money/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/money/currency.py b/money/currency.py new file mode 100644 index 0000000..801d4f9 --- /dev/null +++ b/money/currency.py @@ -0,0 +1,1301 @@ +"""Module defining currencies and currency utilities""" + +from enum import Enum + +#pylint: disable=too-many-lines + +class Currency(Enum): + """Enumerates all supported currencies""" + + AED = 'AED' + AFN = 'AFN' + ALL = 'ALL' + AMD = 'AMD' + ANG = 'ANG' + AOA = 'AOA' + ARS = 'ARS' + AUD = 'AUD' + AWG = 'AWG' + AZN = 'AZN' + BAM = 'BAM' + BBD = 'BBD' + BDT = 'BDT' + BGN = 'BGN' + BHD = 'BHD' + BIF = 'BIF' + BMD = 'BMD' + BND = 'BND' + BOB = 'BOB' + BOV = 'BOV' + BRL = 'BRL' + BSD = 'BSD' + BTN = 'BTN' + BWP = 'BWP' + BYR = 'BYR' + BZD = 'BZD' + CAD = 'CAD' + CDF = 'CDF' + CHE = 'CHE' + CHF = 'CHF' + CHW = 'CHW' + CLF = 'CLF' + CLP = 'CLP' + CNY = 'CNY' + COP = 'COP' + COU = 'COU' + CRC = 'CRC' + CUC = 'CUC' + CUP = 'CUP' + CVE = 'CVE' + CZK = 'CZK' + DJF = 'DJF' + DKK = 'DKK' + DOP = 'DOP' + DZD = 'DZD' + EGP = 'EGP' + ERN = 'ERN' + ETB = 'ETB' + EUR = 'EUR' + FJD = 'FJD' + FKP = 'FKP' + GBP = 'GBP' + GEL = 'GEL' + GHS = 'GHS' + GIP = 'GIP' + GMD = 'GMD' + GNF = 'GNF' + GTQ = 'GTQ' + GYD = 'GYD' + HKD = 'HKD' + HNL = 'HNL' + HRK = 'HRK' + HTG = 'HTG' + HUF = 'HUF' + IDR = 'IDR' + ILS = 'ILS' + INR = 'INR' + IQD = 'IQD' + IRR = 'IRR' + ISK = 'ISK' + JMD = 'JMD' + JOD = 'JOD' + JPY = 'JPY' + KES = 'KES' + KGS = 'KGS' + KHR = 'KHR' + KMF = 'KMF' + KPW = 'KPW' + KRW = 'KRW' + KWD = 'KWD' + KYD = 'KYD' + KZT = 'KZT' + LAK = 'LAK' + LBP = 'LBP' + LKR = 'LKR' + LRD = 'LRD' + LSL = 'LSL' + LTL = 'LTL' + LVL = 'LVL' + LYD = 'LYD' + MAD = 'MAD' + MDL = 'MDL' + MGA = 'MGA' + MKD = 'MKD' + MMK = 'MMK' + MNT = 'MNT' + MOP = 'MOP' + MRO = 'MRO' + MUR = 'MUR' + MVR = 'MVR' + MWK = 'MWK' + MXN = 'MXN' + MXV = 'MXV' + MYR = 'MYR' + MZN = 'MZN' + NAD = 'NAD' + NGN = 'NGN' + NIO = 'NIO' + NOK = 'NOK' + NPR = 'NPR' + NZD = 'NZD' + OMR = 'OMR' + PAB = 'PAB' + PEN = 'PEN' + PGK = 'PGK' + PHP = 'PHP' + PKR = 'PKR' + PLN = 'PLN' + PYG = 'PYG' + QAR = 'QAR' + RON = 'RON' + RSD = 'RSD' + RUB = 'RUB' + RWF = 'RWF' + SAR = 'SAR' + SBD = 'SBD' + SCR = 'SCR' + SDG = 'SDG' + SEK = 'SEK' + SGD = 'SGD' + SHP = 'SHP' + SLL = 'SLL' + SOS = 'SOS' + SRD = 'SRD' + SSP = 'SSP' + STD = 'STD' + SVC = 'SVC' + SYP = 'SYP' + SZL = 'SZL' + THB = 'THB' + TJS = 'TJS' + TMT = 'TMT' + TND = 'TND' + TOP = 'TOP' + TRY = 'TRY' + TTD = 'TTD' + TWD = 'TWD' + TZS = 'TZS' + UAH = 'UAH' + UGX = 'UGX' + USD = 'USD' + USN = 'USN' + USS = 'USS' + UYI = 'UYI' + UYU = 'UYU' + UZS = 'UZS' + VEF = 'VEF' + VND = 'VND' + VUV = 'VUV' + WST = 'WST' + XAF = 'XAF' + XAG = 'XAG' + XAU = 'XAU' + XBA = 'XBA' + XBB = 'XBB' + XBC = 'XBC' + XBD = 'XBD' + XCD = 'XCD' + XDR = 'XDR' + XFU = 'XFU' + XOF = 'XOF' + XPD = 'XPD' + XPF = 'XPF' + XPT = 'XPT' + XSU = 'XSU' + XTS = 'XTS' + XUA = 'XUA' + YER = 'YER' + ZAR = 'ZAR' + ZMW = 'ZMW' + ZWL = 'ZWL' + +class CurrencyHelper: + """Utilities for currencies""" + + _CURRENCY_DATA = { + Currency.AED: { + 'display_name': 'UAE Dirham', + 'numeric_code': 784, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.AFN: { + 'display_name': 'Afghani', + 'numeric_code': 971, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.ALL: { + 'display_name': 'Lek', + 'numeric_code': 8, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.AMD: { + 'display_name': 'Armenian Dram', + 'numeric_code': 51, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.ANG: { + 'display_name': 'Netherlands Antillean Guilder', + 'numeric_code': 532, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.AOA: { + 'display_name': 'Kwanza', + 'numeric_code': 973, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.ARS: { + 'display_name': 'Argentine Peso', + 'numeric_code': 32, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.AUD: { + 'display_name': 'Australian Dollar', + 'numeric_code': 36, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.AWG: { + 'display_name': 'Aruban Florin', + 'numeric_code': 533, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.AZN: { + 'display_name': 'Azerbaijanian Manat', + 'numeric_code': 944, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.BAM: { + 'display_name': 'Convertible Mark', + 'numeric_code': 977, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.BBD: { + 'display_name': 'Barbados Dollar', + 'numeric_code': 52, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.BDT: { + 'display_name': 'Taka', + 'numeric_code': 50, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.BGN: { + 'display_name': 'Bulgarian Lev', + 'numeric_code': 975, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.BHD: { + 'display_name': 'Bahraini Dinar', + 'numeric_code': 48, + 'default_fraction_digits': 3, + 'sub_unit': 1000, + }, + Currency.BIF: { + 'display_name': 'Burundi Franc', + 'numeric_code': 108, + 'default_fraction_digits': 0, + 'sub_unit': 100, + }, + Currency.BMD: { + 'display_name': 'Bermudian Dollar', + 'numeric_code': 60, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.BND: { + 'display_name': 'Brunei Dollar', + 'numeric_code': 96, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.BOB: { + 'display_name': 'Boliviano', + 'numeric_code': 68, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.BOV: { + 'display_name': 'Mvdol', + 'numeric_code': 984, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.BRL: { + 'display_name': 'Brazilian Real', + 'numeric_code': 986, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.BSD: { + 'display_name': 'Bahamian Dollar', + 'numeric_code': 44, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.BTN: { + 'display_name': 'Ngultrum', + 'numeric_code': 64, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.BWP: { + 'display_name': 'Pula', + 'numeric_code': 72, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.BYR: { + 'display_name': 'Belarussian Ruble', + 'numeric_code': 974, + 'default_fraction_digits': 0, + 'sub_unit': 100, + }, + Currency.BZD: { + 'display_name': 'Belize Dollar', + 'numeric_code': 84, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.CAD: { + 'display_name': 'Canadian Dollar', + 'numeric_code': 124, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.CDF: { + 'display_name': 'Congolese Franc', + 'numeric_code': 976, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.CHE: { + 'display_name': 'WIR Euro', + 'numeric_code': 947, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.CHF: { + 'display_name': 'Swiss Franc', + 'numeric_code': 756, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.CHW: { + 'display_name': 'WIR Franc', + 'numeric_code': 948, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.CLF: { + 'display_name': 'Unidades de fomento', + 'numeric_code': 990, + 'default_fraction_digits': 0, + 'sub_unit': 100, + }, + Currency.CLP: { + 'display_name': 'Chilean Peso', + 'numeric_code': 152, + 'default_fraction_digits': 0, + 'sub_unit': 100, + }, + Currency.CNY: { + 'display_name': 'Yuan Renminbi', + 'numeric_code': 156, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.COP: { + 'display_name': 'Colombian Peso', + 'numeric_code': 170, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.COU: { + 'display_name': 'Unidad de Valor Real', + 'numeric_code': 970, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.CRC: { + 'display_name': 'Costa Rican Colon', + 'numeric_code': 188, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.CUC: { + 'display_name': 'Peso Convertible', + 'numeric_code': 931, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.CUP: { + 'display_name': 'Cuban Peso', + 'numeric_code': 192, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.CVE: { + 'display_name': 'Cape Verde Escudo', + 'numeric_code': 132, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.CZK: { + 'display_name': 'Czech Koruna', + 'numeric_code': 203, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.DJF: { + 'display_name': 'Djibouti Franc', + 'numeric_code': 262, + 'default_fraction_digits': 0, + 'sub_unit': 100, + }, + Currency.DKK: { + 'display_name': 'Danish Krone', + 'numeric_code': 208, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.DOP: { + 'display_name': 'Dominican Peso', + 'numeric_code': 214, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.DZD: { + 'display_name': 'Algerian Dinar', + 'numeric_code': 12, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.EGP: { + 'display_name': 'Egyptian Pound', + 'numeric_code': 818, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.ERN: { + 'display_name': 'Nakfa', + 'numeric_code': 232, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.ETB: { + 'display_name': 'Ethiopian Birr', + 'numeric_code': 230, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.EUR: { + 'display_name': 'Euro', + 'numeric_code': 978, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.FJD: { + 'display_name': 'Fiji Dollar', + 'numeric_code': 242, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.FKP: { + 'display_name': 'Falkland Islands Pound', + 'numeric_code': 238, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.GBP: { + 'display_name': 'Pound Sterling', + 'numeric_code': 826, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.GEL: { + 'display_name': 'Lari', + 'numeric_code': 981, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.GHS: { + 'display_name': 'Ghana Cedi', + 'numeric_code': 936, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.GIP: { + 'display_name': 'Gibraltar Pound', + 'numeric_code': 292, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.GMD: { + 'display_name': 'Dalasi', + 'numeric_code': 270, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.GNF: { + 'display_name': 'Guinea Franc', + 'numeric_code': 324, + 'default_fraction_digits': 0, + 'sub_unit': 100, + }, + Currency.GTQ: { + 'display_name': 'Quetzal', + 'numeric_code': 320, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.GYD: { + 'display_name': 'Guyana Dollar', + 'numeric_code': 328, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.HKD: { + 'display_name': 'Hong Kong Dollar', + 'numeric_code': 344, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.HNL: { + 'display_name': 'Lempira', + 'numeric_code': 340, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.HRK: { + 'display_name': 'Croatian Kuna', + 'numeric_code': 191, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.HTG: { + 'display_name': 'Gourde', + 'numeric_code': 332, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.HUF: { + 'display_name': 'Forint', + 'numeric_code': 348, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.IDR: { + 'display_name': 'Rupiah', + 'numeric_code': 360, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.ILS: { + 'display_name': 'New Israeli Sheqel', + 'numeric_code': 376, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.INR: { + 'display_name': 'Indian Rupee', + 'numeric_code': 356, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.IQD: { + 'display_name': 'Iraqi Dinar', + 'numeric_code': 368, + 'default_fraction_digits': 3, + 'sub_unit': 1000, + }, + Currency.IRR: { + 'display_name': 'Iranian Rial', + 'numeric_code': 364, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.ISK: { + 'display_name': 'Iceland Krona', + 'numeric_code': 352, + 'default_fraction_digits': 0, + 'sub_unit': 100, + }, + Currency.JMD: { + 'display_name': 'Jamaican Dollar', + 'numeric_code': 388, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.JOD: { + 'display_name': 'Jordanian Dinar', + 'numeric_code': 400, + 'default_fraction_digits': 3, + 'sub_unit': 1000, + }, + Currency.JPY: { + 'display_name': 'Yen', + 'numeric_code': 392, + 'default_fraction_digits': 0, + 'sub_unit': 1, + }, + Currency.KES: { + 'display_name': 'Kenyan Shilling', + 'numeric_code': 404, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.KGS: { + 'display_name': 'Som', + 'numeric_code': 417, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.KHR: { + 'display_name': 'Riel', + 'numeric_code': 116, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.KMF: { + 'display_name': 'Comoro Franc', + 'numeric_code': 174, + 'default_fraction_digits': 0, + 'sub_unit': 100, + }, + Currency.KPW: { + 'display_name': 'North Korean Won', + 'numeric_code': 408, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.KRW: { + 'display_name': 'Won', + 'numeric_code': 410, + 'default_fraction_digits': 0, + 'sub_unit': 100, + }, + Currency.KWD: { + 'display_name': 'Kuwaiti Dinar', + 'numeric_code': 414, + 'default_fraction_digits': 3, + 'sub_unit': 1000, + }, + Currency.KYD: { + 'display_name': 'Cayman Islands Dollar', + 'numeric_code': 136, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.KZT: { + 'display_name': 'Tenge', + 'numeric_code': 398, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.LAK: { + 'display_name': 'Kip', + 'numeric_code': 418, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.LBP: { + 'display_name': 'Lebanese Pound', + 'numeric_code': 422, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.LKR: { + 'display_name': 'Sri Lanka Rupee', + 'numeric_code': 144, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.LRD: { + 'display_name': 'Liberian Dollar', + 'numeric_code': 430, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.LSL: { + 'display_name': 'Loti', + 'numeric_code': 426, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.LTL: { + 'display_name': 'Lithuanian Litas', + 'numeric_code': 440, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.LVL: { + 'display_name': 'Latvian Lats', + 'numeric_code': 428, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.LYD: { + 'display_name': 'Libyan Dinar', + 'numeric_code': 434, + 'default_fraction_digits': 3, + 'sub_unit': 1000, + }, + Currency.MAD: { + 'display_name': 'Moroccan Dirham', + 'numeric_code': 504, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.MDL: { + 'display_name': 'Moldovan Leu', + 'numeric_code': 498, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.MGA: { + 'display_name': 'Malagasy Ariary', + 'numeric_code': 969, + 'default_fraction_digits': 2, + 'sub_unit': 5, + }, + Currency.MKD: { + 'display_name': 'Denar', + 'numeric_code': 807, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.MMK: { + 'display_name': 'Kyat', + 'numeric_code': 104, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.MNT: { + 'display_name': 'Tugrik', + 'numeric_code': 496, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.MOP: { + 'display_name': 'Pataca', + 'numeric_code': 446, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.MRO: { + 'display_name': 'Ouguiya', + 'numeric_code': 478, + 'default_fraction_digits': 2, + 'sub_unit': 5, + }, + Currency.MUR: { + 'display_name': 'Mauritius Rupee', + 'numeric_code': 480, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.MVR: { + 'display_name': 'Rufiyaa', + 'numeric_code': 462, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.MWK: { + 'display_name': 'Kwacha', + 'numeric_code': 454, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.MXN: { + 'display_name': 'Mexican Peso', + 'numeric_code': 484, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.MXV: { + 'display_name': 'Mexican Unidad de Inversion (UDI)', + 'numeric_code': 979, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.MYR: { + 'display_name': 'Malaysian Ringgit', + 'numeric_code': 458, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.MZN: { + 'display_name': 'Mozambique Metical', + 'numeric_code': 943, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.NAD: { + 'display_name': 'Namibia Dollar', + 'numeric_code': 516, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.NGN: { + 'display_name': 'Naira', + 'numeric_code': 566, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.NIO: { + 'display_name': 'Cordoba Oro', + 'numeric_code': 558, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.NOK: { + 'display_name': 'Norwegian Krone', + 'numeric_code': 578, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.NPR: { + 'display_name': 'Nepalese Rupee', + 'numeric_code': 524, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.NZD: { + 'display_name': 'New Zealand Dollar', + 'numeric_code': 554, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.OMR: { + 'display_name': 'Rial Omani', + 'numeric_code': 512, + 'default_fraction_digits': 3, + 'sub_unit': 1000, + }, + Currency.PAB: { + 'display_name': 'Balboa', + 'numeric_code': 590, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.PEN: { + 'display_name': 'Nuevo Sol', + 'numeric_code': 604, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.PGK: { + 'display_name': 'Kina', + 'numeric_code': 598, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.PHP: { + 'display_name': 'Philippine Peso', + 'numeric_code': 608, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.PKR: { + 'display_name': 'Pakistan Rupee', + 'numeric_code': 586, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.PLN: { + 'display_name': 'Zloty', + 'numeric_code': 985, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.PYG: { + 'display_name': 'Guarani', + 'numeric_code': 600, + 'default_fraction_digits': 0, + 'sub_unit': 100, + }, + Currency.QAR: { + 'display_name': 'Qatari Rial', + 'numeric_code': 634, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.RON: { + 'display_name': 'New Romanian Leu', + 'numeric_code': 946, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.RSD: { + 'display_name': 'Serbian Dinar', + 'numeric_code': 941, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.RUB: { + 'display_name': 'Russian Ruble', + 'numeric_code': 643, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.RWF: { + 'display_name': 'Rwanda Franc', + 'numeric_code': 646, + 'default_fraction_digits': 0, + 'sub_unit': 100, + }, + Currency.SAR: { + 'display_name': 'Saudi Riyal', + 'numeric_code': 682, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.SBD: { + 'display_name': 'Solomon Islands Dollar', + 'numeric_code': 90, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.SCR: { + 'display_name': 'Seychelles Rupee', + 'numeric_code': 690, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.SDG: { + 'display_name': 'Sudanese Pound', + 'numeric_code': 938, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.SEK: { + 'display_name': 'Swedish Krona', + 'numeric_code': 752, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.SGD: { + 'display_name': 'Singapore Dollar', + 'numeric_code': 702, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.SHP: { + 'display_name': 'Saint Helena Pound', + 'numeric_code': 654, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.SLL: { + 'display_name': 'Leone', + 'numeric_code': 694, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.SOS: { + 'display_name': 'Somali Shilling', + 'numeric_code': 706, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.SRD: { + 'display_name': 'Surinam Dollar', + 'numeric_code': 968, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.SSP: { + 'display_name': 'South Sudanese Pound', + 'numeric_code': 728, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.STD: { + 'display_name': 'Dobra', + 'numeric_code': 678, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.SVC: { + 'display_name': 'El Salvador Colon', + 'numeric_code': 222, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.SYP: { + 'display_name': 'Syrian Pound', + 'numeric_code': 760, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.SZL: { + 'display_name': 'Lilangeni', + 'numeric_code': 748, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.THB: { + 'display_name': 'Baht', + 'numeric_code': 764, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.TJS: { + 'display_name': 'Somoni', + 'numeric_code': 972, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.TMT: { + 'display_name': 'Turkmenistan New Manat', + 'numeric_code': 934, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.TND: { + 'display_name': 'Tunisian Dinar', + 'numeric_code': 788, + 'default_fraction_digits': 3, + 'sub_unit': 1000, + }, + Currency.TOP: { + 'display_name': 'Pa’anga', + 'numeric_code': 776, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.TRY: { + 'display_name': 'Turkish Lira', + 'numeric_code': 949, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.TTD: { + 'display_name': 'Trinidad and Tobago Dollar', + 'numeric_code': 780, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.TWD: { + 'display_name': 'New Taiwan Dollar', + 'numeric_code': 901, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.TZS: { + 'display_name': 'Tanzanian Shilling', + 'numeric_code': 834, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.UAH: { + 'display_name': 'Hryvnia', + 'numeric_code': 980, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.UGX: { + 'display_name': 'Uganda Shilling', + 'numeric_code': 800, + 'default_fraction_digits': 0, + 'sub_unit': 100, + }, + Currency.USD: { + 'display_name': 'US Dollar', + 'numeric_code': 840, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.USN: { + 'display_name': 'US Dollar (Next day)', + 'numeric_code': 997, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.USS: { + 'display_name': 'US Dollar (Same day)', + 'numeric_code': 998, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.UYI: { + 'display_name': 'Uruguay Peso en Unidades Indexadas (URUIURUI)', + 'numeric_code': 940, + 'default_fraction_digits': 0, + 'sub_unit': 100, + }, + Currency.UYU: { + 'display_name': 'Peso Uruguayo', + 'numeric_code': 858, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.UZS: { + 'display_name': 'Uzbekistan Sum', + 'numeric_code': 860, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.VEF: { + 'display_name': 'Bolivar', + 'numeric_code': 937, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.VND: { + 'display_name': 'Dong', + 'numeric_code': 704, + 'default_fraction_digits': 0, + 'sub_unit': 10, + }, + Currency.VUV: { + 'display_name': 'Vatu', + 'numeric_code': 548, + 'default_fraction_digits': 0, + 'sub_unit': 1, + }, + Currency.WST: { + 'display_name': 'Tala', + 'numeric_code': 882, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.XAF: { + 'display_name': 'CFA Franc BEAC', + 'numeric_code': 950, + 'default_fraction_digits': 0, + 'sub_unit': 100, + }, + Currency.XAG: { + 'display_name': 'Silver', + 'numeric_code': 961, + 'default_fraction_digits': 0, + 'sub_unit': 100, + }, + Currency.XAU: { + 'display_name': 'Gold', + 'numeric_code': 959, + 'default_fraction_digits': 0, + 'sub_unit': 100, + }, + Currency.XBA: { + 'display_name': 'Bond Markets Unit European Composite Unit (EURCO)', + 'numeric_code': 955, + 'default_fraction_digits': 0, + 'sub_unit': 100, + }, + Currency.XBB: { + 'display_name': 'Bond Markets Unit European Monetary Unit (E.M.U.-6)', + 'numeric_code': 956, + 'default_fraction_digits': 0, + 'sub_unit': 100, + }, + Currency.XBC: { + 'display_name': 'Bond Markets Unit European Unit of Account 9 (E.U.A.-9)', + 'numeric_code': 957, + 'default_fraction_digits': 0, + 'sub_unit': 100, + }, + Currency.XBD: { + 'display_name': 'Bond Markets Unit European Unit of Account 17 (E.U.A.-17)', + 'numeric_code': 958, + 'default_fraction_digits': 0, + 'sub_unit': 100, + }, + Currency.XCD: { + 'display_name': 'East Caribbean Dollar', + 'numeric_code': 951, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.XDR: { + 'display_name': 'SDR (Special Drawing Right)', + 'numeric_code': 960, + 'default_fraction_digits': 0, + 'sub_unit': 100, + }, + Currency.XFU: { + 'display_name': 'UIC-Franc', + 'numeric_code': 958, + 'default_fraction_digits': 0, + 'sub_unit': 100, + }, + Currency.XOF: { + 'display_name': 'CFA Franc BCEAO', + 'numeric_code': 952, + 'default_fraction_digits': 0, + 'sub_unit': 100, + }, + Currency.XPD: { + 'display_name': 'Palladium', + 'numeric_code': 964, + 'default_fraction_digits': 0, + 'sub_unit': 100, + }, + Currency.XPF: { + 'display_name': 'CFP Franc', + 'numeric_code': 953, + 'default_fraction_digits': 0, + 'sub_unit': 100, + }, + Currency.XPT: { + 'display_name': 'Platinum', + 'numeric_code': 962, + 'default_fraction_digits': 0, + 'sub_unit': 100, + }, + Currency.XSU: { + 'display_name': 'Sucre', + 'numeric_code': 994, + 'default_fraction_digits': 0, + 'sub_unit': 100, + }, + Currency.XTS: { + 'display_name': 'Codes specifically reserved for testing purposes', + 'numeric_code': 963, + 'default_fraction_digits': 0, + 'sub_unit': 100, + }, + Currency.XUA: { + 'display_name': 'ADB Unit of Account', + 'numeric_code': 965, + 'default_fraction_digits': 0, + 'sub_unit': 100, + }, + Currency.YER: { + 'display_name': 'Yemeni Rial', + 'numeric_code': 886, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.ZAR: { + 'display_name': 'Rand', + 'numeric_code': 710, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.ZMW: { + 'display_name': 'Zambian Kwacha', + 'numeric_code': 967, + 'default_fraction_digits': 2, + 'sub_unit': 100, + }, + Currency.ZWL: { + 'display_name': 'Zimbabwe Dollar', + 'numeric_code': 932, + 'default_fraction_digits': 2, + 'sub_unit': 100, + } + } + """Data about currencies. + + Taken from https://github.com/sebastianbergmann/money + + """ + + @classmethod + def decimal_precision_for_currency(cls, currency: Currency) -> int: + """Returns the decimal precision for a currency (number of digits after the decimal)""" + + return cls._CURRENCY_DATA[currency]['default_fraction_digits'] + + @classmethod + def sub_unit_for_currency(cls, currency: Currency) -> int: + """Returns the sub unit for a currency. + (eg, the subunit for USD is 100 because there are 100 cents in a dollar) + + """ + return cls._CURRENCY_DATA[currency]['sub_unit'] diff --git a/money/exceptions.py b/money/exceptions.py new file mode 100644 index 0000000..b05ce3d --- /dev/null +++ b/money/exceptions.py @@ -0,0 +1,15 @@ +"""Custom exceptions for money operations""" + +#pylint: disable=missing-docstring + +class InvalidAmountError(ValueError): + def __init__(self): + super().__init__('Invalid amount for currency') + +class CurrencyMismatchError(ValueError): + def __init__(self): + super().__init__('Currencies must match') + +class InvalidOperandError(ValueError): + def __init__(self): + super().__init__('Invalid operand types for operation') diff --git a/money/money.py b/money/money.py new file mode 100644 index 0000000..ab04dd8 --- /dev/null +++ b/money/money.py @@ -0,0 +1,165 @@ +"""Class representing a monetary amount""" + +from decimal import Decimal, ROUND_HALF_UP +from babel.numbers import format_currency +from money.currency import Currency +from money.currency import CurrencyHelper +from money.exceptions import InvalidAmountError, CurrencyMismatchError, InvalidOperandError + +class Money: + """Class representing a monetary amount""" + + def __init__(self, amount: str, currency: Currency=Currency.USD) -> None: + self._amount = Decimal(amount) + self._currency = currency + + if self._round(self._amount, currency) != Decimal(amount): + raise InvalidAmountError + + @property + def amount(self) -> Decimal: + """Returns the numeric amount""" + + return self._amount + + @property + def currency(self) -> Currency: + """Returns the currency""" + + return self._currency + + def __hash__(self) -> str: + return hash((self._amount, self._currency)) + + def __repr__(self) -> str: + return "{} {}".format(self._currency.name, self._amount) + + def __lt__(self, other: 'Money') -> bool: + if not isinstance(other, Money): + raise InvalidOperandError + + self._assert_same_currency(other) + return self.amount < other.amount + + def __le__(self, other: 'Money') -> bool: + if not isinstance(other, Money): + raise InvalidOperandError + + self._assert_same_currency(other) + return self.amount <= other.amount + + def __gt__(self, other: 'Money') -> bool: + if not isinstance(other, Money): + raise InvalidOperandError + + self._assert_same_currency(other) + return self.amount > other.amount + + def __ge__(self, other: 'Money') -> bool: + if not isinstance(other, Money): + raise InvalidOperandError + + self._assert_same_currency(other) + return self.amount >= other.amount + + def __eq__(self, other: 'Money') -> bool: + if not isinstance(other, Money): + raise InvalidOperandError + + self._assert_same_currency(other) + return self.amount == other.amount + + def __ne__(self, other: 'Money') -> bool: + return not self == other + + def __bool__(self): + return bool(self._amount) + + def __add__(self, other: 'Money') -> 'Money': + if not isinstance(other, Money): + raise InvalidOperandError + + self._assert_same_currency(other) + return self.__class__(str(self.amount + other.amount), self.currency) + + def __radd__(self, other: 'Money') -> 'Money': + return self.__add__(other) + + def __sub__(self, other: 'Money') -> 'Money': + if not isinstance(other, Money): + raise InvalidOperandError + + self._assert_same_currency(other) + return self.__class__(str(self.amount - other.amount), self.currency) + + def __rsub__(self, other: 'Money') -> 'Money': + return self.__sub__(other) + + def __mul__(self, other: float) -> 'Money': + if isinstance(other, Money): + raise InvalidOperandError + + amount = self._round(self._amount * Decimal(other), self._currency) + return self.__class__(str(amount), self._currency) + + def __rmul__(self, other: float) -> 'Money': + return self.__mul__(other) + + def __div__(self, other: float) -> 'Money': + return self.__truediv__(other) + + def __truediv__(self, other: float) -> 'Money': + if isinstance(other, Money): + raise InvalidOperandError + elif other == 0: + raise ZeroDivisionError + + amount = self._round(self._amount / Decimal(other), self._currency) + return self.__class__(str(amount), self._currency) + + def __floordiv__(self, other: float) -> 'Money': + if isinstance(other, Money): + raise InvalidOperandError + elif other == 0: + raise ZeroDivisionError + + amount = self._round(self._amount // Decimal(other), self._currency) + return self.__class__(str(amount), self._currency) + + def __mod__(self, other: int) -> 'Money': + if isinstance(other, Money): + raise InvalidOperandError + elif other == 0: + raise ZeroDivisionError + + amount = self._round(self._amount % Decimal(other), self._currency) + return self.__class__(str(amount), self._currency) + + def __neg__(self) -> 'Money': + return self.__class__(str(-self._amount), self._currency) + + def __pos__(self) -> 'Money': + return self.__class__(str(+self._amount), self._currency) + + def __abs__(self) -> 'Money': + return self.__class__(str(abs(self._amount)), self._currency) + + def format(self, locale: str='en_US') -> str: + """Returns a string of the currency formatted for the specified locale""" + + return format_currency(self.amount, self.currency.name, locale=locale) + + def _assert_same_currency(self, other: 'Money') -> None: + if self.currency != other.currency: + raise CurrencyMismatchError + + @staticmethod + def _round(amount: Decimal, currency: Currency) -> Decimal: + sub_units = CurrencyHelper.sub_unit_for_currency(currency) + # rstrip is necessary because quantize treats 1. differently from 1.0 + rounded_to_subunits = amount.quantize(Decimal(str(1 / sub_units).rstrip('0')),\ + rounding=ROUND_HALF_UP) + decimal_precision = CurrencyHelper.decimal_precision_for_currency(currency) + return rounded_to_subunits.quantize(\ + Decimal(str(1 / (10 ** decimal_precision)).rstrip('0')),\ + rounding=ROUND_HALF_UP) diff --git a/money/tests/__init__.py b/money/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/money/tests/test_money.py b/money/tests/test_money.py new file mode 100644 index 0000000..28b7c3a --- /dev/null +++ b/money/tests/test_money.py @@ -0,0 +1,216 @@ +"""Money tests""" + +from decimal import Decimal +import pytest +from money.money import Money +from money.currency import Currency +from money.exceptions import InvalidAmountError, CurrencyMismatchError, InvalidOperandError + +# pylint: disable=unneeded-not,expression-not-assigned,no-self-use,missing-docstring +# pylint: disable=misplaced-comparison-constant,singleton-comparison + +class TestMoney: + """Money tests""" + + def test_construction(self): + money = Money('3.95') + assert money.amount == Decimal('3.95') + assert money.currency == Currency.USD + + money = Money('1', Currency.USD) + assert money.amount == Decimal('1') + assert money.currency == Currency.USD + + money = Money('199', Currency.JPY) + assert money.amount == Decimal('199') + assert money.currency == Currency.JPY + + money = Money('192.325', Currency.KWD) + assert money.amount == Decimal('192.325') + assert money.currency == Currency.KWD + + with pytest.raises(InvalidAmountError): + Money('3.956', Currency.USD) + + with pytest.raises(InvalidAmountError): + # nonfractional currency + Money('10.2', Currency.KRW) + + def test_hash(self): + assert hash(Money('1.2')) == hash(Money('1.2', Currency.USD)) + assert hash(Money('1.5')) != hash(Money('9.3')) + assert hash(Money('99.3', Currency.CHF)) != hash(Money('99.3', Currency.USD)) + + def test_tostring(self): + assert str(Money('1.2')) == 'USD 1.2' + assert str(Money('3.6', Currency.CHF)) == 'CHF 3.6' + assert str(Money('88', Currency.JPY)) == 'JPY 88' + + def test_less_than(self): + assert Money('1.2') < Money('3.5') + assert not Money('104.2') < Money('5.13') + assert not Money('2.2') < Money('2.2') + + with pytest.raises(CurrencyMismatchError): + Money('1.2', Currency.GBP) < Money('3.5', Currency.EUR) + + with pytest.raises(InvalidOperandError): + 1.2 < Money('3.5') + + def test_less_than_or_equal(self): + assert Money('1.2') <= Money('3.5') + assert not Money('104.2') <= Money('5.13') + assert Money('2.2') <= Money('2.2') + + with pytest.raises(CurrencyMismatchError): + Money('1.2', Currency.GBP) <= Money('3.5', Currency.EUR) + + with pytest.raises(InvalidOperandError): + 1.2 <= Money('3.5') + + def test_greater_than(self): + assert Money('3.5') > Money('1.2') + assert not Money('5.13') > Money('104.2') + assert not Money('2.2') > Money('2.2') + + with pytest.raises(CurrencyMismatchError): + Money('3.5', Currency.EUR) > Money('1.2', Currency.GBP) + + with pytest.raises(InvalidOperandError): + Money('3.5') > 1.2 + + def test_greater_than_or_equal(self): + assert Money('3.5') >= Money('1.2') + assert not Money('5.13') >= Money('104.2') + assert Money('2.2') >= Money('2.2') + + with pytest.raises(CurrencyMismatchError): + Money('3.5', Currency.EUR) >= Money('1.2', Currency.GBP) + + with pytest.raises(InvalidOperandError): + Money('3.5') >= 1.2 + + def test_equal(self): + assert Money('3.5') == Money('3.5') + assert Money('4.0', Currency.GBP) == Money('4.0', Currency.GBP) + assert not Money('6.9') == Money('43') + + with pytest.raises(CurrencyMismatchError): + Money('3.5', Currency.EUR) == Money('3.5', Currency.GBP) + + with pytest.raises(InvalidOperandError): + Money('5.5') == 5.5 + + def test_not_equal(self): + assert Money('3.5') != Money('46.44') + assert Money('4.0', Currency.GBP) != Money('12.01', Currency.GBP) + assert not Money('6.9') != Money('6.9') + + with pytest.raises(CurrencyMismatchError): + Money('3.5', Currency.EUR) != Money('23', Currency.GBP) + + with pytest.raises(InvalidOperandError): + Money('5.5') != 666.32 + + def test_bool(self): + assert bool(Money('3.62')) == True + assert bool(Money('0.00')) == False + + def test_add(self): + assert Money('3.5') + Money('1.25') == Money('4.75') + + with pytest.raises(CurrencyMismatchError): + Money('3.5', Currency.EUR) + Money('23', Currency.GBP) + + with pytest.raises(InvalidOperandError): + Money('5.5') + 666.32 + + with pytest.raises(InvalidOperandError): + 666.32 + Money('5.5') + + def test_subtract(self): + assert Money('3.5') - Money('1.25') == Money('2.25') + assert Money('4') - Money('5.5') == Money('-1.5') + + with pytest.raises(CurrencyMismatchError): + Money('3.5', Currency.EUR) - Money('1.8', Currency.GBP) + + with pytest.raises(InvalidOperandError): + Money('5.5') - 6.32 + + with pytest.raises(InvalidOperandError): + 666.32 - Money('5.5') + + def test_multiply(self): + assert Money('3.2') * 3 == Money('9.6') + assert 3 * Money('3.2', Currency.EUR) == Money('9.6', Currency.EUR) + assert Money('9.95') * 0.15 == Money('1.49') + assert Money('3', Currency.JPY) * 0.2 == Money('1', Currency.JPY) + assert Money('3', Currency.KRW) * 1.5 == Money('5', Currency.KRW) + + # since GNF has different subunits than JPY, the results are different even though + # they have the same final decimal precision. hopefully this behavior is correct... + assert Money('3', Currency.JPY) * 1.4995 == Money('4', Currency.JPY) + assert Money('3', Currency.GNF) * 1.4995 == Money('5', Currency.GNF) + + with pytest.raises(InvalidOperandError): + Money('5.5') * Money('1.2') + + def test_divide(self): + assert Money('3.3') / 3 == Money('1.1') + assert Money('9.95') / 0.24 == Money('41.46') + assert Money('3', Currency.JPY) / 1.6 == Money('2', Currency.JPY) + + with pytest.raises(InvalidOperandError): + Money('5.5') / Money('1.2') + + with pytest.raises(TypeError): + 3 / Money('5.5') + + with pytest.raises(ZeroDivisionError): + Money('3.3') / 0 + + def test_floor_divide(self): + assert Money('3.3') // 3 == Money('1') + assert Money('9.95') // 0.24 == Money('41') + assert Money('3', Currency.JPY) // 1.6 == Money('1', Currency.JPY) + + with pytest.raises(InvalidOperandError): + Money('5.5') // Money('1.2') + + with pytest.raises(TypeError): + 3 // Money('5.5') + + with pytest.raises(ZeroDivisionError): + Money('3.3') // 0 + + def test_mod(self): + assert Money('3.3') % 3 == Money('0.3') + assert Money('3', Currency.JPY) % 2 == Money('1', Currency.JPY) + + with pytest.raises(InvalidOperandError): + Money('5.5') % Money('1.2') + + with pytest.raises(TypeError): + 3 % Money('5.5') + + with pytest.raises(ZeroDivisionError): + Money('3.3') % 0 + + def test_neg(self): + assert -Money('5.23') == Money('-5.23') + assert -Money('-1.35') == Money('1.35') + + def test_pos(self): + assert +Money('5.23') == Money('5.23') + assert +Money('-1.35') == Money('-1.35') + + def test_abs(self): + assert abs(Money('5.23')) == Money('5.23') + assert abs(Money('-1.35')) == Money('1.35') + + def test_format(self): + assert Money('3.24').format() == '$3.24' + assert Money('5.56', Currency.EUR).format('en_UK') == '€5.56' + assert Money('10', Currency.JPY).format() == '¥10' + assert Money('94', Currency.JPY).format('ja_JP') == '¥94' diff --git a/pylintrc b/pylintrc new file mode 100644 index 0000000..a58882a --- /dev/null +++ b/pylintrc @@ -0,0 +1,407 @@ +[MASTER] + +# Specify a configuration file. +#rcfile= + +# Python code to execute, usually for sys.path manipulation such as +# pygtk.require(). +#init-hook= + +# Add files or directories to the blacklist. They should be base names, not +# paths. +ignore=CVS + +# Add files or directories matching the regex patterns to the blacklist. The +# regex matches against base names, not paths. +ignore-patterns= + +# Pickle collected data for later comparisons. +persistent=yes + +# List of plugins (as comma separated values of python modules names) to load, +# usually to register additional checkers. +load-plugins= + +# Use multiple processes to speed up Pylint. +jobs=1 + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code +extension-pkg-whitelist= + +# Allow optimization of some AST trees. This will activate a peephole AST +# optimizer, which will apply various small optimizations. For instance, it can +# be used to obtain the result of joining multiple strings with the addition +# operator. Joining a lot of strings can lead to a maximum recursion error in +# Pylint and this flag can prevent that. It has one side effect, the resulting +# AST will be different than the one from reality. This option is deprecated +# and it will be removed in Pylint 2.0. +optimize-ast=no + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED +confidence= + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +#enable= + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once).You can also use "--disable=all" to +# disable everything first and then reenable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use"--disable=all --enable=classes +# --disable=W" +disable=print-statement,parameter-unpacking,unpacking-in-except,old-raise-syntax,backtick,import-star-module-level,apply-builtin,basestring-builtin,buffer-builtin,cmp-builtin,coerce-builtin,execfile-builtin,file-builtin,long-builtin,raw_input-builtin,reduce-builtin,standarderror-builtin,unicode-builtin,xrange-builtin,coerce-method,delslice-method,getslice-method,setslice-method,no-absolute-import,old-division,dict-iter-method,dict-view-method,next-method-called,metaclass-assignment,indexing-exception,raising-string,reload-builtin,oct-method,hex-method,nonzero-method,cmp-method,input-builtin,round-builtin,intern-builtin,unichr-builtin,map-builtin-not-iterating,zip-builtin-not-iterating,range-builtin-not-iterating,filter-builtin-not-iterating,using-cmp-argument,long-suffix,old-ne-operator,old-octal-literal,suppressed-message,useless-suppression + + +[REPORTS] + +# Set the output format. Available formats are text, parseable, colorized, msvs +# (visual studio) and html. You can also give a reporter class, eg +# mypackage.mymodule.MyReporterClass. +output-format=text + +# Put messages in a separate file for each module / package specified on the +# command line instead of printing them on stdout. Reports (if any) will be +# written in a file name "pylint_global.[txt|html]". This option is deprecated +# and it will be removed in Pylint 2.0. +files-output=no + +# Tells whether to display a full report or only the messages +reports=no + +# Python expression which should return a note less than 10 (10 is the highest +# note). You have access to the variables errors warning, statement which +# respectively contain the number of errors / warnings messages and the total +# number of statements analyzed. This is used by the global evaluation report +# (RP0004). +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details +#msg-template= + + +[BASIC] + +# Good variable names which should always be accepted, separated by a comma +good-names=i,j,k,ex,Run,_ + +# Bad variable names which should always be refused, separated by a comma +bad-names=foo,bar,baz,toto,tutu,tata + +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= + +# Include a hint for the correct naming format with invalid-name +include-naming-hint=no + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +property-classes=abc.abstractproperty + +# Regular expression matching correct module names +module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ + +# Naming hint for module names +module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ + +# Regular expression matching correct constant names +const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ + +# Naming hint for constant names +const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$ + +# Regular expression matching correct class names +class-rgx=[A-Z_][a-zA-Z0-9]+$ + +# Naming hint for class names +class-name-hint=[A-Z_][a-zA-Z0-9]+$ + +# Regular expression matching correct function names +function-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Naming hint for function names +function-name-hint=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression matching correct method names +method-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Naming hint for method names +method-name-hint=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression matching correct attribute names +attr-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Naming hint for attribute names +attr-name-hint=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression matching correct argument names +argument-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Naming hint for argument names +argument-name-hint=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression matching correct variable names +variable-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Naming hint for variable names +variable-name-hint=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression matching correct class attribute names +class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ + +# Naming hint for class attribute names +class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ + +# Regular expression matching correct inline iteration names +inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ + +# Naming hint for inline iteration names +inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$ + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=^_ + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=-1 + + +[ELIF] + +# Maximum number of nested blocks for function / method body +max-nested-blocks=5 + + +[FORMAT] + +# Maximum number of characters on a single line. +max-line-length=100 + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=^\s*(# )??$ + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=no + +# List of optional constructs for which whitespace checking is disabled. `dict- +# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. +# `trailing-comma` allows a space between comma and closing bracket: (a, ). +# `empty-line` allows space-only lines. +no-space-check=trailing-comma,dict-separator + +# Maximum number of lines in a module +max-module-lines=1000 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= + + +[LOGGING] + +# Logging modules to check that the string format arguments are in logging +# function parameter format +logging-modules=logging + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME,XXX,TODO + + +[SIMILARITIES] + +# Minimum lines number of a similarity. +min-similarity-lines=4 + +# Ignore comments when computing similarities. +ignore-comments=yes + +# Ignore docstrings when computing similarities. +ignore-docstrings=yes + +# Ignore imports when computing similarities. +ignore-imports=no + + +[SPELLING] + +# Spelling dictionary name. Available dictionaries: none. To make it working +# install python-enchant package. +spelling-dict= + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to indicated private dictionary in +# --spelling-private-dict-file option instead of raising a message. +spelling-store-unknown-words=no + + +[TYPECHECK] + +# Tells whether missing members accessed in mixin class should be ignored. A +# mixin class is detected if its name ends with "mixin" (case insensitive). +ignore-mixin-members=yes + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis. It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules= + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=optparse.Values,thread._local,_thread._local + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members= + +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager + + +[VARIABLES] + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# A regular expression matching the name of dummy variables (i.e. expectedly +# not used). +dummy-variables-rgx=(_+[a-zA-Z0-9]*?$)|dummy + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid to define new builtins when possible. +additional-builtins= + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_,_cb + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six.moves,future.builtins + + +[CLASSES] + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__,__new__,setUp + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=mcs + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict,_fields,_replace,_source,_make + + +[DESIGN] + +# Maximum number of arguments for function / method +max-args=5 + +# Argument names that match this expression will be ignored. Default to name +# with leading underscore +ignored-argument-names=_.* + +# Maximum number of locals for function / method body +max-locals=15 + +# Maximum number of return / yield for function / method body +max-returns=6 + +# Maximum number of branch for function / method body +max-branches=12 + +# Maximum number of statements in function / method body +max-statements=50 + +# Maximum number of parents for a class (see R0901). +max-parents=7 + +# Maximum number of attributes for a class (see R0902). +max-attributes=7 + +# Minimum number of public methods for a class (see R0903). +min-public-methods=2 + +# Maximum number of public methods for a class (see R0904). +max-public-methods=20 + +# Maximum number of boolean expressions in a if statement +max-bool-expr=5 + + +[IMPORTS] + +# Deprecated modules which should not be used, separated by a comma +deprecated-modules=optparse + +# Create a graph of every (i.e. internal and external) dependencies in the +# given file (report RP0402 must not be disabled) +import-graph= + +# Create a graph of external dependencies in the given file (report RP0402 must +# not be disabled) +ext-import-graph= + +# Create a graph of internal dependencies in the given file (report RP0402 must +# not be disabled) +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when being caught. Defaults to +# "Exception" +overgeneral-exceptions=Exception diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..5dd2437 --- /dev/null +++ b/setup.py @@ -0,0 +1,33 @@ +from setuptools import setup +from os import path + +here = path.abspath(path.dirname(__file__)) + +# Get the long description from the README file +with open(path.join(here, 'README.rst'), encoding='utf-8') as f: + long_description = f.read() + +setup( + name='py-money', + packages=[ + 'money' + ], + version='0.1.0', + description='Money module for python', + long_description=long_description, + url='https://github.com/vimeo/py-money', + author='Nicky Robinson', + author_email='nickr@vimeo.com', + license='MIT', + classifiers=[ + 'Development Status :: 4 - Beta', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: MIT License', + 'Programming Language :: Python :: 3', + ], + keywords='money currency', + install_requires=[ + 'pytest==3.0.6', + 'babel==2.4.0' + ], +)