Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Pydantic (2) support – addition of __get_pydantic_core_schema__ #276

Merged
merged 15 commits into from
Nov 23, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/pythonpackage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ jobs:
run: |
python tests/types/implementation_test.py
python tests/types/conversion_type.py
python tests/types/pydantic_field.py
python tests/examples/bankfile_test.py
- name: Codecov
run: |
Expand Down
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ $ pip install stockholm[protobuf]

* [**Arithmetics – works with loads of compatible types – completely currency aware.**](#arithmetics---fully-supported)
* [**Instantiating a monetary amount in many flexible ways.**](#input-data-types-in-flexible-variants)
* [**Use in Pydantic models.**](#use-in-pydantic-models)
* [**Using `stockholm.Money` monetary amount with Protocol Buffers.**](#using-protocol-buffers-for-transporting-monetary-amounts-over-the-network)
* [**Conversion between dicts, JSON and values for use in GraphQL or other JSON-based API:s:**](#conversion-for-other-transport-medium-for-example-protocol-buffers-or-json)
- [**Using dict values for input and output / having GraphQL in mind.**](#monetary-amounts-can-also-be-exported-to-dict-as-well-as-created-with-dict-value-input-which-can-be-great-to-for-example-transport-a-monetary-value-in-json)
Expand Down Expand Up @@ -389,6 +390,49 @@ sum(amounts)
# <stockholm.Money: "1002.50">
```

### Use in Pydantic models

`Money` objects can be used in Pydantic (`Pydantic>=2.2` supported) models and used with Pydantic's JSON serialization and validation – the same goes for `Number` and `Currency` objects as well. Specify the `stockholm.Money` type as the field type and you're good to go.

```python
from pydantic import BaseModel
from stockholm import Money

class Transaction(BaseModel):
reference: str
amount: Money

transaction = Transaction(reference="abc123", amount=Money("100.00", "SEK"))
# Transaction(reference='abc123', amount=<stockholm.Money: "100.00 SEK">)

json_data = transaction.model_dump_json()
# '{"reference":"abc123","amount":{"value":"100.00 SEK","units":100,"nanos":0,"currency_code":"SEK"}}'

Transaction.model_validate_json(json_data)
# Transaction(reference='abc123', amount=<stockholm.Money: "100.00 SEK">)
```

It's also possible to use the `stockholm.types` Pydantic field types, for example `stockholm.types.ConvertibleToMoney`, which will automatically coerce input into a `Money` object.

```python
from pydantic import BaseModel
from stockholm import Money
from stockholm.types import ConvertibleToMoney

class ExampleModel(BaseModel):
amount: ConvertibleToMoney

example = ExampleModel(amount="4711.50 USD")
# ExampleModel(amount=<stockholm.Money: "4711.50 USD">)

example.model_dump_json()
# '{"amount":{"value":"4711.50 USD","units":4711,"nanos":500000000,"currency_code":"USD"}}'
```

Other similar field types that can be used on Pydantic fields are `ConvertibleToNumber`, `ConvertibleToMoneyWithRequiredCurrency` and `ConvertibleToCurrency` – all imported from `stockholm.types`.

Note that it's generally recommended to opt for the more strict types (`stockholm.Money`, `stockholm.Number` and `stockholm.Currency`) when possible and the coercion types should be used with caution and is mainly suited for experimentation and early development.

### Conversion for other transport medium (for example Protocol Buffers or JSON)

##### *Easily splittable into `units` and `nanos` for transport in network medium, for example using the [`google.type.Money` protobuf definition](https://github.com/googleapis/googleapis/blob/master/google/type/money.proto) when using Protocol Buffers.*
Expand Down
500 changes: 332 additions & 168 deletions poetry.lock

Large diffs are not rendered by default.

45 changes: 45 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ classifiers = [
[tool.poetry.dependencies]
python = "^3.8"
protobuf = { version = ">=3.20.0,<5.0.0", optional = true }
typing-extensions = { version = ">=4.7.0", python = "<=3.10" }

[tool.poetry.dev-dependencies]
flake8 = { version = ">=3.8.4", markers = "sys_platform != \"win32\"" }
Expand All @@ -43,6 +44,7 @@ codecov = { version = ">=2.1.10", markers = "sys_platform != \"win32\"" }
protobuf = { version = ">=3.20.0,<5.0.0", markers = "sys_platform != \"win32\"" }
types-protobuf = { version = ">=0.1.13", markers = "sys_platform != \"win32\"" }
setuptools = { version = ">=68.1.2", markers = "sys_platform != \"win32\"" }
pydantic = { version = ">=2.2", markers = "sys_platform != \"win32\"" }

[tool.poetry.extras]
protobuf = ["protobuf"]
Expand All @@ -64,6 +66,49 @@ src_paths = ["stockholm", "tests"]
known_first_party = "stockholm"
skip = [".mypy_cache", ".pytest_cache", "__pycache__", "stockholm.egg-info", ".eggs", ".git", ".venv", ".vscode", "build", "dist", "tmp"]

[tool.ruff]
line-length = 120
target-version = "py38"
select = [
"F", # pyflakes
"E", # pycodestyle (erorr)
"I", # isort
"W", # pycodestyle (warning)
"C901", # complex-structure
"UP", # pyupgrade
"N", # pep8-naming
"B", # flake8-bugbear
"DTZ", # flake8-datetimez
"Q", # flake8-quotes
"T20", # flake8-print
"PL", # pylint
"PIE", # flake8-pie
"RET", # flake8-return
"SLF", # flake8-self
"SIM", # flake8-simplify
"PGH", # pygrep-hooks
"RUF", # ruff-specific
"PT", # flake8-pytest-style
"C4", # flake8-comprehensions
"A", # flake8-builtins
"BLE", # flake8-blind-except
"S", # flake8-bandit
]
ignore = [
"UP007", # union type annotations
]
src = [
"src",
"tests",
]

[tool.ruff.per-file-ignores]
"tests/**/*.py" = [
"S101", # assert
"I003", # isort
"PLR2004", # magic-value-comparison
]

[tool.mypy]
pretty = true
files = ["$MYPY_CONFIG_FILE_DIR/stockholm", "$MYPY_CONFIG_FILE_DIR/tests/types", "$MYPY_CONFIG_FILE_DIR/tests/examples"]
Expand Down
74 changes: 73 additions & 1 deletion stockholm/currency.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import sys
from decimal import Decimal
from typing import Any, Dict, List, Optional, Protocol, Set, Tuple, Type, Union, cast
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Protocol, Set, Tuple, Type, Union, cast


class DefaultCurrencyValue(type):
Expand Down Expand Up @@ -140,6 +140,70 @@ def __instancecheck__(self, instance: Any) -> bool:
return True
return return_value

@classmethod
def __get_pydantic_core_schema__(
cls,
_source_type: Any,
_handler: Any,
) -> Any:
def validate_currency_code(value: Any) -> BaseCurrency:
return get_currency(str(value))

def serialize(value: Any) -> str:
return str(value)

currency_validator_function_schema = {
"type": "function-plain",
"function": {"type": "no-info", "function": validate_currency_code},
}
currency_regex_str_schema = {
"type": "str",
"pattern": "^[a-zA-Z]+$",
}
is_currency_instance_schema = {"type": "is-instance", "cls": BaseCurrencyType}

schemas = [
{
"type": "chain",
"steps": [step, currency_validator_function_schema],
}
for step in (
is_currency_instance_schema,
currency_regex_str_schema,
)
]

def json_schema(schema: Any) -> Any:
if isinstance(schema, dict):
if schema.get("type") == "is-instance":
return None
return {k: json_schema(v) for k, v in schema.items() if json_schema(v) is not None}
elif isinstance(schema, list):
return [json_schema(v) for v in schema if json_schema(v) is not None]
return schema

return {
"type": "json-or-python",
"json_schema": {
"type": "union",
"choices": json_schema(schemas),
},
"python_schema": {
"type": "union",
"choices": schemas,
"strict": True,
},
"serialization": {
"type": "function-plain",
"function": serialize,
"when_used": "json-unless-none",
},
}

@classmethod
def _validate(cls, value: Any, handler: Callable[..., BaseCurrency]) -> BaseCurrency:
return handler(value)


class BaseCurrencyType(metaclass=MetaCurrency):
ticker: str
Expand Down Expand Up @@ -1879,5 +1943,13 @@ class Currency(BaseCurrency):
ZWN = ZWN
ZWR = ZWR

if TYPE_CHECKING: # pragma: no cover

def __get__(self, instance: Any, owner: Any) -> BaseCurrency:
return cast(BaseCurrency, ...)

def __set__(self, instance: Any, value: CurrencyValue) -> None:
...


from stockholm.money import Money # noqa isort:skip
145 changes: 144 additions & 1 deletion stockholm/money.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import re
from decimal import ROUND_HALF_UP, Decimal
from functools import reduce
from typing import Any, Dict, Generic, Iterable, List, Optional, Tuple, Type, TypeVar, Union, cast
from typing import Any, Callable, Dict, Generic, Iterable, List, Optional, Tuple, Type, TypeVar, Union, cast

from .currency import BaseCurrencyType, CurrencyValue, DefaultCurrency, DefaultCurrencyValue
from .exceptions import ConversionError, CurrencyMismatchError, InvalidOperandError
Expand Down Expand Up @@ -918,6 +918,149 @@ def __copy__(self) -> MoneyModel[MoneyType]:
def __deepcopy__(self, memo: Dict) -> MoneyModel[MoneyType]:
return self

@classmethod
def __get_pydantic_core_schema__(
cls,
_source_type: Any,
_handler: Any,
) -> Any:
def validate_money(value: Any) -> MoneyModel[MoneyType]:
return cls(value)

def serialize(value: MoneyModel[MoneyType]) -> Dict:
return value.asdict()

money_validator_function_schema = {
"type": "function-plain",
"function": {"type": "no-info", "function": validate_money},
}
money_regex_str_schema = {
"type": "str",
"pattern": "^(?:[-+]?[0-9.]+([ ]+[a-zA-Z]+)?|[a-zA-Z]+[ ]+[-+]?[0-9.]+)$",
}
currency_regex_str_schema = {
"type": "str",
"pattern": "^[a-zA-Z]+$",
}
float_schema = {"type": "float", "allow_inf_nan": False}
int_schema = {"type": "int"}
decimal_schema = {"type": "decimal", "allow_inf_nan": False}
is_money_model_instance_schema = {"type": "is-instance", "cls": MoneyModel}
is_currency_instance_schema = {"type": "is-instance", "cls": BaseCurrencyType}

field_value = field_amount = {
"type": "typed-dict-field",
"schema": {
"type": "nullable",
"schema": {
"type": "union",
"choices": [
is_money_model_instance_schema,
money_regex_str_schema,
float_schema,
int_schema,
decimal_schema,
],
},
},
"required": False,
}
field_currency = {
"type": "typed-dict-field",
"schema": {
"type": "nullable",
"schema": {
"type": "union",
"choices": [
is_currency_instance_schema,
currency_regex_str_schema,
],
},
},
"required": False,
}
field_currency_code = {
"type": "typed-dict-field",
"schema": {"type": "nullable", "schema": currency_regex_str_schema},
"required": False,
}
field_from_sub_units = {
"type": "typed-dict-field",
"schema": {"type": "nullable", "schema": {"type": "bool"}},
"required": False,
}
field_units = {
"type": "typed-dict-field",
"schema": {"type": "int", "le": 999999999999999999, "ge": -999999999999999999},
"required": False,
}
field_nanos = {
"type": "typed-dict-field",
"schema": {"type": "int", "le": 999999999, "ge": -999999999},
"required": False,
}
money_model_dict_schema = {
"type": "typed-dict",
"fields": {
"amount": field_amount,
"units": field_units,
"nanos": field_nanos,
"currency": field_currency,
"currency_code": field_currency_code,
"from_sub_units": field_from_sub_units,
"value": field_value,
},
}

schemas = [
{
"type": "chain",
"steps": [step, money_validator_function_schema],
}
for step in (
is_money_model_instance_schema,
money_regex_str_schema,
float_schema,
int_schema,
decimal_schema,
money_model_dict_schema,
)
]

def json_schema(schema: Any) -> Any:
if isinstance(schema, dict):
if schema.get("type") == "is-instance":
return None
return {k: json_schema(v) for k, v in schema.items() if json_schema(v) is not None}
elif isinstance(schema, list):
return [json_schema(v) for v in schema if json_schema(v) is not None]
return schema

return {
"type": "json-or-python",
"json_schema": {
"type": "union",
"choices": json_schema(schemas),
},
"python_schema": {
"type": "union",
"choices": [
{"type": "is-instance", "cls": cls},
*schemas,
],
"strict": True,
},
"serialization": {
"type": "function-plain",
"function": serialize,
"when_used": "json-unless-none",
},
}

@classmethod
def _validate(cls, value: Any, handler: Callable[..., MoneyType]) -> MoneyType:
return handler(value)


class Money(MoneyModel["Money"]):
def to_currency(self, currency: Optional[Union[CurrencyValue, str]]) -> "Money":
Expand Down
Loading