<h1 align=center>immoney</h1>
<p align=center>
<a href=https://github.com/antonagestam/immoney/actions?query=workflow%3ACI+branch%3Amain><img src=https://github.com/antonagestam/immoney/workflows/CI/badge.svg alt="CI Build Status"></a>
<a href=https://codecov.io/gh/antonagestam/immoney><img src=https://codecov.io/gh/antonagestam/immoney/branch/main/graph/badge.svg?token=UEI88N0EPG alt="Test coverage report"></a>
<br>
<a href=https://pypi.org/project/immoney/><img src=https://img.shields.io/pypi/v/immoney.svg?color=informational&label=PyPI alt="PyPI Package"></a>
<a href=https://pypi.org/project/immoney/><img src=https://img.shields.io/pypi/pyversions/immoney.svg?color=informational&label=Python alt="Python versions"></a>
</p>
### Installation
```shell
$ pip install --require-venv immoney
```
### Design goals
These core aspects of this library 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 as `0.001` US
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.
- A comprehensive test suite with 100% coverage, including property tests that assert
random [sequences of operations][sequence-test] behave as expected.
[sequence-test]:
https://github.com/antonagestam/immoney/blob/cc8ad48713fcf5c843e9832de9f722366b17a404/tests/test_arithmetic.py#L190
### Features
#### Safe division
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__`.
```pycon
>>> 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 fractions
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__`.
```pycon
>>> 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.
```pycon
>>> (SEK(13) / 3).round_money(Round.DOWN)
Money('4.33', SEK)
```
#### Overdraft
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.
```pycon
>>> 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 comparison
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.
```pycon
>>> 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
```
#### Immediate 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.
```pycon
>>> 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.
```pycon
>>> EUR("2.001")
Traceback (most recent call last):
...
immoney.errors.ParseError: Cannot interpret value as Money of currency EUR ...
```
#### Instance cache
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.
#### Support for localization
Because localization is a large and complex problem to solve, rather than reinventing
the wheel, this is mostly outsourced to the [Babel library][babel]. There's a wrapping
function provided around Babel's `format_currency`, and a dependency ["extra"][extra] to
install a compatible version.
[babel]: https://github.com/python-babel/babel
[extra]:
https://packaging.python.org/en/latest/tutorials/installing-packages/#installing-extras
To use `immoney.babel`, make sure to install a compatible version.
```shell
$ pip install --require-venv immoney[babel]
```
The function can be used with instances of `Money` and `Overdraft`.
```pycon
>>> 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.
[types-babel]: https://pypi.org/project/types-babel/
#### Retrieving currencies by code
Currencies can be retrieved by their codes via `immoney.currencies.registry`.
```pycon
>>> 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'
```
#### Custom currency registries
The 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.
```python
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()
```
#### Pydantic V2 support
Install a compatible Pydantic version by supplying the `[pydantic]` extra.
```shell
$ pip install --require-venv immoney[pydantic]
```
The `Currency`, `Money`, `SubunitFraction` and `Overdraft` entities can all be used as
Pydantic model fields.
```pycon
>>> 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"
}
}
```
### Developing
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.
[pyenv]: https://github.com/pyenv/pyenv
[pyenv-virtualenv]: https://github.com/pyenv/pyenv-virtualenv
To install development requirements, run the following with your virtualenv activated.
```shell
$ python3 -m pip install .[pydantic,test]
```
Now, to run the test suite, execute the following.
```shell
$ pytest
```
Static analysis and formatting is configured with [goose].
[goose]: https://github.com/antonagestam/goose
```shell
# run all checks
$ python3 -m goose run --select=all
# or just a single hook
$ python3 -m goose run ruff-format --select=all
```
Raw data
{
"_id": null,
"home_page": null,
"name": "immoney",
"maintainer": null,
"docs_url": null,
"requires_python": ">=3.10",
"maintainer_email": null,
"keywords": "money, finance, fintech, type-driven, immutable",
"author": null,
"author_email": "Anton Agestam <git@antonagestam.se>",
"download_url": "https://files.pythonhosted.org/packages/d6/24/0aa24b567b25f1d1c81774304d3902b86c3f22037bcf2c7968c6036181e8/immoney-0.11.0.tar.gz",
"platform": null,
"description": "<h1 align=center>immoney</h1>\n\n<p align=center>\n <a href=https://github.com/antonagestam/immoney/actions?query=workflow%3ACI+branch%3Amain><img src=https://github.com/antonagestam/immoney/workflows/CI/badge.svg alt=\"CI Build Status\"></a>\n <a href=https://codecov.io/gh/antonagestam/immoney><img src=https://codecov.io/gh/antonagestam/immoney/branch/main/graph/badge.svg?token=UEI88N0EPG alt=\"Test coverage report\"></a>\n <br>\n <a href=https://pypi.org/project/immoney/><img src=https://img.shields.io/pypi/v/immoney.svg?color=informational&label=PyPI alt=\"PyPI Package\"></a>\n <a href=https://pypi.org/project/immoney/><img src=https://img.shields.io/pypi/pyversions/immoney.svg?color=informational&label=Python alt=\"Python versions\"></a>\n</p>\n\n### Installation\n\n```shell\n$ pip install --require-venv immoney\n```\n\n### Design goals\n\nThese core aspects of this library each eliminate entire classes of bugs:\n\n- Exposed and internal data types are either immutable or faux immutable.\n- Invalid amounts of money cannot be represented. There is no such thing as `0.001` US\n dollars, and there is no such thing as negative money.\n- Builtin operations never implicitly lose precision.\n- Built from the ground-up with support for static type checking in mind. This means\n that bugs that attempt to mix currencies can be found by a static type checker.\n- A comprehensive test suite with 100% coverage, including property tests that assert\n random [sequences of operations][sequence-test] behave as expected.\n\n[sequence-test]:\n https://github.com/antonagestam/immoney/blob/cc8ad48713fcf5c843e9832de9f722366b17a404/tests/test_arithmetic.py#L190\n\n### Features\n\n#### Safe division\n\nIn real life we cannot split the subunit of a currency, and so for our abstractions to\nsafely reflect reality, we shouldn't be able to do that in code either. Therefore\ninstead of defining division to return a value with precision loss, the implementation\nof division for `Money` returns a tuple of new instances with the value split up as even\nas possible. This is implemented as `Money.__floordiv__`.\n\n```pycon\n>>> Money(\"0.11\", SEK) // 3\n(Money('0.04', SEK), Money('0.04', SEK), Money('0.03', SEK))\n```\n\nThis method of division will always be safe, as it has the guaranteed property that the\nsum of the instances returned by the operation always equal the original numerator.\n\n#### Subunit fractions\n\nSometimes we do need to represent fractions of monetary values that are smaller than the\nsubunit of a currency, for instance as a partial result of a larger equation. For that\npurpose, this library exposes a `SubunitFraction` type. This type is used as return type\nfor `Money.__truediv__`.\n\n```pycon\n>>> SEK(13) / 3\nSubunitFraction('1300/3', SEK)\n```\n\nBecause there is no guarantee that a `SubunitFraction` is a whole subunit (by definition\n...), converting back to `Money` can only be done with precision loss.\n\n```pycon\n>>> (SEK(13) / 3).round_money(Round.DOWN)\nMoney('4.33', SEK)\n```\n\n#### Overdraft\n\nAgain referring to real life, there is no such thing as negative money. Following in the\nsame vein as for not allowing subunits to be split, the value of a `Money` instance\ncannot be negative. Instead, to represent for instance a negative balance on an account,\nthis library exposes an `Overdraft` class that is used as return type of `Money.__sub__`\nwhen the computed value would have been negative.\n\n```pycon\n>>> balance = SEK(5)\n>>> balance - SEK(4)\nMoney('1.00', SEK)\n>>> balance - SEK(5)\nMoney('0.00', SEK)\n>>> balance - SEK(\"6.50\")\nOverdraft('1.50', SEK)\n>>> balance - SEK(\"6.50\") + SEK(\"1.50\")\nMoney('0.00', SEK)\n```\n\nBecause negative values are encoded as its own type in this way, situations where\nnegative values can result from arithmetic but aren't logically expected, such as for\nthe price of an item in a store, can be discovered with a static type checker.\n\n#### Type-safe comparison\n\nInstances of `Money` do not support direct comparison with numeric scalar values. For\nconvenience an exception is made for integer zero, which is always unambiguous.\n\n```pycon\n>>> from immoney.currencies import SEK\n>>> SEK(1) == 1\nFalse\n>>> SEK(1) >= 1\nTraceback (most recent call last):\n ...\nTypeError: '>=' not supported between instances of 'Money' and 'int'\n>>> SEK(0) == 0\nTrue\n```\n\n#### Immediate and full instantiation\n\n\"2 dollars\" is represented exactly the same as \"2.00 dollars\", in every aspect. This\nmeans that normalization of values happen at instantiation time.\n\nInstantiating normalizes precision to the number of subunits of the instantiated\ncurrency.\n\n```pycon\n>>> EUR(2)\nMoney('2.00', EUR)\n>>> EUR(\"2.000\")\nMoney('2.00', EUR)\n```\n\nTrying to instantiate with a value that would result in precision loss raises a runtime\nerror.\n\n```pycon\n>>> EUR(\"2.001\")\nTraceback (most recent call last):\n ...\nimmoney.errors.ParseError: Cannot interpret value as Money of currency EUR ...\n```\n\n#### Instance cache\n\nSince instances of `Money` and `Currency` are immutable it's safe to reuse existing\ninstances instead of instantiating new ones. This happens transparently when\ninstantiating a new `Money` instance and can lead to faster code and less consumed\nmemory.\n\n#### Support for localization\n\nBecause localization is a large and complex problem to solve, rather than reinventing\nthe wheel, this is mostly outsourced to the [Babel library][babel]. There's a wrapping\nfunction provided around Babel's `format_currency`, and a dependency [\"extra\"][extra] to\ninstall a compatible version.\n\n[babel]: https://github.com/python-babel/babel\n[extra]:\n https://packaging.python.org/en/latest/tutorials/installing-packages/#installing-extras\n\nTo use `immoney.babel`, make sure to install a compatible version.\n\n```shell\n$ pip install --require-venv immoney[babel]\n```\n\nThe function can be used with instances of `Money` and `Overdraft`.\n\n```pycon\n>>> from immoney.babel import format_monetary\n>>> from immoney.currencies import KRW, USD\n>>> format_monetary(KRW(1234), locale=\"KO\")\n'\u20a91,234'\n>>> format_monetary(USD(\"12.34\"), locale=\"NB\")\n'USD\\xa012,34'\n```\n\nBecause `format_monetary` is just a simple wrapper, you need to refer to [the\ndocumentation of Babel's `format_currency`][format-currency] for the full documentation\nof accepted parameters and their behavior.\n\n[format-currency]:\n https://babel.pocoo.org/en/latest/api/numbers.html#babel.numbers.format_currency\n\n> [!NOTE]\\\n> Because Babel is not a typed library, you will likely want to install [types-babel] in\n> your static type checking CI pipeline.\n\n[types-babel]: https://pypi.org/project/types-babel/\n\n#### Retrieving currencies by code\n\nCurrencies can be retrieved by their codes via `immoney.currencies.registry`.\n\n```pycon\n>>> from immoney.currencies import registry\n>>> registry[\"NOK\"]\nCurrency(code=NOK, subunit=100)\n>>> registry[\"MVP\"]\nCurrency(code=MVP, subunit=1)\n>>> registry[\"foo\"]\nTraceback (most recent call last):\n ...\nKeyError: 'foo'\n```\n\n#### Custom currency registries\n\nThe library ships with a sensible set of default currencies, however, you might want to\nuse a custom registry for two reasons:\n\n- You want to use non-default currencies.\n- You only want to allow a subset of the default currencies.\n\nTo achieve this, you can construct a custom set of types. It's recommended to use a\ncustom abstract base class for this, this way things will also play nice with the\nPydantic integration.\n\n```python\nimport abc\nfrom typing import Final\nfrom immoney.registry import CurrencyCollector\nfrom immoney.currencies import Currency\n\n__currencies = CurrencyCollector()\n\n\nclass SpaceCurrency(Currency, abc.ABC): ...\n\n\nclass MoonCoinType(SpaceCurrency):\n subunit = 100_000\n code = \"MCN\"\n\n\nMCN: Final = MoonCoinType()\n__currencies.add(MCN)\n\n\nclass JupiterDollarType(SpaceCurrency):\n subunit = 100\n code = \"JCN\"\n\n\nJCN: Final = JupiterDollarType()\n__currencies.add(JCN)\n\ncustom_registry: Final = __currencies.finalize()\n```\n\n#### Pydantic V2 support\n\nInstall a compatible Pydantic version by supplying the `[pydantic]` extra.\n\n```shell\n$ pip install --require-venv immoney[pydantic]\n```\n\nThe `Currency`, `Money`, `SubunitFraction` and `Overdraft` entities can all be used as\nPydantic model fields.\n\n```pycon\n>>> from pydantic import BaseModel\n>>> from immoney import Money\n>>> from immoney.currencies import USD\n>>> class Model(BaseModel, frozen=True):\n... money: Money\n...\n>>> print(instance.model_dump_json(indent=2))\n{\n \"money\": {\n \"subunits\": 25000,\n \"currency\": \"USD\"\n }\n}\n```\n\n### Developing\n\nIt's a good idea to use virtualenvs for development. I recommend using a combination of\n[pyenv] and [pyenv-virtualenv] for installing Python versions and managing virtualenvs.\nUsing the lowest supported version for development is recommended, as of writing this is\nPython 3.10.\n\n[pyenv]: https://github.com/pyenv/pyenv\n[pyenv-virtualenv]: https://github.com/pyenv/pyenv-virtualenv\n\nTo install development requirements, run the following with your virtualenv activated.\n\n```shell\n$ python3 -m pip install .[pydantic,test]\n```\n\nNow, to run the test suite, execute the following.\n\n```shell\n$ pytest\n```\n\nStatic analysis and formatting is configured with [goose].\n\n[goose]: https://github.com/antonagestam/goose\n\n```shell\n# run all checks\n$ python3 -m goose run --select=all\n# or just a single hook\n$ python3 -m goose run ruff-format --select=all\n```\n",
"bugtrack_url": null,
"license": "BSD-3-Clause",
"summary": "Immutable money types for Python",
"version": "0.11.0",
"project_urls": {
"Bug Tracker": "https://github.com/antonagestam/immoney/issues",
"Source Repository": "https://github.com/antonagestam/immoney/"
},
"split_keywords": [
"money",
" finance",
" fintech",
" type-driven",
" immutable"
],
"urls": [
{
"comment_text": "",
"digests": {
"blake2b_256": "0b2ac55bedc9274b572643697255f2910be909dceb372f972467a82485022b13",
"md5": "f3fc4278a39faa152502d5bf4c0988cb",
"sha256": "f3e7acee8b87aab75b2fad54d4a49d86cacc41d4983700aadec2dcc4aa665019"
},
"downloads": -1,
"filename": "immoney-0.11.0-py3-none-any.whl",
"has_sig": false,
"md5_digest": "f3fc4278a39faa152502d5bf4c0988cb",
"packagetype": "bdist_wheel",
"python_version": "py3",
"requires_python": ">=3.10",
"size": 22990,
"upload_time": "2024-10-19T17:00:44",
"upload_time_iso_8601": "2024-10-19T17:00:44.925419Z",
"url": "https://files.pythonhosted.org/packages/0b/2a/c55bedc9274b572643697255f2910be909dceb372f972467a82485022b13/immoney-0.11.0-py3-none-any.whl",
"yanked": false,
"yanked_reason": null
},
{
"comment_text": "",
"digests": {
"blake2b_256": "d6240aa24b567b25f1d1c81774304d3902b86c3f22037bcf2c7968c6036181e8",
"md5": "9cb40dc6a82f2d7b1a59e483f9052d29",
"sha256": "20b1e2273e371aa9fd8320ae2beb21e84a8d1e47d4fd86aa962766f4c7f14206"
},
"downloads": -1,
"filename": "immoney-0.11.0.tar.gz",
"has_sig": false,
"md5_digest": "9cb40dc6a82f2d7b1a59e483f9052d29",
"packagetype": "sdist",
"python_version": "source",
"requires_python": ">=3.10",
"size": 26264,
"upload_time": "2024-10-19T17:00:46",
"upload_time_iso_8601": "2024-10-19T17:00:46.192791Z",
"url": "https://files.pythonhosted.org/packages/d6/24/0aa24b567b25f1d1c81774304d3902b86c3f22037bcf2c7968c6036181e8/immoney-0.11.0.tar.gz",
"yanked": false,
"yanked_reason": null
}
],
"upload_time": "2024-10-19 17:00:46",
"github": true,
"gitlab": false,
"bitbucket": false,
"codeberg": false,
"github_user": "antonagestam",
"github_project": "immoney",
"travis_ci": false,
"coveralls": false,
"github_actions": true,
"requirements": [],
"lcname": "immoney"
}