Skip to content

Commit

Permalink
add format_number_no_round function (#82)
Browse files Browse the repository at this point in the history
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
  • Loading branch information
joshuadavidthomas and pre-commit-ci[bot] authored Aug 1, 2024
1 parent 19a7daa commit 354d716
Show file tree
Hide file tree
Showing 4 changed files with 203 additions and 0 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ and this project attempts to adhere to [Semantic Versioning](https://semver.org/

## [Unreleased]

### Added

- Added `format_number_no_round` function to handle formatting of numbers with a specified number of decimal places while trimming excess trailing zeros or adding zeros as needed.

### Changed

- Bumped `django-twc-package` template version to v2024.22.
Expand Down
74 changes: 74 additions & 0 deletions src/django_twc_toolbox/numbers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
from __future__ import annotations

from decimal import Decimal
from decimal import InvalidOperation
from typing import TypeVar
from typing import overload

T = TypeVar("T", float, str, Decimal)


@overload
def format_number_no_round(number: float, *, decimal_places: int = 2) -> str: ...
@overload
def format_number_no_round(number: str, *, decimal_places: int = 2) -> str: ...
@overload
def format_number_no_round(number: Decimal, *, decimal_places: int = 2) -> Decimal: ...
def format_number_no_round(number: T, *, decimal_places: int = 2) -> str | Decimal:
"""Formats a number with the number of decimal places specified without rounding.
It takes a number and ensures it has at least the number of decimal places passed
it as an argument, defaulting to 2. This csn be useful for displaying and working
with currency as it does not round the number, preserving any additional precision
beyond the decimal places if present.
Args:
number (T): The number to format. Can be a float, str, or Decimal.
decimal_places (int, optional): Minimum number of decimal places to display.
Defaults to 2.
Returns:
str | Decimal: The formatted number as a string if the input was a float or str,
or as a Decimal if the input was a Decimal.
Raises:
ValueError: If the input cannot be converted to a valid number.
Examples:
>>> format_number_no_round(123.4)
'123.40'
>>> format_number_no_round(123.45)
'123.45'
>>> format_number_no_round(123.456)
'123.456'
>>> format_number_no_round(123.4560)
'123.456'
>>> format_number_no_round(123.45600)
'123.456'
>>> format_number_no_round(123.45600, decimal_places=5)
'123.45600'
>>> format_number_no_round(Decimal('123.4'))
Decimal('123.40')
"""
try:
decimal_value = Decimal(str(number))
except InvalidOperation as err:
msg = f"Invalid number input: {number}"
raise ValueError(msg) from err

str_value = str(decimal_value)
parts = str_value.split(".")

if len(parts) == 1:
formatted_str = f"{str_value}.{'0' * decimal_places}"
else:
integer_part, fractional_part = parts
fractional_part = fractional_part.rstrip("0")
if len(fractional_part) < decimal_places:
fractional_part = fractional_part.ljust(decimal_places, "0")
formatted_str = f"{integer_part}.{fractional_part}"

if isinstance(number, (float | str)):
return formatted_str
else:
return Decimal(formatted_str)
10 changes: 10 additions & 0 deletions src/django_twc_toolbox/templatetags/django_twc_toolbox.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

from collections.abc import Iterable
from decimal import Decimal
from typing import TypeVar

from django import template
Expand All @@ -11,6 +12,8 @@
from django.db import models
from django.utils.itercompat import is_iterable

from django_twc_toolbox.numbers import format_number_no_round

register = template.Library()


Expand Down Expand Up @@ -104,3 +107,10 @@ def class_name(instance: object) -> str:
@register.filter
def startswith(text: str, starts: str) -> bool:
return text.startswith(starts)


@register.filter
def format_no_round(
number: float | int | str | Decimal, decimal_places: int = 2
) -> str:
return str(format_number_no_round(number, decimal_places=decimal_places))
115 changes: 115 additions & 0 deletions tests/test_numbers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
from __future__ import annotations

from decimal import Decimal

import pytest

from django_twc_toolbox.numbers import format_number_no_round


@pytest.mark.parametrize(
"number,decimal_places,expected",
[
(123.0, 2, "123.00"),
(123.4, 2, "123.40"),
(123.4567, 2, "123.4567"),
(123.4500, 2, "123.45"),
(0.0, 2, "0.00"),
(0.1, 2, "0.10"),
(0.01, 2, "0.01"),
(0.001, 2, "0.001"),
(1000000.0, 2, "1000000.00"),
(1000000.10, 2, "1000000.10"),
(-123.45, 2, "-123.45"),
(-123.4500, 2, "-123.45"),
(-123.4567, 2, "-123.4567"),
(123.0, 3, "123.000"),
(123.4, 3, "123.400"),
(123.456789, 4, "123.456789"),
],
)
def test_format_number_no_round_float(number, decimal_places, expected):
result = format_number_no_round(number, decimal_places=decimal_places)

assert isinstance(result, str)
assert result == expected
assert float(result) == pytest.approx(number)


@pytest.mark.parametrize(
"number,decimal_places,expected",
[
("123", 2, "123.00"),
("123.4", 2, "123.40"),
("123.40", 2, "123.40"),
("123.4567", 2, "123.4567"),
("123.4500", 2, "123.45"),
("0", 2, "0.00"),
("0.1", 2, "0.10"),
("0.01", 2, "0.01"),
("0.001", 2, "0.001"),
("1000000", 2, "1000000.00"),
("1000000.10", 2, "1000000.10"),
("-123.45", 2, "-123.45"),
("-123.4500", 2, "-123.45"),
("-123.4567", 2, "-123.4567"),
("123", 3, "123.000"),
("123.4", 3, "123.400"),
("123.456789", 4, "123.456789"),
],
)
def test_format_number_no_round_str(number, decimal_places, expected):
result = format_number_no_round(number, decimal_places=decimal_places)

assert isinstance(result, str)
assert result == expected


@pytest.mark.parametrize(
"number,decimal_places,expected",
[
(Decimal("123"), 2, Decimal("123.00")),
(Decimal("123.4"), 2, Decimal("123.40")),
(Decimal("123.40"), 2, Decimal("123.40")),
(Decimal("123.4567"), 2, Decimal("123.4567")),
(Decimal("123.4500"), 2, Decimal("123.45")),
(Decimal("0"), 2, Decimal("0.00")),
(Decimal("0.1"), 2, Decimal("0.10")),
(Decimal("0.01"), 2, Decimal("0.01")),
(Decimal("0.001"), 2, Decimal("0.001")),
(Decimal("1000000"), 2, Decimal("1000000.00")),
(Decimal("1000000.10"), 2, Decimal("1000000.10")),
(Decimal("-123.45"), 2, Decimal("-123.45")),
(Decimal("-123.4500"), 2, Decimal("-123.45")),
(Decimal("-123.4567"), 2, Decimal("-123.4567")),
(Decimal("123"), 3, Decimal("123.000")),
(Decimal("123.4"), 3, Decimal("123.400")),
(Decimal("123.456789"), 4, Decimal("123.456789")),
],
)
def test_format_number_no_round_decimal(number, decimal_places, expected):
result = format_number_no_round(number, decimal_places=decimal_places)

assert isinstance(result, Decimal)
assert result == expected


@pytest.mark.parametrize(
"decimal_places, expected",
[
(0, "123.456789"),
(2, "123.456789"),
(7, "123.4567890"),
],
)
def test_format_number_no_round_arg(decimal_places, expected):
number = "123.456789"

result = format_number_no_round(number, decimal_places=decimal_places)

assert result == expected


def test_format_number_no_round_invalid_input():
with pytest.raises(ValueError):
format_number_no_round("invalid", decimal_places=2)

0 comments on commit 354d716

Please sign in to comment.