Package: immoney
immoneyInstallation$pipinstall--require-venvimmoneyDesign goalsThere are a few core design aspects of this library that each eliminate entire classes
of bugs:Exposed and internal data types are either immutable or faux immutable.Invalid amounts of money cannot be represented. There is no such thing as0.001US
dollars, and there is no such thing as negative money.Builtin operations never implicitly lose precision.Built from the ground-up with support for static type checking in mind. This means
that bugs that attempt to mix currencies can be found by a static type checker.FeaturesSafe divisionIn 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 forMoneyreturns a tuple of new instances with the value split up as even
as possible. This is implemented asMoney.__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.Subunit fractionsSometimes 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 aSubunitFractiontype. This type is used as return type
forMoney.__truediv__.>>>SEK(13)/3SubunitFraction('1300/3', SEK)Because there is no guarantee that aSubunitFractionis a whole subunit (by definition
...), converting back toMoneycan only be done with precision loss.>>>(SEK(13)/3).round_money(Round.DOWN)Money('4.33', SEK)OverdraftAgain 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 aMoneyinstance
cannot be negative. Instead, to represent for instance a negative balance on an account,
this library exposes anOverdraftclass that is used as return type ofMoney.__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.Type-safe comparisonInstances ofMoneydo not support direct comparison with numeric scalar values. For
convenience an exception is made for integer zero, which is always unambiguous.>>>fromimmoney.currenciesimportSEK>>>SEK(1)==1False>>>SEK(1)>=1Traceback (most recent call last):...TypeError:'>=' not supported between instances of 'Money' and 'int'>>>SEK(0)==0TrueImmediate and full instantiation"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 ...Instance cacheSince instances ofMoneyandCurrencyare immutable it's safe to reuse existing
instances instead of instantiating new ones. This happens transparently when
instantiating a newMoneyinstance and can lead to faster code and less consumed
memory.Support for localizationBecause localization is a large and complex problem to solve, rather than reinventing
the wheel, this is mostly outsourced to theBabel library. There's a wrapping
function provided around Babel'sformat_currency, and a dependency "extra" to install
a compatible version.To useimmoney.babel, make sure to install a compatible version.$pipinstall--require-venvimmoney[babel]The function can be used with instances ofMoneyandOverdraft.>>>fromimmoney.babelimportformat_monetary>>>fromimmoney.currenciesimportKRW,USD>>>format_monetary(KRW(1234),locale="KO")'₩1,234'>>>format_monetary(USD("12.34"),locale="NB")'USD\xa012,34'Becauseformat_monetaryis just a simple wrapper, you need to refer tothe
documentation of Babel'sformat_currencyfor the full documentation
of accepted parameters and their behavior.[!NOTE]Because Babel is not a typed library, you will likely want to installtypes-babelin
your static type checking CI pipeline.Retrieving currencies by codeCurrencies can be retrieved by their codes viaimmoney.currencies.registry.>>>fromimmoney.currenciesimportregistry>>>registry["NOK"]Currency(code=NOK, subunit=100)>>>registry["MVP"]Currency(code=MVP, subunit=1)>>>registry["foo"]Traceback (most recent call last):...KeyError:'foo'Custom currency registriesThe library ships with a sensible set of default currencies, however, you might want to
use a custom registry for two reasons:You want to use non-default currencies.You only want to allow a subset of the default currencies.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.importabcfromtypingimportFinalfromimmoney.registryimportCurrencyCollectorfromimmoney.currenciesimportCurrency__currencies=CurrencyCollector()classSpaceCurrency(Currency,abc.ABC):...classMoonCoinType(SpaceCurrency):subunit=100_000code="MCN"MCN:Final=MoonCoinType()__currencies.add(MCN)classJupiterDollarType(SpaceCurrency):subunit=100code="JCN"JCN:Final=JupiterDollarType()__currencies.add(JCN)custom_registry:Final=__currencies.finalize()Pydantic V2 supportInstall a compatible Pydantic version by supplying the[pydantic]extra.$pipinstall--require-venvimmoney[pydantic]TheCurrency,Money,SubunitFractionandOverdraftentities can all be used as
Pydantic model fields.>>>frompydanticimportBaseModel>>>fromimmoneyimportMoney>>>fromimmoney.currenciesimportUSD>>>classModel(BaseModel,frozen=True):...money:Money>>>print(instance.model_dump_json(indent=2)){"money": {"subunits": 25000,"currency": "USD"}}DevelopingIt's a good idea to use virtualenvs for development. I recommend using a combination ofpyenvandpyenv-virtualenvfor 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-mpipinstall.[pydantic,test]Now, to run the test suite, execute the following.$pytestStatic analysis and formatting is configured withpre-commit.# configure hooks to run when pushing$pre-commitinstall-tpre-push# or on every commit, if you prefer$pre-commitinstall-tpre-commit# run all checks$pre-commitrun--all-files# or just a single hook$pre-commitrunmypy--all-files