Skip to content

Commit f78b14e

Browse files
authored
Merge pull request #99 from binary-butterfly/decimal-rounding-mode
DecimalValidator: Add parameter to specify rounding mode (#88)
2 parents ec99a1d + 4a27f40 commit f78b14e

File tree

8 files changed

+179
-76
lines changed

8 files changed

+179
-76
lines changed

docs/03-basic-validators.md

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -474,15 +474,23 @@ The `DecimalValidator` accepts decimal numbers **as strings** (e.g. `"1.234"`) a
474474
Only allows finite numbers in regular decimal notation (e.g. `"1.234"`, `"-42"`, `".00"`, ...), but no other values that
475475
are accepted by `decimal.Decimal` (e.g. no `Infinity` or `NaN` and no scientific notation).
476476

477-
Optionally a number range (minimum/maximum value either as `Decimal`, integer or decimal string), minimum/maximum number
478-
of decimal places and a fixed number of decimal places in the output value can be specified.
477+
Optionally a number range (minimum/maximum value as `Decimal`, integer or decimal string) can be specified using the
478+
parameters `min_value` and `max_value`, as well as a minimum/maximum number of decimal places in the input using
479+
`min_places` and `max_places`.
479480

480-
A fixed number of output places will result in rounding according to the current decimal context (see `decimal.getcontext()`),
481-
by default this means that `"1.49"` will be rounded to `1` and `"1.50"` to `2`.
481+
You can also specify how many decimal places the output value should have using `output_places`. If this parameter
482+
is set, the output value will always have the specified amount of decimal places. If rounding is necessary, a
483+
rounding mode as defined by the `decimal` module (https://docs.python.org/3/library/decimal.html#rounding-modes) is
484+
used, which can be specified with the `rounding` parameter.
485+
486+
The rounding mode defaults to `decimal.ROUND_HALF_UP`, which basically means that digits 0 to 4 are rounded down and
487+
digits 5 to 9 are rounded up (e.g. with `output_places=1`, "1.149" would be rounded to "1.1" and "1.150" would be
488+
rounded to "1.2"). Alternatively, set `rounding=None` to use the rounding mode set by the current decimal context.
482489

483490
**Examples:**
484491

485492
```python
493+
import decimal
486494
from decimal import Decimal
487495

488496
from validataclass.validators import DecimalValidator
@@ -515,6 +523,13 @@ validator.validate("1.23") # will return Decimal('1.230')
515523
validator.validate("0.1234") # will return Decimal('0.123')
516524
validator.validate("0.1235") # will return Decimal('0.124')
517525
validator.validate("100000.00") # will return Decimal('100000.000')
526+
527+
# Use a different rounding mode to always round numbers up (i.e. away from zero)
528+
validator = DecimalValidator(output_places=2, rounding=decimal.ROUND_UP)
529+
validator.validate("1.0") # will return Decimal('1.00')
530+
validator.validate("1.001") # will return Decimal('1.01')
531+
validator.validate("1.009") # will return Decimal('1.01')
532+
validator.validate("-1.001") # will return Decimal('-1.01')
518533
```
519534

520535

@@ -533,8 +548,8 @@ If you want to accept all three numeric input types (integers, floats and decima
533548
basically is just a `FloatToDecimalValidator` with those two options always enabled.
534549

535550
Like the `DecimalValidator` it supports the optional parameters `min_value` and `max_value` (specified as `Decimal`,
536-
decimal strings, floats or integers), as well as `output_places`. However, it **does not** support the `min_places` and
537-
max_places` parameters (those are technically not possible with floats)!
551+
decimal strings, floats or integers), as well as `output_places` and `rounding`. However, it **does not** support the
552+
`min_places` and max_places` parameters (those are technically not possible with floats)!
538553

539554
**Note:** Due to the way that floats work, the resulting decimals can have inaccuracies! It is recommended to use
540555
`DecimalValidator` with decimal strings instead of floats as input whenever possible. This validator mainly exists for
@@ -582,7 +597,7 @@ always enabled, and is intended as a shortcut.
582597

583598
Like the `FloatToDecimalValidator`, the `NumericValidator` supports the optional parameters `min_value` and `max_value`
584599
to specify the allowed value range, as well as `output_places` to set a fixed number of decimal places in the output
585-
value.
600+
value and `rounding` to set the rounding mode.
586601

587602
**Examples:**
588603

src/validataclass/validators/decimal_validator.py

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
Use of this source code is governed by an MIT-style license that can be found in the LICENSE file.
55
"""
66

7+
import decimal
78
import re
89
from decimal import Decimal, InvalidOperation
910
from typing import Any, Optional, Union
@@ -23,10 +24,18 @@ class DecimalValidator(StringValidator):
2324
Only allows finite numbers in regular decimal notation (e.g. '1.234', '-42', '.00', ...), but no other values that
2425
are accepted by `decimal.Decimal` (e.g. no 'Infinity' or 'NaN' and no scientific notation).
2526
26-
Optionally a number range (minimum/maximum value as `Decimal`, integer or decimal string), minimum/maximum number of
27-
decimal places and a fixed number of decimal places in the output value can be specified. A fixed number of output
28-
places will result in rounding according to the current decimal context (see `decimal.getcontext()`), by default
29-
this means that "1.49" will be rounded to "1" and "1.50" to "2".
27+
Optionally a number range (minimum/maximum value as `Decimal`, integer or decimal string) can be specified using the
28+
parameters `min_value` and `max_value`, as well as a minimum/maximum number of decimal places in the input using
29+
`min_places` and `max_places`.
30+
31+
You can also specify how many decimal places the output value should have using `output_places`. If this parameter
32+
is set, the output value will always have the specified amount of decimal places. If rounding is necessary, a
33+
rounding mode as defined by the `decimal` module (https://docs.python.org/3/library/decimal.html#rounding-modes) is
34+
used, which can be specified with the `rounding` parameter.
35+
36+
The rounding mode defaults to `decimal.ROUND_HALF_UP`, which basically means that digits 0 to 4 are rounded down and
37+
digits 5 to 9 are rounded up (e.g. with `output_places=1`, "1.149" would be rounded to "1.1" and "1.150" would be
38+
rounded to "1.2"). Alternatively, set `rounding=None` to use the rounding mode set by the current decimal context.
3039
3140
Examples:
3241
@@ -48,6 +57,9 @@ class DecimalValidator(StringValidator):
4857
4958
# As above, but only allow 2 or less decimal places in input (e.g. '1' -> '1.00', '1.23' -> '1.23' but '1.234' raises an exception)
5059
DecimalValidator(max_places=2, output_places=2)
60+
61+
# Two output places, but always round up (e.g. '1.001' -> '1.01')
62+
DecimalValidator(output_places=2, rounding=decimal.ROUND_UP)
5163
```
5264
5365
Valid input: `str` in decimal notation
@@ -65,6 +77,9 @@ class DecimalValidator(StringValidator):
6577
# Quantum used in `.quantize()` to set a fixed number of decimal places (from constructor argument output_places)
6678
output_quantum: Optional[Decimal] = None
6779

80+
# Rounding mode (constant from decimal module)
81+
rounding: Optional[str] = None
82+
6883
# Precompiled regular expression for decimal values
6984
decimal_regex: re.Pattern = re.compile(r'[+-]?([0-9]+\.[0-9]*|\.?[0-9]+)')
7085

@@ -75,17 +90,19 @@ def __init__(
7590
min_places: Optional[int] = None,
7691
max_places: Optional[int] = None,
7792
output_places: Optional[int] = None,
93+
rounding: Optional[str] = decimal.ROUND_HALF_UP,
7894
):
7995
"""
80-
Create a DecimalValidator with optional value range, optional minimum/maximum number of decimal places and optional number
81-
of decimal places in output value.
96+
Create a DecimalValidator with optional value range, optional minimum/maximum number of decimal places and
97+
optional number of decimal places in output value.
8298
8399
Parameters:
84100
min_value: Decimal, integer or string, specifies lowest allowed value (default: None, no minimum value)
85101
max_value: Decimal, integer or string, specifies highest allowed value (default: None, no maximum value)
86102
min_places: Integer, minimum number of decimal places an input value must have (default: None, no minimum places)
87103
max_places: Integer, maximum number of decimal places an input value must have (default: None, no maximum places)
88104
output_places: Integer, number of decimal places the output Decimal object shall have (default: None, output equals input)
105+
rounding: Rounding mode for numbers that need to be rounded (default: decimal.ROUND_HALF_UP)
89106
"""
90107
# Restrict string length
91108
super().__init__(max_length=40)
@@ -112,6 +129,7 @@ def __init__(
112129
self.max_value = max_value
113130
self.min_places = min_places
114131
self.max_places = max_places
132+
self.rounding = rounding
115133

116134
# Set output "quantum" (the output decimal will have the same number of decimal places as this value)
117135
if output_places is not None:
@@ -151,6 +169,6 @@ def validate(self, input_data: Any, **kwargs) -> Decimal:
151169

152170
# Set fixed number of decimal places (if wanted)
153171
if self.output_quantum is not None:
154-
return decimal_out.quantize(self.output_quantum)
172+
return decimal_out.quantize(self.output_quantum, rounding=self.rounding)
155173
else:
156174
return decimal_out

src/validataclass/validators/float_to_decimal_validator.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
Use of this source code is governed by an MIT-style license that can be found in the LICENSE file.
55
"""
66

7+
import decimal
78
import math
89
from decimal import Decimal
910
from typing import Any, Optional, Union, List
@@ -20,9 +21,9 @@ class FloatToDecimalValidator(DecimalValidator):
2021
"""
2122
Validator that converts float values (IEEE 754) to `decimal.Decimal` objects. Sub class of `DecimalValidator`.
2223
23-
Optionally a number range can be specified using the parameters 'min_value' and 'max_value' (specified as `Decimal`,
24-
decimal strings, floats or integers), as well as a fixed number of decimal places in the output value using
25-
'output_places'. These parameters will be passed to the underlying `DecimalValidator`.
24+
Optionally the parameters `min_value` and `max_value` (allowed number range), `output_places` (fixed number of
25+
decimal places in the output value) and `rounding` (rounding mode as defined in the `decimal` module) can be
26+
specified, which will be passed to the underlying `DecimalValidator`.
2627
2728
By default, only floats are allowed as input type. Set `allow_integers=True` to also accept integers as input (e.g.
2829
`1` results in a `Decimal('1')`). Furthermore, with `allow_strings=True` the validator will also accept decimal
@@ -71,21 +72,23 @@ def __init__(
7172
min_value: Optional[Union[Decimal, str, float, int]] = None,
7273
max_value: Optional[Union[Decimal, str, float, int]] = None,
7374
output_places: Optional[int] = None,
75+
rounding: Optional[str] = decimal.ROUND_HALF_UP,
7476
allow_integers: bool = False,
7577
allow_strings: bool = False,
7678
):
7779
"""
7880
Create a FloatToDecimalValidator with optional value range and optional number of decimal places in output value.
7981
80-
The parameters 'min_value', 'max_value' and 'output_places' are passed to the underlying DecimalValidator.
82+
The parameters `min_value`, `max_value`, `output_places` and `rounding` are passed to the underlying DecimalValidator.
8183
82-
The parameters 'allow_integers' and 'allow_strings' can be used to extend the allowed input types. Strings, if
84+
The parameters `allow_integers` and `allow_strings` can be used to extend the allowed input types. Strings, if
8385
accepted, will be simply passed to the DecimalValidator.
8486
8587
Parameters:
8688
min_value: Decimal, str, float or int, specifies lowest value an input float may have (default: None, no minimum value)
8789
max_value: Decimal, str, float or int, specifies highest value an input float may have (default: None, no maximum value)
8890
output_places: Integer, number of decimal places the output Decimal object shall have (default: None, output equals input)
91+
rounding: Rounding mode for numbers that need to be rounded (default: decimal.ROUND_HALF_UP)
8992
allow_integers: Boolean, if True, integers are accepted as input (default: False)
9093
allow_strings: Boolean, if True, decimal strings are accepted and will be parsed by a DecimalValidator (default: False)
9194
"""
@@ -94,6 +97,7 @@ def __init__(
9497
min_value=str(min_value) if type(min_value) in [float, int] else min_value,
9598
max_value=str(max_value) if type(max_value) in [float, int] else max_value,
9699
output_places=output_places,
100+
rounding=rounding,
97101
)
98102

99103
# Save parameters

src/validataclass/validators/numeric_validator.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
Use of this source code is governed by an MIT-style license that can be found in the LICENSE file.
55
"""
66

7+
import decimal
78
from decimal import Decimal
89
from typing import Optional, Union
910

@@ -22,8 +23,8 @@ class NumericValidator(FloatToDecimalValidator):
2223
This validator is based on the `FloatToDecimalValidator`. In fact, the validator is basically just a shortcut for
2324
`FloatToDecimalValidator(allow_integers=True, allow_strings=True)`.
2425
25-
The validator supports the optional parameters 'min_value', 'max_value' and 'output_places' which will be passed
26-
to the `FloatToDecimalValidator`.
26+
The validator supports the optional parameters `min_value`, `max_value`, `output_places` and `rounding` which will
27+
be passed to the `FloatToDecimalValidator`.
2728
2829
NOTE: Due to the way that floats work, the resulting decimals for float input values can have inaccuracies! It is
2930
recommended to use `DecimalValidator` with decimal strings as input data instead of using float input (e.g. strings
@@ -61,22 +62,25 @@ def __init__(
6162
min_value: Optional[Union[Decimal, str, float, int]] = None,
6263
max_value: Optional[Union[Decimal, str, float, int]] = None,
6364
output_places: Optional[int] = None,
65+
rounding: Optional[str] = decimal.ROUND_HALF_UP,
6466
):
6567
"""
6668
Create a `NumericValidator` with optional value range and optional number of decimal places in output value.
6769
68-
The parameters 'min_value', 'max_value' and 'output_places' are passed to the underlying `FloatToDecimalValidator`.
70+
The parameters `min_value`, `max_value`, `output_places` and `rounding` are passed to the underlying DecimalValidator.
6971
7072
Parameters:
7173
min_value: Decimal, str, float or int, specifies lowest value an input float may have (default: None, no minimum value)
7274
max_value: Decimal, str, float or int, specifies highest value an input float may have (default: None, no maximum value)
7375
output_places: Integer, number of decimal places the output Decimal object shall have (default: None, output equals input)
76+
rounding: Rounding mode for numbers that need to be rounded (default: decimal.ROUND_HALF_UP)
7477
"""
7578
# Initialize base FloatToDecimalValidator with allow_integers and allow_strings always being enabled
7679
super().__init__(
7780
min_value=min_value,
7881
max_value=max_value,
7982
output_places=output_places,
83+
rounding=rounding,
8084
allow_integers=True,
8185
allow_strings=True,
8286
)

tests/test_utils.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
Use of this source code is governed by an MIT-style license that can be found in the LICENSE file.
55
"""
66

7-
from typing import Any, List
7+
from decimal import Decimal
8+
from typing import Any, List, Union
89

910
from validataclass.validators import Validator
1011

@@ -73,6 +74,14 @@ def unpack_params(*args) -> List[tuple]:
7374
UNSET_PARAMETER = object()
7475

7576

77+
def assert_decimal(actual: Decimal, expected: Union[Decimal, str]) -> None:
78+
"""
79+
Assert that `actual` is of type `Decimal` and has the same decimal value (string comparison) as `expected`.
80+
"""
81+
assert type(actual) is Decimal
82+
assert str(actual) == str(expected)
83+
84+
7685
# Test validator that parses context arguments
7786
class UnitTestContextValidator(Validator):
7887
"""

0 commit comments

Comments
 (0)