Last active
August 15, 2019 21:22
-
-
Save cellofellow/69b445664c67386794caee767515acb4 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| ''' | |
| Toy module that handles old pre-decimal British £sd (pounds, shillings, pence) | |
| money values. It stores all as integers of farthings (Farthing class), and can | |
| convert a number of farthings into £sd notation or into a number of all British | |
| coins. It can also take a bundle of coins and simplify those according to a set | |
| of available coins. | |
| ''' | |
| from typing import (Union, Tuple, FrozenSet, Set, Type, NamedTuple, Dict) | |
| Farthinglike = Union[int, 'Farthing', Type['Farthing']] | |
| StrSet = Union[FrozenSet[str], Set[str]] | |
| class Farthing(int): | |
| ''' | |
| Represent one farthing (¼ penny) of £sd British currency. | |
| ''' | |
| def lsd(self) -> 'LSD': | |
| 'Notation of this number of farthings in £sd, pounds shillings pence.' | |
| return LSD.make(self) | |
| def coin(self, with_guineas=False) -> 'Coins': | |
| 'Best coinage for this number of farthings.' | |
| return Coins.split(self, | |
| (WITH_GUINEAS | |
| if with_guineas | |
| else WITHOUT_GUINEAS)) | |
| def split(self, other: 'Farthing') -> Tuple[int, 'Farthing']: | |
| return int(self) // other, Farthing(self % other) | |
| def __add__(self, other: Farthinglike) -> 'Farthing': | |
| right: int | |
| if other is Farthing: | |
| right = 1 | |
| elif isinstance(other, Farthing): | |
| right = int(other) | |
| elif isinstance(other, int): | |
| right = other | |
| return Farthing(int(self) + right) | |
| def __sub__(self, other: Farthinglike) -> 'Farthing': | |
| right: int | |
| if other is Farthing: | |
| right = 1 | |
| elif isinstance(other, Farthing): | |
| right = int(other) | |
| elif isinstance(other, int): | |
| right = other | |
| return Farthing(int(self) - right) | |
| def __mul__(self, other: Farthinglike) -> 'Farthing': | |
| right: int | |
| if other is Farthing: | |
| right = 1 | |
| elif isinstance(other, Farthing): | |
| right = int(other) | |
| elif isinstance(other, int): | |
| right = other | |
| return Farthing(int(self) * right) | |
| def __rmul__(self, other: Farthinglike) -> 'Farthing': | |
| left: int | |
| if other is Farthing: | |
| left = 1 | |
| elif isinstance(other, Farthing): | |
| left = int(other) | |
| elif isinstance(other, int): | |
| left = other | |
| return Farthing(int(self) * left) | |
| def __repr__(self): | |
| return f'Farthing({int(self)})' | |
| # Coins in denominations of farthings. | |
| Zero = Farthing(0) | |
| One = Farthing(1) | |
| Halfpenny = Farthing(2) | |
| Penny = Farthing(4) | |
| Twopence = Penny * 2 | |
| Threepence = Penny * 3 | |
| Sixpence = Penny * 6 | |
| Shilling = Penny * 12 | |
| Florin = Shilling * 2 | |
| HalfCrown = (Shilling * 2) + Sixpence | |
| Crown = Shilling * 5 | |
| HalfSovereign = Shilling * 10 | |
| Pound = Shilling * 20 | |
| Sovereign = Pound | |
| Guinea = Pound + Shilling | |
| def test_farthing_add(): | |
| two = One + 1 | |
| assert isinstance(two, Farthing), 'expected a Farthing instance' | |
| assert two == 2 | |
| assert two == Halfpenny | |
| def test_farthing_sub(): | |
| three = Penny - 1 | |
| assert isinstance(three, Farthing), 'expected a Farthing instance' | |
| assert three == 3 | |
| def test_farthing_mul(): | |
| tuppence = Penny * 2 | |
| assert isinstance(tuppence, Farthing), 'expected a Farthing instance' | |
| assert tuppence == 8 | |
| assert tuppence == Twopence | |
| def test_farthing_rmul(): | |
| tuppence = 2 * Penny | |
| assert isinstance(tuppence, Farthing), 'expected a Farthing instance' | |
| assert tuppence == 8 | |
| assert tuppence == Twopence | |
| def test_farthing_split(): | |
| penny, one = Farthing(5).split(Penny) | |
| assert not isinstance(penny, Farthing), 'expected not a Farthing instance' | |
| assert isinstance(one, Farthing), 'expected a Farthing instance' | |
| assert penny == 1 | |
| assert one == 1 | |
| def test_farthing_repr(): | |
| assert repr(Zero) == 'Farthing(0)' | |
| assert repr(Guinea) == 'Farthing(1008)' | |
| def test_farthing_lsd(): | |
| one_of_each = Pound + Shilling + Penny + One | |
| lsd = one_of_each.lsd() | |
| assert isinstance(lsd, LSD), 'expected LSD instance' | |
| assert lsd.value() == one_of_each | |
| def test_farthing_coin(): | |
| one_of_each = Pound + Shilling + Penny + One | |
| coins = one_of_each.coin() | |
| assert isinstance(coins, Coins), 'expected Coins instance' | |
| assert coins.value() == one_of_each | |
| class LSD(NamedTuple): | |
| ''' | |
| Representation of £sd, pounds shillings pence, from a number of farthings. | |
| ''' | |
| pounds: int | |
| shillings: int | |
| pennies: int | |
| farthings: int | |
| @classmethod | |
| def make(cls, value: Farthing) -> 'LSD': | |
| pounds, remainder = Farthing(value).split(Pound) | |
| shillings, remainder = remainder.split(Shilling) | |
| pennies, farthings = remainder.split(Penny) | |
| return cls(pounds, shillings, pennies, farthings) | |
| def value(self) -> Farthing: | |
| return sum([self.pounds * Pound, | |
| self.shillings * Shilling, | |
| self.pennies * Penny, | |
| Farthing(self.farthings)], | |
| Zero) | |
| def __str__(self): | |
| FARTHING_MAP = {1: '¼', 2: '½', 3: '¾'} | |
| pounds = f'£{self.pounds}' if self.pounds else '' | |
| shillings = f'{self.shillings}s' if self.shillings else '' | |
| farthings = FARTHING_MAP.get(self.farthings, '') | |
| pence = (f'{self.pennies}{farthings}d.' | |
| if (self.pennies or farthings) | |
| else '') | |
| return '.'.join(s for s in [pounds, shillings, pence] if s) | |
| def test_lsd_make(): | |
| lsd = LSD.make(Pound + Shilling + Penny + One) | |
| assert isinstance(lsd, LSD), 'expected LSD instance' | |
| assert (1, 1, 1, 1) == lsd | |
| def test_lsd_value(): | |
| lsd = LSD(1, 1, 1, 1) | |
| assert lsd.value() == Pound + Shilling + Penny + One | |
| def test_lsd_str(): | |
| lsd = LSD(1, 1, 1, 1) | |
| assert str(lsd) == '£1.1s.1¼d.' | |
| NAME_MAP = { | |
| # Field name, Display Name, Plural or append S, Value | |
| 'guineas': ('Guinea', None, Guinea), | |
| 'sovereigns': ('Sovereign', None, Sovereign), | |
| 'half_sovereigns': ('Half Sovereign', None, HalfSovereign), | |
| 'crowns': ('Crown', None, Crown), | |
| 'half_crowns': ('Half Crown', None, HalfCrown), | |
| 'florins': ('Florin', None, Florin), | |
| 'shillings': ('Shilling', None, Shilling), | |
| 'sixpence': ('Sixpence', 'Sixpence', Sixpence), | |
| 'threepence': ('Threepence', 'Threepence', Threepence), | |
| 'twopence': ('Twopence', 'Twopence', Twopence), | |
| 'pennies': ('Penny', 'Pence', Penny), | |
| 'halfpennies': ('Halfpenny', 'Halfpence', Halfpenny), | |
| 'farthings': ('Farthing', None, One), | |
| } | |
| ALL = frozenset(NAME_MAP) | |
| WITHOUT_GUINEAS = frozenset(ALL - {'guineas'}) | |
| WITH_GUINEAS = frozenset(ALL - {'sovereigns'}) | |
| COMMON = frozenset({'sovereigns', 'shillings', 'pennies'}) | |
| class Coins(NamedTuple): | |
| ''' | |
| A bundle of coins of all types from the British pre-decimal times. | |
| Convert a Farthing instance into coins using the split method. Split takes | |
| a set of coins to consider valid; by default it uses all except the odd | |
| Guinea, which has a value of 21 shillings and is a little weird. | |
| A bundle of coins can be simplified by calling the simplify method, which | |
| likewise takes a set of valid coins to use. | |
| Get the value in farthings of a bundle by calling value. | |
| ''' | |
| guineas: int | |
| sovereigns: int | |
| half_sovereigns: int | |
| crowns: int | |
| half_crowns: int | |
| florins: int | |
| shillings: int | |
| sixpence: int | |
| threepence: int | |
| twopence: int | |
| pennies: int | |
| halfpennies: int | |
| farthings: int | |
| @classmethod | |
| def split(cls, value: Farthing, | |
| coin_set: StrSet = WITHOUT_GUINEAS) -> 'Coins': | |
| ''' | |
| Create an optimum bundle of coins from a value as Farthing instance. | |
| ''' | |
| remainder = Farthing(value) | |
| coins: Dict[str, int] = {} | |
| *fields_except_farthings, _ = cls._fields | |
| for name in fields_except_farthings: | |
| if name in coin_set: | |
| *_, multiplier = NAME_MAP[name] | |
| coins[name], remainder = remainder.split(multiplier) | |
| else: | |
| coins[name] = 0 | |
| coins['farthings'] = int(remainder) | |
| return cls(**coins) | |
| def make_change(self, value: Farthing) -> Tuple['Coins', 'Coins']: | |
| ''' | |
| Given a value, make change from this instance as the till, returning a | |
| new till and the change. | |
| If there are insufficient funds, raises ValueError with message | |
| 'Insufficient funds'. | |
| If change cannot be made, a ValueError is raised with message | |
| 'Imperfect change' and the balance that cannot be changed. | |
| ''' | |
| if self.value() < value: | |
| raise ValueError('Insufficient funds') | |
| remainder = Farthing(value) | |
| new_till: Dict[str, int] = {} | |
| change: Dict[str, int] = {} | |
| *fields_except_farthings, _ = self._fields | |
| for name in fields_except_farthings: | |
| till_count = getattr(self, name) | |
| _, _, multiplier = NAME_MAP[name] | |
| change_required, change_remains = remainder.split(multiplier) | |
| if change_required and change_required <= till_count: | |
| new_till[name] = till_count - change_required | |
| change[name] = change_required | |
| remainder = change_remains | |
| else: | |
| new_till[name] = till_count | |
| change[name] = 0 | |
| if remainder > self.farthings: | |
| raise ValueError('Imperfect change', remainder - self.farthings) | |
| new_till['farthings'] = self.farthings - remainder | |
| change['farthings'] = int(remainder) | |
| return Coins(**new_till), Coins(**change) | |
| @classmethod | |
| def _farthings(cls, name: str, value: int) -> Farthing: | |
| *_, multiplier = NAME_MAP[name] | |
| return Farthing(value * multiplier) | |
| def value(self) -> Farthing: | |
| 'Convert bundle of coins to value as Farthing instance.' | |
| return sum((self._farthings(k, v) for k, v in self._asdict().items()), | |
| Zero) | |
| def simplify(self, coin_set: StrSet = WITHOUT_GUINEAS) -> 'Coins': | |
| ''' | |
| Convert bundle of coins into a simpler bundle of coins using provided | |
| set of valid coins. | |
| ''' | |
| return self.split(self.value(), coin_set) | |
| @classmethod | |
| def _singular_or_plural(cls, name: str, value: int) -> str: | |
| singular, plural, _ = NAME_MAP[name] | |
| return singular if value == 1 else (plural or f'{singular}s') | |
| def __str__(self): | |
| return ', '.join(f'{v} {self._singular_or_plural(k, v)}' | |
| for k, v in self._asdict().items() if v) | |
| def __add__(self, other: 'Coins') -> 'Coins': | |
| return Coins(*(a + b for a, b in zip(self, other))) | |
| def test_coins_split(): | |
| from operator import eq | |
| coins = Coins.split(One) | |
| assert isinstance(coins, Coins), 'expected Coins instance' | |
| assert coins.farthings == 1 | |
| assert sum(coins[:-1]) == 0 | |
| one_of_each = Pound + Shilling + Penny + Farthing | |
| coins = Coins.split(one_of_each) | |
| assert coins.value() == one_of_each | |
| assert eq( | |
| (coins.guineas, coins.sovereigns, coins.shillings, coins.pennies, | |
| coins.farthings), | |
| (0, 1, 1, 1, 1)) | |
| coins = Coins.split(one_of_each, WITH_GUINEAS) | |
| assert eq( | |
| (coins.guineas, coins.sovereigns, coins.shillings, coins.pennies, | |
| coins.farthings), | |
| (1, 0, 0, 1, 1)) | |
| def test_coins_value(): | |
| from operator import eq | |
| coins = Coins(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1) | |
| assert eq( | |
| coins.value(), | |
| sum((val for _, _, val in NAME_MAP.values()), | |
| Zero)) | |
| def test_coins_simplify(): | |
| coins = Coins(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1) | |
| simplified = coins.simplify(COMMON) | |
| assert {k for k, v in simplified._asdict().items() | |
| if v} <= COMMON | {'farthings'} | |
| def test_coins_str(): | |
| from operator import eq | |
| # Singular Forms | |
| coins = Coins(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1) | |
| assert eq( | |
| str(coins), | |
| '1 Guinea, 1 Sovereign, 1 Half Sovereign, 1 Crown, ' | |
| '1 Half Crown, 1 Florin, 1 Shilling, 1 Sixpence, ' | |
| '1 Threepence, 1 Twopence, 1 Penny, 1 Halfpenny, 1 Farthing') | |
| # Plural Forms, Special | |
| coins = Coins(sixpence=2, threepence=2, twopence=2, pennies=2, | |
| halfpennies=2, | |
| guineas=0, sovereigns=0, half_sovereigns=0, crowns=0, | |
| half_crowns=0, florins=0, shillings=0, farthings=0) | |
| assert eq(str(coins), | |
| '2 Sixpence, 2 Threepence, 2 Twopence, 2 Pence, 2 Halfpence') | |
| # Plural Forms, normal (with an "s") | |
| coins = Coins(sixpence=0, threepence=0, twopence=0, pennies=0, | |
| halfpennies=0, | |
| guineas=2, sovereigns=2, half_sovereigns=2, crowns=2, | |
| half_crowns=2, florins=2, shillings=2, farthings=2) | |
| assert eq( | |
| str(coins), | |
| '2 Guineas, 2 Sovereigns, 2 Half Sovereigns, 2 Crowns, 2 Half Crowns, ' | |
| '2 Florins, 2 Shillings, 2 Farthings') | |
| def test_coins_make_change(): | |
| till = Coins(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1) | |
| try: | |
| till.make_change(4 * Pound) | |
| except ValueError as exc: | |
| assert exc.args[0] == 'Insufficient funds' | |
| else: | |
| assert False, 'Expected ValueError' | |
| till = Coins(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0) | |
| try: | |
| till.make_change(Halfpenny + One) | |
| except ValueError as exc: | |
| message, balance = exc.args | |
| assert (message, balance) == ('Imperfect change', 1) | |
| else: | |
| assert False, 'Expected ValueError' | |
| till = Coins(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1) | |
| new_till, change = till.make_change(6 * Shilling + Sixpence) | |
| assert new_till + change == till | |
| assert change.value() == 6 * Shilling + Sixpence | |
| assert new_till.value() == till.value() - change.value() | |
| expected_change = Coins(crowns=1, shillings=1, sixpence=1, | |
| guineas=0, sovereigns=0, half_sovereigns=0, | |
| half_crowns=0, florins=0, threepence=0, twopence=0, | |
| pennies=0, halfpennies=0, farthings=0) | |
| expected_new_till = Coins(crowns=0, shillings=0, sixpence=0, | |
| guineas=1, sovereigns=1, half_sovereigns=1, | |
| half_crowns=1, florins=1, threepence=1, | |
| twopence=1, pennies=1, halfpennies=1, | |
| farthings=1) | |
| assert change == expected_change | |
| assert new_till == expected_new_till | |
| def test_coins_add(): | |
| a = Coins(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1) | |
| b = Coins(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1) | |
| c = a + b | |
| assert c.value() == a.value() + b.value() | |
| assert c == (2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment