Skip to content

Commit

Permalink
Overhaul Metal class (#268)
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>
Co-authored-by: Gobot1234 <[email protected]>
  • Loading branch information
3 people authored Jul 20, 2023
1 parent 27cf6f0 commit 8976936
Show file tree
Hide file tree
Showing 5 changed files with 177 additions and 22 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ venv
.mypy_cache
.pytest_cache
.ruff_cache
.hypothesis

.DS_Store
Thumbs.db
Expand Down
45 changes: 44 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ poethepoet = ">=0.19,<0.22"
blacken-docs = "^1.12"
ruff = ">=0.0.261,<0.0.279"
tomli = {version = "~2", python = "<3.11"}
hypothesis = "^6.82.0"

[tool.poe.tasks]
test = {cmd = "pytest tests", help = "Run the tests"}
Expand Down
86 changes: 65 additions & 21 deletions steam/ext/tf2/currency.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,41 @@
# pyright: reportIncompatibleMethodOverride = none
"""A nice way to work with TF2's currencies."""

from __future__ import annotations

import math
from decimal import Decimal
from fractions import Fraction
from typing import Final, SupportsFloat, SupportsIndex, SupportsRound, TypeAlias, overload
from typing import TypeAlias, overload

from typing_extensions import Self

__all__ = ("Metal",)


class SupportsRoundOrFloat(SupportsRound[int], SupportsFloat):
...
SupportsMetal: TypeAlias = int | str | float | Fraction | Decimal


class SupportsRoundOrIndex(SupportsRound[int], SupportsIndex):
...
def modf(value: Decimal) -> tuple[Decimal, Decimal]:
# can't just use divmod(value, 1) as both raise for large Decimals because lol,
# so we need this whole song and dance
as_tuple = value.as_tuple()
if not isinstance(as_tuple.exponent, int):
raise ValueError(f"invalid exponent {as_tuple.exponent!r}")

if not as_tuple.exponent:
integer = value
fractional = Decimal().copy_sign(value)
elif as_tuple.exponent < 0:
integer = Decimal((as_tuple.sign, as_tuple.digits[: as_tuple.exponent], 0))
fractional = Decimal((as_tuple.sign, as_tuple.digits[as_tuple.exponent :], as_tuple.exponent))
else:
raise AssertionError("shouldn't be reachable") # you get rounding issues here either way

SupportsMetal: TypeAlias = float | str | SupportsRoundOrFloat | SupportsRoundOrIndex
return fractional, integer


# TODO look into the discuss post about returning Self() in subclasses instead of concrete instances
# TODO use https://github.com/python/cpython/pull/106223 when it's merged
class Metal(Fraction):
"""A class to represent some metal in TF2.
Expand All @@ -36,6 +49,10 @@ class Metal(Fraction):
Metal("1.22") == Metal(1.22)
Metal(1.22) == Metal(1.22)
Metal(1) == Metal(1.00)
Note
----
When working with large floating point numbers, it's recommended to pass a :class:`str` to avoid precision loss.
"""

__slots__ = ()
Expand All @@ -45,32 +62,59 @@ def __new__(cls, value: SupportsMetal, /) -> Self: # type: ignore
...

def __new__(cls, value: SupportsMetal, /, *, _normalize: bool = ...) -> Self:
if isinstance(value, str): # '1.22'
value = float(value)
return super().__new__(cls, cls.extract_scrap(value), 9)

self = object.__new__(cls)
@classmethod
def extract_scrap(cls, value: SupportsMetal) -> int:
if isinstance(value, Fraction):
if value.denominator in {9, 3, 1}:
return value.numerator * (9 // value.denominator)
raise ValueError("cannot convert Fraction to Metal, denominator isn't 1, 3 or 9")

try:
true_value = round(value, 2)
value = Decimal(value)
except (ValueError, TypeError):
raise TypeError("non-int passed to Metal.__new__, that could not be cast") from None

if not math.isclose(value, true_value):
fractional, integer = modf(value)
rounded_fractional = round(fractional, 2)
if not math.isclose(fractional - rounded_fractional, 0, abs_tol=1e-9):
raise ValueError("metal value's last digits must be close to 0")

stred = f"{true_value:.2f}"
if stred[-1] != stred[-2]:
digits = rounded_fractional.as_tuple().digits
if len(digits) >= 2 and digits[0] != digits[1]:
raise ValueError("metal value must be a multiple of 0.11")

self._numerator = round(true_value * 9)
return self
return int(integer) * 9 + (digits[0] if fractional >= 0 else -digits[0])

def __add__(self, other: SupportsMetal) -> Metal:
return Metal(super().__add__(Fraction(self.extract_scrap(other), 9)))

def __sub__(self, other: SupportsMetal) -> Metal:
return Metal(super().__sub__(Fraction(self.extract_scrap(other), 9)))

def __mul__(self, other: SupportsMetal) -> Metal:
return Metal(super().__mul__(Fraction(self.extract_scrap(other), 9)))

def __truediv__(self, other: SupportsMetal) -> Metal:
return Metal(super().__truediv__(Fraction(self.extract_scrap(other), 9)))

__radd__ = __add__ # type: ignore
__rsub__ = __sub__ # type: ignore
__rmul__ = __mul__ # type: ignore
__rtruediv__ = __truediv__ # type: ignore

def __abs__(self) -> Fraction:
return Metal(super().__abs__())

def __pos__(self) -> Fraction:
return Metal(super().__pos__())

denominator: Final = 9 # type: ignore
_numerator: int
_denominator: Final = 9
def __neg__(self) -> Fraction:
return Metal(super().__neg__())

def __str__(self) -> str:
return f"{self._numerator / 9:.2f}"
return f"{self.numerator // self.denominator}.{f'{(self.numerator % self.denominator) * 9 // self.denominator}' * 2}"

def __repr__(self) -> str:
return f"{self.__class__.__name__}({str(self)})"
return f"{self.__class__.__name__}({str(self)!r})"
66 changes: 66 additions & 0 deletions tests/unit/test_ext_tf2.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,82 @@
# pyright: reportUnusedExpression = false
from decimal import Decimal
from fractions import Fraction

import pytest
from hypothesis import given, strategies as st

from steam.ext import tf2

client = tf2.Client()


def test_metal_initializations():
assert tf2.Metal("0.77") == tf2.Metal(0.77)
assert tf2.Metal(Fraction(7, 9)) == tf2.Metal(0.77)
assert tf2.Metal(Decimal(0.77)) == tf2.Metal(0.77)
assert tf2.Metal(Fraction(18, 9)) == tf2.Metal(2)
assert tf2.Metal(1.99) == tf2.Metal(2)


def test_metal_addition():
assert tf2.Metal(1.11) + tf2.Metal(1) == tf2.Metal(2.11)
assert tf2.Metal(1.88) + tf2.Metal(1.11) == tf2.Metal(3)
assert tf2.Metal(1.11) + tf2.Metal(1.11) == tf2.Metal(2.22)
assert tf2.Metal(1.88) + tf2.Metal(1.88) == tf2.Metal(3.77)
assert tf2.Metal(1.00) + tf2.Metal(0) == tf2.Metal(1)
# TODO test inf prec


def test_metal_subtraction():
assert tf2.Metal(1.11) - tf2.Metal(1) == tf2.Metal(0.11)
assert tf2.Metal(1.11) - tf2.Metal(0) == tf2.Metal(1.11)

assert tf2.Metal(1) - tf2.Metal(1.11) == tf2.Metal(-0.11)


def test_metal_multiplication():
assert tf2.Metal(3) * 3 == tf2.Metal(9)
assert tf2.Metal(0) * 3 == tf2.Metal(0)

assert tf2.Metal(3) * -1 == tf2.Metal(-3)


def test_metal_division():
assert tf2.Metal(2) / 2 == tf2.Metal(1)
assert tf2.Metal(2) / -2 == tf2.Metal(-1)

with pytest.raises(ValueError):
tf2.Metal(5) / 2
with pytest.raises(ValueError):
tf2.Metal(5) / -2
with pytest.raises(ValueError):
-tf2.Metal(5) / 2

with pytest.raises(ZeroDivisionError):
tf2.Metal(2) / 0


def test_metal_invalid_values():
with pytest.raises(ValueError):
tf2.Metal(1.12)
with pytest.raises(ValueError):
tf2.Metal(1.115)
with pytest.raises(ValueError):
tf2.Metal(Fraction(1, 10))


def test_str():
assert str(tf2.Metal(1.22)) == "1.22"
assert str(tf2.Metal(1.33)) == "1.33"
assert str(tf2.Metal(1000)) == "1000.00"


# some property tests
@given(st.integers(), st.integers())
def test_ints_are_commutative(x: int, y: int):
assert x + y == y + x


@given(x=st.integers(), y=st.integers())
def test_ints_cancel(x: int, y: int):
assert (tf2.Metal(x) + tf2.Metal(y)) - tf2.Metal(y) == tf2.Metal(x)

0 comments on commit 8976936

Please sign in to comment.