$ pip install --require-venv immoney
These core aspects of this library each eliminate entire classes of bugs:
0.001
US
dollars, and there is no such thing as negative money.[sequence-test]: https://github.com/antonagestam/immoney/blob/cc8ad48713fcf5c843e9832de9f722366b17a404/tests/test_arithmetic.py#L190
In real life we cannot split the subunit of a currency, and so for our abstractions to
safely reflect reality, we shouldn't be able to do that in code either. Therefore
instead of defining division to return a value with precision loss, the implementation
of division for Money
returns a tuple of new instances with the value split up as even
as possible. This is implemented as Money.__floordiv__
.
>>> Money("0.11", SEK) // 3
(Money('0.04', SEK), Money('0.04', SEK), Money('0.03', SEK))
This method of division will always be safe, as it has the guaranteed property that the sum of the instances returned by the operation always equal the original numerator.
Sometimes we do need to represent fractions of monetary values that are smaller than the
subunit of a currency, for instance as a partial result of a larger equation. For that
purpose, this library exposes a SubunitFraction
type. This type is used as return type
for Money.__truediv__
.
>>> SEK(13) / 3
SubunitFraction('1300/3', SEK)
Because there is no guarantee that a SubunitFraction
is a whole subunit (by definition
...), converting back to Money
can only be done with precision loss.
>>> (SEK(13) / 3).round_money(Round.DOWN)
Money('4.33', SEK)
Again referring to real life, there is no such thing as negative money. Following in the
same vein as for not allowing subunits to be split, the value of a Money
instance
cannot be negative. Instead, to represent for instance a negative balance on an account,
this library exposes an Overdraft
class that is used as return type of Money.__sub__
when the computed value would have been negative.
>>> balance = SEK(5)
>>> balance - SEK(4)
Money('1.00', SEK)
>>> balance - SEK(5)
Money('0.00', SEK)
>>> balance - SEK("6.50")
Overdraft('1.50', SEK)
>>> balance - SEK("6.50") + SEK("1.50")
Money('0.00', SEK)
Because negative values are encoded as its own type in this way, situations where negative values can result from arithmetic but aren't logically expected, such as for the price of an item in a store, can be discovered with a static type checker.
Instances of Money
do not support direct comparison with numeric scalar values. For
convenience an exception is made for integer zero, which is always unambiguous.
>>> from immoney.currencies import SEK
>>> SEK(1) == 1
False
>>> SEK(1) >= 1
Traceback (most recent call last):
...
TypeError: '>=' not supported between instances of 'Money' and 'int'
>>> SEK(0) == 0
True
"2 dollars" is represented exactly the same as "2.00 dollars", in every aspect. This means that normalization of values happen at instantiation time.
Instantiating normalizes precision to the number of subunits of the instantiated currency.
>>> EUR(2)
Money('2.00', EUR)
>>> EUR("2.000")
Money('2.00', EUR)
Trying to instantiate with a value that would result in precision loss raises a runtime error.
>>> EUR("2.001")
Traceback (most recent call last):
...
immoney.errors.ParseError: Cannot interpret value as Money of currency EUR ...
Since instances of Money
and Currency
are immutable it's safe to reuse existing
instances instead of instantiating new ones. This happens transparently when
instantiating a new Money
instance and can lead to faster code and less consumed
memory.
Because localization is a large and complex problem to solve, rather than reinventing
the wheel, this is mostly outsourced to the Babel library. There's a wrapping
function provided around Babel's format_currency
, and a dependency ["extra"][extra] to
install a compatible version.
[extra]: https://packaging.python.org/en/latest/tutorials/installing-packages/#installing-extras
To use immoney.babel
, make sure to install a compatible version.
$ pip install --require-venv immoney[babel]
The function can be used with instances of Money
and Overdraft
.
>>> from immoney.babel import format_monetary
>>> from immoney.currencies import KRW, USD
>>> format_monetary(KRW(1234), locale="KO")
'₩1,234'
>>> format_monetary(USD("12.34"), locale="NB")
'USD\xa012,34'
Because format_monetary
is just a simple wrapper, you need to refer to [the
documentation of Babel's format_currency
][format-currency] for the full documentation
of accepted parameters and their behavior.
[format-currency]: https://babel.pocoo.org/en/latest/api/numbers.html#babel.numbers.format_currency
[!NOTE]\ Because Babel is not a typed library, you will likely want to install types-babel in your static type checking CI pipeline.
Currencies can be retrieved by their codes via immoney.currencies.registry
.
>>> from immoney.currencies import registry
>>> registry["NOK"]
Currency(code=NOK, subunit=100)
>>> registry["MVP"]
Currency(code=MVP, subunit=1)
>>> registry["foo"]
Traceback (most recent call last):
...
KeyError: 'foo'
The library ships with a sensible set of default currencies, however, you might want to use a custom registry for two reasons:
To achieve this, you can construct a custom set of types. It's recommended to use a custom abstract base class for this, this way things will also play nice with the Pydantic integration.
import abc
from typing import Final
from immoney.registry import CurrencyCollector
from immoney.currencies import Currency
__currencies = CurrencyCollector()
class SpaceCurrency(Currency, abc.ABC): ...
class MoonCoinType(SpaceCurrency):
subunit = 100_000
code = "MCN"
MCN: Final = MoonCoinType()
__currencies.add(MCN)
class JupiterDollarType(SpaceCurrency):
subunit = 100
code = "JCN"
JCN: Final = JupiterDollarType()
__currencies.add(JCN)
custom_registry: Final = __currencies.finalize()
Install a compatible Pydantic version by supplying the [pydantic]
extra.
$ pip install --require-venv immoney[pydantic]
The Currency
, Money
, SubunitFraction
and Overdraft
entities can all be used as
Pydantic model fields.
>>> from pydantic import BaseModel
>>> from immoney import Money
>>> from immoney.currencies import USD
>>> class Model(BaseModel, frozen=True):
... money: Money
...
>>> print(instance.model_dump_json(indent=2))
{
"money": {
"subunits": 25000,
"currency": "USD"
}
}
It's a good idea to use virtualenvs for development. I recommend using a combination of pyenv and pyenv-virtualenv for installing Python versions and managing virtualenvs. Using the lowest supported version for development is recommended, as of writing this is Python 3.10.
To install development requirements, run the following with your virtualenv activated.
$ python3 -m pip install .[pydantic,test]
Now, to run the test suite, execute the following.
$ pytest
Static analysis and formatting is configured with goose.
# run all checks
$ python3 -m goose run --select=all
# or just a single hook
$ python3 -m goose run ruff-format --select=all