diff --git a/08-def-type-hints/README.asciidoc b/08-def-type-hints/README.asciidoc new file mode 100644 index 0000000..4d89408 --- /dev/null +++ b/08-def-type-hints/README.asciidoc @@ -0,0 +1 @@ +== Type Hints in Function Definitions diff --git a/08-def-type-hints/RPN_calc/calc.py b/08-def-type-hints/RPN_calc/calc.py new file mode 100755 index 0000000..5827eb9 --- /dev/null +++ b/08-def-type-hints/RPN_calc/calc.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 + +import sys +from array import array +from typing import Mapping, MutableSequence, Callable, Iterable, Sequence, Union, Any + + +OPERATORS: Mapping[str, Callable[[float, float], float]] = { + '+': lambda a, b: a + b, + '-': lambda a, b: a - b, + '*': lambda a, b: a * b, + '/': lambda a, b: a / b, + '^': lambda a, b: a ** b, +} + + +Stack = MutableSequence[float] + + +def parse_token(token: str) -> Union[str, float]: + try: + return float(token) + except ValueError: + return token + + +def evaluate(tokens: Iterable[str], stack: Stack) -> None: + for token in tokens: + atom = parse_token(token) + if isinstance(atom, float): + stack.append(atom) + else: # not float, must be operator + op = OPERATORS[atom] + x, y = stack.pop(), stack.pop() + result = op(y, x) + stack.append(result) + + +def display(s: Stack) -> str: + items = (repr(n) for n in s) + return ' │ '.join(items) + ' →' + + +def repl(input_fn: Callable[[Any], str] = input) -> None: + """Read-Eval-Print-Loop""" + + print('Use CTRL+C to quit.', file=sys.stderr) + stack: Stack = array('d') + + while True: + try: + line = input_fn('> ') # Read + except (EOFError, KeyboardInterrupt): + break + try: + evaluate(line.split(), stack) # Eval + except IndexError: + print('*** Not enough arguments.', file=sys.stderr) + except KeyError as exc: + print('*** Unknown operator:', exc.args[0], file=sys.stderr) + print(display(stack)) # Print + + print() + + +if __name__ == '__main__': + repl() diff --git a/08-def-type-hints/RPN_calc/calc_test.py b/08-def-type-hints/RPN_calc/calc_test.py new file mode 100644 index 0000000..c2d144c --- /dev/null +++ b/08-def-type-hints/RPN_calc/calc_test.py @@ -0,0 +1,51 @@ +from pytest import mark, approx # type: ignore + +from dialogue import Dialogue # type: ignore + +from calc import evaluate, repl, display, Stack + +TOLERANCE = .0001 + +@mark.parametrize("source, want", [ + ('2', 2), + ('2 3 +', 5), + ('5 3 -', 2), + ('3 5 * 2 +', 17), + ('2 3 4 5 * * *', 120), + ('1.1 1.1 1.1 + +', approx(3.3, TOLERANCE)), + ('100 32 - 5 * 9 /', approx(37.78, TOLERANCE)), +]) +def test_evaluate(source, want) -> None: + stack: Stack = [] + evaluate(source.split(), stack) + assert want == stack[-1] + + +@mark.parametrize("value, want", [ + ([], ' →'), + ([3.], '3.0 →'), + ([3., 4., 5.], '3.0 │ 4.0 │ 5.0 →'), +]) +def test_display(value, want) -> None: + assert want == display(value) + + +@mark.parametrize("session", [ + """ + > 3 + 3.0 → + """, + """ + > 3 5 6 + 3.0 │ 5.0 │ 6.0 → + > * + 3.0 │ 30.0 → + > - + -27.0 → + """, +]) +def test_repl(capsys, session) -> None: + dlg = Dialogue(session) + repl(dlg.fake_input) + captured = capsys.readouterr() + assert dlg.session.strip() == captured.out.strip() diff --git a/08-def-type-hints/arg_lab.py b/08-def-type-hints/arg_lab.py new file mode 100644 index 0000000..e4c7cd3 --- /dev/null +++ b/08-def-type-hints/arg_lab.py @@ -0,0 +1,36 @@ +import typing +from typing import Optional + + +def f(a: str, *b: int, **c: float) -> None: + if typing.TYPE_CHECKING: + # reveal_type(b) + reveal_type(c) + print(a, b, c) + + +def g(__a: int) -> None: + print(__a) + + +def h(a: int, /) -> None: + print(a) + + +def tag( + name: str, + /, + *content: str, + class_: Optional[str] = None, + foo: Optional[str] = None, + **attrs: str, +) -> str: + return repr((name, content, class_, attrs)) + + +f(a='1') +f('1', 2, 3, x=4, y=5) +g(__a=1) +# h(a=1) +print(tag('li', 'first', 'second', id='#123')) +print(tag('li', 'first', 'second', class_='menu', id='#123')) diff --git a/08-def-type-hints/birds/birds.py b/08-def-type-hints/birds/birds.py new file mode 100644 index 0000000..61c32b9 --- /dev/null +++ b/08-def-type-hints/birds/birds.py @@ -0,0 +1,15 @@ +class Bird: + pass + +class Duck(Bird): # <1> + def quack(self): + print('Quack!') + +def alert(birdie): # <2> + birdie.quack() + +def alert_duck(birdie: Duck) -> None: # <3> + birdie.quack() + +def alert_bird(birdie: Bird) -> None: # <4> + birdie.quack() diff --git a/08-def-type-hints/birds/daffy.py b/08-def-type-hints/birds/daffy.py new file mode 100644 index 0000000..48830e2 --- /dev/null +++ b/08-def-type-hints/birds/daffy.py @@ -0,0 +1,6 @@ +from birds import * + +daffy = Duck() +alert(daffy) # <1> +alert_duck(daffy) # <2> +alert_bird(daffy) # <3> diff --git a/08-def-type-hints/birds/protocol/lake.py b/08-def-type-hints/birds/protocol/lake.py new file mode 100644 index 0000000..ed979e7 --- /dev/null +++ b/08-def-type-hints/birds/protocol/lake.py @@ -0,0 +1,9 @@ +from typing import Protocol # <1> + +class GooseLike(Protocol): + def honk(self, times: int) -> None: ... # <2> + def swim(self) -> None: ... + + +def alert(waterfowl: GooseLike) -> None: # <3> + waterfowl.honk(2) diff --git a/08-def-type-hints/birds/protocol/parrot.py b/08-def-type-hints/birds/protocol/parrot.py new file mode 100644 index 0000000..c3ef8fe --- /dev/null +++ b/08-def-type-hints/birds/protocol/parrot.py @@ -0,0 +1,10 @@ +from lake import alert + +class Parrot: + def honk(self, times: int) -> None: # <1> + print('Honk! ' * times * 2) + + +ze_carioca = Parrot() + +alert(ze_carioca) # <2> diff --git a/08-def-type-hints/birds/protocol/swan.py b/08-def-type-hints/birds/protocol/swan.py new file mode 100644 index 0000000..437074d --- /dev/null +++ b/08-def-type-hints/birds/protocol/swan.py @@ -0,0 +1,13 @@ +from lake import alert # <1> + +class Swan: # <2> + def honk(self, repetitions: int) -> None: # <3> + print('Honk! ' * repetitions) + + def swim(self) -> None: # <4> + pass + + +bella = Swan() + +alert(bella) # <5> diff --git a/08-def-type-hints/birds/woody.py b/08-def-type-hints/birds/woody.py new file mode 100644 index 0000000..726e089 --- /dev/null +++ b/08-def-type-hints/birds/woody.py @@ -0,0 +1,6 @@ +from birds import * + +woody = Bird() +alert(woody) +alert_duck(woody) +alert_bird(woody) diff --git a/08-def-type-hints/bus.py b/08-def-type-hints/bus.py new file mode 100644 index 0000000..778afa2 --- /dev/null +++ b/08-def-type-hints/bus.py @@ -0,0 +1,29 @@ + +""" +>>> import copy +>>> bus1 = Bus(['Alice', 'Bill', 'Claire', 'David']) +>>> bus2 = copy.copy(bus1) +>>> bus3 = copy.deepcopy(bus1) +>>> bus1.drop('Bill') +>>> bus2.passengers +['Alice', 'Claire', 'David'] +>>> bus3.passengers +['Alice', 'Bill', 'Claire', 'David'] + +""" + +# tag::BUS_CLASS[] +class Bus: + + def __init__(self, passengers=None): + if passengers is None: + self.passengers = [] + else: + self.passengers = list(passengers) + + def pick(self, name): + self.passengers.append(name) + + def drop(self, name): + self.passengers.remove(name) +# end::BUS_CLASS[] diff --git a/08-def-type-hints/charindex.py b/08-def-type-hints/charindex.py new file mode 100644 index 0000000..790c464 --- /dev/null +++ b/08-def-type-hints/charindex.py @@ -0,0 +1,35 @@ +""" +``name_index`` builds an inverted index mapping words to sets of Unicode +characters which contain that word in their names. For example:: + + >>> index = name_index(32, 65) + >>> sorted(index['SIGN']) + ['#', '$', '%', '+', '<', '=', '>'] + >>> sorted(index['DIGIT']) + ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'] + >>> index['DIGIT'] & index['EIGHT'] + {'8'} +""" + +# tag::CHARINDEX[] +import sys +import re +import unicodedata +from typing import Dict, Set, Iterator + +RE_WORD = re.compile('\w+') +STOP_CODE = sys.maxunicode + 1 + +def tokenize(text: str) -> Iterator[str]: # <1> + """return iterable of uppercased words""" + for match in RE_WORD.finditer(text): + yield match.group().upper() + +def name_index(start: int = 32, end: int = STOP_CODE) -> Dict[str, Set[str]]: + index: Dict[str, Set[str]] = {} # <2> + for char in (chr(i) for i in range(start, end)): + if name := unicodedata.name(char, ''): # <3> + for word in tokenize(name): + index.setdefault(word, set()).add(char) + return index +# end::CHARINDEX[] diff --git a/08-def-type-hints/clip_annot.py b/08-def-type-hints/clip_annot.py new file mode 100644 index 0000000..2c7980c --- /dev/null +++ b/08-def-type-hints/clip_annot.py @@ -0,0 +1,38 @@ +""" + >>> clip('banana ', 6) + 'banana' + >>> clip('banana ', 7) + 'banana' + >>> clip('banana ', 5) + 'banana' + >>> clip('banana split', 6) + 'banana' + >>> clip('banana split', 7) + 'banana' + >>> clip('banana split', 10) + 'banana' + >>> clip('banana split', 11) + 'banana' + >>> clip('banana split', 12) + 'banana split' +""" + +# tag::CLIP_ANNOT[] +def clip(text: str, max_len: int = 80) -> str: + """Return new ``str`` clipped at last space before or after ``max_len``. + Return full ``text`` if no space found. + """ + end = None + if len(text) > max_len: + space_before = text.rfind(' ', 0, max_len) + if space_before >= 0: + end = space_before + else: + space_after = text.rfind(' ', max_len) + if space_after >= 0: + end = space_after + if end is None: + end = len(text) + return text[:end].rstrip() + +# end::CLIP_ANNOT[] diff --git a/08-def-type-hints/clip_annot_1ed.py b/08-def-type-hints/clip_annot_1ed.py new file mode 100644 index 0000000..ec4b392 --- /dev/null +++ b/08-def-type-hints/clip_annot_1ed.py @@ -0,0 +1,38 @@ +""" + >>> clip('banana ', 6) + 'banana' + >>> clip('banana ', 7) + 'banana' + >>> clip('banana ', 5) + 'banana' + >>> clip('banana split', 6) + 'banana' + >>> clip('banana split', 7) + 'banana' + >>> clip('banana split', 10) + 'banana' + >>> clip('banana split', 11) + 'banana' + >>> clip('banana split', 12) + 'banana split' +""" + +# tag::CLIP_ANNOT[] + +def clip(text:str, max_len:'int > 0'=80) -> str: # <1> + """Return text clipped at the last space before or after max_len + """ + end = None + if len(text) > max_len: + space_before = text.rfind(' ', 0, max_len) + if space_before >= 0: + end = space_before + else: + space_after = text.rfind(' ', max_len) + if space_after >= 0: + end = space_after + if end is None: # no spaces were found + end = len(text) + return text[:end].rstrip() + +# end::CLIP_ANNOT[] diff --git a/08-def-type-hints/clip_annot_signature.rst b/08-def-type-hints/clip_annot_signature.rst new file mode 100644 index 0000000..6f41aac --- /dev/null +++ b/08-def-type-hints/clip_annot_signature.rst @@ -0,0 +1,10 @@ +>>> from clip_annot import clip +>>> from inspect import signature +>>> sig = signature(clip) +>>> sig.return_annotation + +>>> for param in sig.parameters.values(): +... note = repr(param.annotation).ljust(13) +... print(note, ':', param.name, '=', param.default) + : text = +'int > 0' : max_len = 80 diff --git a/08-def-type-hints/colors.py b/08-def-type-hints/colors.py new file mode 100644 index 0000000..270cd51 --- /dev/null +++ b/08-def-type-hints/colors.py @@ -0,0 +1,81 @@ +from typing import Tuple, Mapping + +NAMES = { + 'aqua': 65535, + 'black': 0, + 'blue': 255, + 'fuchsia': 16711935, + 'gray': 8421504, + 'green': 32768, + 'lime': 65280, + 'maroon': 8388608, + 'navy': 128, + 'olive': 8421376, + 'purple': 8388736, + 'red': 16711680, + 'silver': 12632256, + 'teal': 32896, + 'white': 16777215, + 'yellow': 16776960, +} + +def rgb2hex(color=Tuple[int, int, int]) -> str: + if any(c not in range(256) for c in color): + raise ValueError('Color components must be in range(256)') + values = (f'{n % 256:02x}' for n in color) + return '#' + ''.join(values) + +HEX_ERROR = "Color must use format '#0099ff', got: {!r}" + +def hex2rgb(color=str) -> Tuple[int, int, int]: + if len(color) != 7 or color[0] != '#': + raise ValueError(HEX_ERROR.format(color)) + try: + r, g, b = (int(color[i:i+2], 16) for i in range(1, 6, 2)) + except ValueError as exc: + raise ValueError(HEX_ERROR.format(color)) from exc + return r, g, b + +def name2hex(name: str, color_map: Mapping[str, int]) -> str: + try: + code = color_map[name] + except KeyError as exc: + raise KeyError(f'Color {name!r} not found.') from exc + return f'#{code:06x}' + + +def demo(): + c = (255, 255, 0) + h = rgb2hex(c) + r = hex2rgb(h) + print(c, h, r) + c = (255, 165, 0) + h = rgb2hex(c) + r = hex2rgb(h) + print(c, h, r) + c = (512, 165, 0) + try: + h = rgb2hex(c) + except ValueError as exc: + print(c, repr(exc)) + try: + r = hex2rgb('bla') + except ValueError as exc: + print(c, repr(exc)) + try: + r = hex2rgb('#nonono') + except ValueError as exc: + print(c, repr(exc)) + n = 'yellow' + print(n, name2hex(n, NAMES)) + n = 'blue' + print(n, name2hex(n, NAMES)) + from collections import OrderedDict + o = OrderedDict(black=0, white=0xffffff) + n = 'white' + print(n, name2hex(n, o)) + + + +if __name__ == '__main__': + demo() diff --git a/08-def-type-hints/columnize.py b/08-def-type-hints/columnize.py new file mode 100644 index 0000000..ef995ae --- /dev/null +++ b/08-def-type-hints/columnize.py @@ -0,0 +1,26 @@ +# tag::COLUMNIZE[] +from typing import Sequence, List, Tuple + +def columnize(sequence: Sequence[str], num_columns: int = 0) -> List[Tuple[str, ...]]: + if num_columns == 0: + num_columns = round(len(sequence) ** .5) + num_rows, reminder = divmod(len(sequence), num_columns) + num_rows += bool(reminder) + return [tuple(sequence[i::num_rows]) for i in range(num_rows)] +# end::COLUMNIZE[] + + +def demo() -> None: + nato = ('Alfa Bravo Charlie Delta Echo Foxtrot Golf Hotel India' + ' Juliett Kilo Lima Mike November Oscar Papa Quebec Romeo' + ' Sierra Tango Uniform Victor Whiskey X-ray Yankee Zulu' + ).split() + + for row in columnize(nato, 4): + for word in row: + print(f'{word:15}', end='') + print() + + +if __name__ == '__main__': + demo() diff --git a/08-def-type-hints/columnize2.py b/08-def-type-hints/columnize2.py new file mode 100644 index 0000000..68929b5 --- /dev/null +++ b/08-def-type-hints/columnize2.py @@ -0,0 +1,37 @@ +from typing import Sequence, List, Tuple, TypeVar + +T = TypeVar('T') + +def columnize(sequence: Sequence[T], num_columns: int = 0) -> List[Tuple[T, ...]]: + if num_columns == 0: + num_columns = round(len(sequence) ** .5) + num_rows, reminder = divmod(len(sequence), num_columns) + num_rows += bool(reminder) + return [tuple(sequence[i::num_rows]) for i in range(num_rows)] + + + +def demo() -> None: + nato = ('Alfa Bravo Charlie Delta Echo Foxtrot Golf Hotel India' + ' Juliett Kilo Lima Mike November Oscar Papa Quebec Romeo' + ' Sierra Tango Uniform Victor Whiskey X-ray Yankee Zulu' + ).split() + + for line in columnize(nato): + for word in line: + print(f'{word:15}', end='') + print() + + + print() + for length in range(2, 21, 6): + values = list(range(1, length + 1)) + for row in columnize(values): + for cell in row: + print(f'{cell:5}', end='') + print() + print() + + +if __name__ == '__main__': + demo() diff --git a/08-def-type-hints/columnize_alias.py b/08-def-type-hints/columnize_alias.py new file mode 100644 index 0000000..95b85b3 --- /dev/null +++ b/08-def-type-hints/columnize_alias.py @@ -0,0 +1,27 @@ +from typing import Sequence, List, Tuple + +Row = Tuple[str, ...] + +def columnize(sequence: Sequence[str], num_columns: int) -> List[Row]: + if num_columns == 0: + num_columns = round(len(sequence) ** .5) + num_rows, reminder = divmod(len(sequence), num_columns) + num_rows += bool(reminder) + return [tuple(sequence[i::num_rows]) for i in range(num_rows)] + + + +def demo() -> None: + nato = ('Alfa Bravo Charlie Delta Echo Foxtrot Golf Hotel India' + ' Juliett Kilo Lima Mike November Oscar Papa Quebec Romeo' + ' Sierra Tango Uniform Victor Whiskey X-ray Yankee Zulu' + ).split() + + for row in columnize(nato, 4): + for word in row: + print(f'{word:15}', end='') + print() + + +if __name__ == '__main__': + demo() diff --git a/08-def-type-hints/columnize_test.py b/08-def-type-hints/columnize_test.py new file mode 100644 index 0000000..8a0b0f0 --- /dev/null +++ b/08-def-type-hints/columnize_test.py @@ -0,0 +1,79 @@ +from columnize import columnize + + +def test_columnize_8_in_2(): + sequence = 'ABCDEFGH' + expected = [ + ('A', 'E'), + ('B', 'F'), + ('C', 'G'), + ('D', 'H'), + ] + result = columnize(sequence, 2) + assert expected == result + + +def test_columnize_8_in_4(): + sequence = 'ABCDEFGH' + expected = [ + ('A', 'C', 'E', 'G'), + ('B', 'D', 'F', 'H'), + ] + result = columnize(sequence, 4) + assert expected == result + + +def test_columnize_7_in_2(): + sequence = 'ABCDEFG' + expected = [ + ('A', 'E'), + ('B', 'F'), + ('C', 'G'), + ('D',), + ] + result = columnize(sequence, 2) + assert expected == result + + +def test_columnize_8_in_3(): + sequence = 'ABCDEFGH' + expected = [ + ('A', 'D', 'G',), + ('B', 'E', 'H',), + ('C', 'F'), + ] + result = columnize(sequence, 3) + assert expected == result + + +def test_columnize_8_in_5(): + # Not the right number of columns, but the right number of rows. + # This acually looks better, so it's OK! + sequence = 'ABCDEFGH' + expected = [ + ('A', 'C', 'E', 'G'), + ('B', 'D', 'F', 'H'), + ] + result = columnize(sequence, 5) + assert expected == result + + +def test_columnize_7_in_5(): + # Not the right number of columns, but the right number of rows. + # This acually looks better, so it's OK! + sequence = 'ABCDEFG' + expected = [ + ('A', 'C', 'E', 'G'), + ('B', 'D', 'F'), + ] + result = columnize(sequence, 5) + assert expected == result + + +def test_columnize_not_enough_items(): + sequence = 'AB' + expected = [ + ('A', 'B'), + ] + result = columnize(sequence, 3) + assert expected == result \ No newline at end of file diff --git a/08-def-type-hints/comparable/comparable.py b/08-def-type-hints/comparable/comparable.py new file mode 100644 index 0000000..2c8aa16 --- /dev/null +++ b/08-def-type-hints/comparable/comparable.py @@ -0,0 +1,4 @@ +from typing import Protocol, Any + +class Comparable(Protocol): # <1> + def __lt__(self, other: Any) -> bool: ... # <2> diff --git a/08-def-type-hints/comparable/mymax.py b/08-def-type-hints/comparable/mymax.py new file mode 100644 index 0000000..b69a296 --- /dev/null +++ b/08-def-type-hints/comparable/mymax.py @@ -0,0 +1,60 @@ +# tag::MYMAX_TYPES[] +from typing import Protocol, Any, TypeVar, overload, Callable, Iterable, Union + +class _Comparable(Protocol): + def __lt__(self, other: Any) -> bool: ... + +_T = TypeVar('_T') +_CT = TypeVar('_CT', bound=_Comparable) +_DT = TypeVar('_DT') + +MISSING = object() +EMPTY_MSG = 'max() arg is an empty sequence' + +@overload +def max(__arg1: _CT, __arg2: _CT, *_args: _CT, key: None = ...) -> _CT: + ... +@overload +def max(__arg1: _T, __arg2: _T, *_args: _T, key: Callable[[_T], _CT]) -> _T: + ... +@overload +def max(__iterable: Iterable[_CT], *, key: None = ...) -> _CT: + ... +@overload +def max(__iterable: Iterable[_T], *, key: Callable[[_T], _CT]) -> _T: + ... +@overload +def max(__iterable: Iterable[_CT], *, key: None = ..., + default: _DT) -> Union[_CT, _DT]: + ... +@overload +def max(__iterable: Iterable[_T], *, key: Callable[[_T], _CT], + default: _DT) -> Union[_T, _DT]: + ... +# end::MYMAX_TYPES[] +# tag::MYMAX[] +def max(first, *args, key=None, default=MISSING): + if args: + series = args + candidate = first + else: + series = iter(first) + try: + candidate = next(series) + except StopIteration: + if default is not MISSING: + return default + raise ValueError(EMPTY_MSG) from None + if key is None: + for current in series: + if candidate < current: + candidate = current + else: + candidate_key = key(candidate) + for current in series: + current_key = key(current) + if candidate_key < current_key: + candidate = current + candidate_key = current_key + return candidate +# end::MYMAX[] \ No newline at end of file diff --git a/08-def-type-hints/comparable/mymax_demo.py b/08-def-type-hints/comparable/mymax_demo.py new file mode 100644 index 0000000..7ee6123 --- /dev/null +++ b/08-def-type-hints/comparable/mymax_demo.py @@ -0,0 +1,127 @@ +from typing import TYPE_CHECKING, List, Optional + +import mymax as my + +def demo_args_list_float() -> None: + args = [2.5, 3.5, 1.5] + expected = 3.5 + result = my.max(*args) + print(args, expected, result, sep='\n') + assert result == expected + if TYPE_CHECKING: + reveal_type(args) + reveal_type(expected) + reveal_type(result) + +def demo_args_iter_int() -> None: + args = [30, 10, 20] + expected = 30 + result = my.max(args) + print(args, expected, result, sep='\n') + assert result == expected + if TYPE_CHECKING: + reveal_type(args) + reveal_type(expected) + reveal_type(result) + + +def demo_args_iter_str() -> None: + args = iter('banana kiwi mango apple'.split()) + expected = 'mango' + result = my.max(args) + print(args, expected, result, sep='\n') + assert result == expected + if TYPE_CHECKING: + reveal_type(args) + reveal_type(expected) + reveal_type(result) + + +def demo_args_iter_not_comparable_with_key() -> None: + args = [object(), object(), object()] + key = id + expected = max(args, key=id) + result = my.max(args, key=key) + print(args, key, expected, result, sep='\n') + assert result == expected + if TYPE_CHECKING: + reveal_type(args) + reveal_type(key) + reveal_type(expected) + reveal_type(result) + + +def demo_empty_iterable_with_default() -> None: + args: List[float] = [] + default = None + expected = None + result = my.max(args, default=default) + print(args, default, expected, result, sep='\n') + assert result == expected + if TYPE_CHECKING: + reveal_type(args) + reveal_type(default) + reveal_type(expected) + reveal_type(result) + + +def demo_different_key_return_type() -> None: + args = iter('banana kiwi mango apple'.split()) + key = len + expected = 'banana' + result = my.max(args, key=key) + print(args, key, expected, result, sep='\n') + assert result == expected + if TYPE_CHECKING: + reveal_type(args) + reveal_type(key) + reveal_type(expected) + reveal_type(result) + + +def demo_different_key_none() -> None: + args = iter('banana kiwi mango apple'.split()) + key = None + expected = 'mango' + result = my.max(args, key=key) + print(args, key, expected, result, sep='\n') + assert result == expected + if TYPE_CHECKING: + reveal_type(args) + reveal_type(key) + reveal_type(expected) + reveal_type(result) + +###################################### intentional type errors + +def error_reported_bug() -> None: + # example from https://github.com/python/typeshed/issues/4051 + top: Optional[int] = None + try: + my.max(5, top) + except TypeError as exc: + print(exc) + + +def error_args_iter_not_comparable() -> None: + try: + my.max([None, None]) + except TypeError as exc: + print(exc) + + +def error_single_arg_not_iterable() -> None: + try: + my.max(1) + except TypeError as exc: + print(exc) + + +def main(): + for name, val in globals().items(): + if name.startswith('demo') or name.startswith('error'): + print('_' * 20, name) + val() + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/08-def-type-hints/comparable/mymax_test.py b/08-def-type-hints/comparable/mymax_test.py new file mode 100644 index 0000000..3434364 --- /dev/null +++ b/08-def-type-hints/comparable/mymax_test.py @@ -0,0 +1,69 @@ +from typing import List, Callable, TypeVar + +import pytest # type: ignore + +import mymax as my + +@pytest.fixture +def fruits(): + return 'banana kiwi mango apple'.split() + +@pytest.mark.parametrize('args, expected', [ + ([1, 3], 3), + ([3, 1], 3), + ([30, 10, 20], 30), +]) +def test_max_args(args, expected): + result = my.max(*args) + assert result == expected + + +@pytest.mark.parametrize('iterable, expected', [ + ([7], 7), + ([1, 3], 3), + ([3, 1], 3), + ([30, 10, 20], 30), +]) +def test_max_iterable(iterable, expected): + result = my.max(iterable) + assert result == expected + + +def test_max_single_arg_not_iterable(): + msg = "'int' object is not iterable" + with pytest.raises(TypeError) as exc: + my.max(1) + assert exc.value.args[0] == msg + + +def test_max_empty_iterable_no_default(): + with pytest.raises(ValueError) as exc: + my.max([]) + assert exc.value.args[0] == my.EMPTY_MSG + + +@pytest.mark.parametrize('iterable, default, expected', [ + ([7], -1, 7), + ([], -1, -1), + ([], None, None), +]) +def test_max_empty_iterable_with_default(iterable, default, expected): + result = my.max(iterable, default=default) + assert result == expected + + +@pytest.mark.parametrize('key, expected', [ + (None, 'mango'), + (lambda x: x, 'mango'), + (len, 'banana'), + (lambda s: -len(s), 'kiwi'), + (lambda s: -ord(s[0]), 'apple'), + (lambda s: ord(s[-1]), 'mango'), +]) +def test_max_iterable_with_key( + fruits: List[str], + key: Callable[[str], str], + expected: str +) -> None: + result = my.max(fruits, key=key) + assert result == expected diff --git a/08-def-type-hints/comparable/top.py b/08-def-type-hints/comparable/top.py new file mode 100644 index 0000000..51b7751 --- /dev/null +++ b/08-def-type-hints/comparable/top.py @@ -0,0 +1,31 @@ +""" +``top(it, n)`` returns the "greatest" ``n`` elements of the iterable ``t``. +Example: + +# tag::TOP_DOCTEST[] +>>> top([4, 1, 5, 2, 6, 7, 3], 3) +[7, 6, 5] +>>> l = 'mango pear apple kiwi banana'.split() +>>> top(l, 3) +['pear', 'mango', 'kiwi'] +>>> +>>> l2 = [(len(s), s) for s in l] +>>> l2 +[(5, 'mango'), (4, 'pear'), (5, 'apple'), (4, 'kiwi'), (6, 'banana')] +>>> top(l2, 3) +[(6, 'banana'), (5, 'mango'), (5, 'apple')] + +# end::TOP_DOCTEST[] + +""" + +# tag::TOP[] +from typing import TypeVar, Iterable, List +from comparable import Comparable + +CT = TypeVar('CT', bound=Comparable) + +def top(series: Iterable[CT], length: int) -> List[CT]: + ordered = sorted(series, reverse=True) + return ordered[:length] +# end::TOP[] \ No newline at end of file diff --git a/08-def-type-hints/comparable/top_test.py b/08-def-type-hints/comparable/top_test.py new file mode 100644 index 0000000..2e69e6c --- /dev/null +++ b/08-def-type-hints/comparable/top_test.py @@ -0,0 +1,39 @@ +from typing import Tuple, List, Iterator, TYPE_CHECKING +import pytest # type: ignore +from top import top + +@pytest.mark.parametrize('series, length, expected', [ + ((1, 2, 3), 2, [3, 2]), + ((1, 2, 3), 3, [3, 2, 1]), + ((3, 3, 3), 1, [3]), +]) +def test_top( + series: Tuple[float, ...], + length: int, + expected: List[float], +) -> None: + result = top(series, length) + assert expected == result + +# tag::TOP_TEST[] +def test_top_tuples() -> None: + fruit = 'mango pear apple kiwi banana'.split() + series: Iterator[Tuple[int, str]] = ( + (len(s), s) for s in fruit) + length = 3 + expected = [(6, 'banana'), (5, 'mango'), (5, 'apple')] + result = top(series, length) + if TYPE_CHECKING: + reveal_type(series) + reveal_type(expected) + reveal_type(result) + assert result == expected + +def test_top_objects_error() -> None: + series = [object() for _ in range(4)] + if TYPE_CHECKING: + reveal_type(series) + with pytest.raises(TypeError) as exc: + top(series, 3) + assert "'<' not supported" in str(exc) +# end::TOP_TEST[] diff --git a/08-def-type-hints/coordinates/coordinates.py b/08-def-type-hints/coordinates/coordinates.py new file mode 100644 index 0000000..6986f8a --- /dev/null +++ b/08-def-type-hints/coordinates/coordinates.py @@ -0,0 +1,19 @@ +# This example uses the geolib library: +# https://pypi.org/project/geolib/ + +""" +>>> shanghai = 31.2304, 121.4737 +>>> geohash(shanghai) +'wtw3sjq6q' +""" + +# tag::GEOHASH[] +from typing import Tuple + +from geolib import geohash as gh # type: ignore + +PRECISION = 9 + +def geohash(lat_lon = Tuple[float, float]) -> str: + return gh.encode(*lat_lon, PRECISION) +# end::GEOHASH[] \ No newline at end of file diff --git a/08-def-type-hints/coordinates/coordinates_named.py b/08-def-type-hints/coordinates/coordinates_named.py new file mode 100644 index 0000000..87cac72 --- /dev/null +++ b/08-def-type-hints/coordinates/coordinates_named.py @@ -0,0 +1,36 @@ +# This example requires the geolib library: +# https://pypi.org/project/geolib/ + + +""" +>>> shanghai = 31.2304, 121.4737 +>>> geohash(shanghai) +'wtw3sjq6q' +""" + +# tag::GEOHASH[] +from typing import Tuple, NamedTuple + +from geolib import geohash as gh # type: ignore + +PRECISION = 9 + +class Coordinate(NamedTuple): + lat: float + lon: float + +def geohash(lat_lon: Coordinate) -> str: + return gh.encode(*lat_lon, PRECISION) + +def display(lat_lon: Tuple[float, float]) -> str: + lat, lon = lat_lon + ns = 'N' if lat >= 0 else 'S' + ew = 'E' if lon >= 0 else 'W' + return f'{abs(lat):0.1f}°{ns}, {abs(lon):0.1f}°{ew}' + +# end::GEOHASH[] + +def demo(): + shanghai = 31.2304, 121.4737 + s = geohash(shanghai) + print(s) diff --git a/08-def-type-hints/coordinates/coordinates_named_test.py b/08-def-type-hints/coordinates/coordinates_named_test.py new file mode 100644 index 0000000..60bcd49 --- /dev/null +++ b/08-def-type-hints/coordinates/coordinates_named_test.py @@ -0,0 +1,12 @@ +from coordinates_named import geohash, Coordinate, display + +def test_geohash_max_precision() -> None: + sao_paulo = -23.5505, -46.6339 + result = geohash(Coordinate(*sao_paulo)) + assert '6gyf4bf0r' == result + +def test_display() -> None: + sao_paulo = -23.5505, -46.6339 + assert display(sao_paulo) == '23.6°S, 46.6°W' + shanghai = 31.2304, 121.4737 + assert display(shanghai) == '31.2°N, 121.5°E' diff --git a/08-def-type-hints/coordinates/coordinates_test.py b/08-def-type-hints/coordinates/coordinates_test.py new file mode 100644 index 0000000..601f8c2 --- /dev/null +++ b/08-def-type-hints/coordinates/coordinates_test.py @@ -0,0 +1,6 @@ +from coordinates import geohash + +def test_geohash_max_precision() -> None: + sao_paulo = -23.5505, -46.6339 + result = geohash(sao_paulo) + assert '6gyf4bf0r' == result diff --git a/08-def-type-hints/coordinates/requirements.txt b/08-def-type-hints/coordinates/requirements.txt new file mode 100644 index 0000000..fb11094 --- /dev/null +++ b/08-def-type-hints/coordinates/requirements.txt @@ -0,0 +1,2 @@ +geolib==1.0.7 +future==0.18.2 diff --git a/08-def-type-hints/ctime.py b/08-def-type-hints/ctime.py new file mode 100644 index 0000000..25e12ae --- /dev/null +++ b/08-def-type-hints/ctime.py @@ -0,0 +1,5 @@ +import time +from typing import Optional + +def ctime(secs: Optional[float] = None, /) -> str: + return time.ctime(secs) diff --git a/08-def-type-hints/double/double_object.py b/08-def-type-hints/double/double_object.py new file mode 100644 index 0000000..22e0a54 --- /dev/null +++ b/08-def-type-hints/double/double_object.py @@ -0,0 +1,2 @@ +def double(n: object) -> object: + return n * 2 diff --git a/08-def-type-hints/double/double_protocol.py b/08-def-type-hints/double/double_protocol.py new file mode 100644 index 0000000..f0704f0 --- /dev/null +++ b/08-def-type-hints/double/double_protocol.py @@ -0,0 +1,11 @@ +from typing import TypeVar, Protocol + +T = TypeVar('T') # <1> + +class Repeatable(Protocol): + def __mul__(self: T, other: int) -> T: ... # <2> + +RT = TypeVar('RT', bound=Repeatable) # <3> + +def double(n: RT) -> RT: # <4> + return n * 2 diff --git a/08-def-type-hints/double/double_sequence.py b/08-def-type-hints/double/double_sequence.py new file mode 100644 index 0000000..f9d1dbd --- /dev/null +++ b/08-def-type-hints/double/double_sequence.py @@ -0,0 +1,6 @@ +from collections import abc +from typing import Any + +def double(n: abc.Sequence) -> Any: + return n * 2 + diff --git a/08-def-type-hints/double/double_test.py b/08-def-type-hints/double/double_test.py new file mode 100644 index 0000000..d37aba5 --- /dev/null +++ b/08-def-type-hints/double/double_test.py @@ -0,0 +1,56 @@ +from typing import TYPE_CHECKING +import pytest +from double_protocol import double + +def test_double_int() -> None: + given = 2 + result = double(given) + assert result == given * 2 + if TYPE_CHECKING: + reveal_type(given) + reveal_type(result) + + +def test_double_str() -> None: + given = 'A' + result = double(given) + assert result == given * 2 + if TYPE_CHECKING: + reveal_type(given) + reveal_type(result) + + +def test_double_fraction() -> None: + from fractions import Fraction + given = Fraction(2, 5) + result = double(given) + assert result == given * 2 + if TYPE_CHECKING: + reveal_type(given) + reveal_type(result) + + +def test_double_array() -> None: + from array import array + given = array('d', [1.0, 2.0, 3.14]) + result = double(given) + if TYPE_CHECKING: + reveal_type(given) + reveal_type(result) + + +def test_double_nparray() -> None: + import numpy as np # type: ignore + given = np.array([[1, 2], [3, 4]]) + result = double(given) + comparison = result == given * 2 + assert comparison.all() + if TYPE_CHECKING: + reveal_type(given) + reveal_type(result) + + +def test_double_none() -> None: + given = None + with pytest.raises(TypeError): + result = double(given) diff --git a/08-def-type-hints/list.py b/08-def-type-hints/list.py new file mode 100644 index 0000000..6d122e7 --- /dev/null +++ b/08-def-type-hints/list.py @@ -0,0 +1,10 @@ +from typing import List, Tuple + +def tokenize(text: str) -> List[str]: + return text.upper().split() + +l: List[str] = [] +l.append(1) +print(l) + +t: Tuple[str, float] = ('São Paulo', 12_176_866) diff --git a/08-def-type-hints/messages/hints_1/messages.py b/08-def-type-hints/messages/hints_1/messages.py new file mode 100644 index 0000000..c00e706 --- /dev/null +++ b/08-def-type-hints/messages/hints_1/messages.py @@ -0,0 +1,20 @@ +""" +# tag::SHOW_COUNT_DOCTEST[] +>>> show_count(99, 'bird') +'99 birds' +>>> show_count(1, 'bird') +'1 bird' +>>> show_count(0, 'bird') +'no bird' + +# end::SHOW_COUNT_DOCTEST[] +""" + +# tag::SHOW_COUNT[] +def show_count(count: int, word: str) -> str: + if count == 0: + return f'no {word}' + elif count == 1: + return f'{count} {word}' + return f'{count} {word}s' +# end::SHOW_COUNT[] diff --git a/08-def-type-hints/messages/hints_1/messages_test.py b/08-def-type-hints/messages/hints_1/messages_test.py new file mode 100644 index 0000000..2a16f25 --- /dev/null +++ b/08-def-type-hints/messages/hints_1/messages_test.py @@ -0,0 +1,17 @@ +from pytest import mark + +from messages import show_count + + +@mark.parametrize('qty, expected', [ + (1, '1 part'), + (2, '2 parts'), +]) +def test_show_count(qty, expected): + got = show_count(qty, 'part') + assert got == expected + + +def test_show_count_zero(): + got = show_count(0, 'part') + assert got == 'no part' diff --git a/08-def-type-hints/messages/hints_1/mypy.ini b/08-def-type-hints/messages/hints_1/mypy.ini new file mode 100644 index 0000000..f658867 --- /dev/null +++ b/08-def-type-hints/messages/hints_1/mypy.ini @@ -0,0 +1,6 @@ +[mypy] +python_version = 3.8 +warn_unused_configs = True +disallow_incomplete_defs = True +[mypy-pytest] +ignore_missing_imports = True diff --git a/08-def-type-hints/messages/hints_2/messages.py b/08-def-type-hints/messages/hints_2/messages.py new file mode 100644 index 0000000..c43d85f --- /dev/null +++ b/08-def-type-hints/messages/hints_2/messages.py @@ -0,0 +1,24 @@ +""" +>>> show_count(99, 'bird') +'99 birds' +>>> show_count(1, 'bird') +'1 bird' +>>> show_count(0, 'bird') +'no bird' +>>> show_count(3, 'virus', 'viruses') +'3 viruses' +""" + +# tag::SHOW_COUNT[] +def show_count(count: int, singular: str, plural: str = '') -> str: + if count == 0: + return f'no {singular}' + elif count == 1: + return f'1 {singular}' + else: + if plural: + return f'{count} {plural}' + else: + return f'{count} {singular}s' + +# end::SHOW_COUNT[] diff --git a/08-def-type-hints/messages/hints_2/messages_test.py b/08-def-type-hints/messages/hints_2/messages_test.py new file mode 100644 index 0000000..f7b2fe1 --- /dev/null +++ b/08-def-type-hints/messages/hints_2/messages_test.py @@ -0,0 +1,25 @@ +from pytest import mark # type: ignore + +from messages import show_count + + +@mark.parametrize('qty, expected', [ + (1, '1 part'), + (2, '2 parts'), + (0, 'no part'), +]) +def test_show_count(qty, expected): + got = show_count(qty, 'part') + assert got == expected + + +# tag::TEST_IRREGULAR[] +@mark.parametrize('qty, expected', [ + (1, '1 child'), + (2, '2 children'), + (0, 'no child'), +]) +def test_irregular(qty, expected) -> None: + got = show_count(qty, 'child', 'children') + assert got == expected +# end::TEST_IRREGULAR[] diff --git a/08-def-type-hints/messages/no_hints/messages.py b/08-def-type-hints/messages/no_hints/messages.py new file mode 100644 index 0000000..d7898c5 --- /dev/null +++ b/08-def-type-hints/messages/no_hints/messages.py @@ -0,0 +1,20 @@ +""" +# tag::SHOW_COUNT_DOCTEST[] +>>> show_count(99, 'bird') +'99 birds' +>>> show_count(1, 'bird') +'1 bird' +>>> show_count(0, 'bird') +'no bird' + +# end::SHOW_COUNT_DOCTEST[] +""" + +# tag::SHOW_COUNT[] +def show_count(count, word): + if count == 0: + return f'no {word}' + elif count == 1: + return f'{count} {word}' + return f'{count} {word}s' +# end::SHOW_COUNT[] diff --git a/08-def-type-hints/messages/no_hints/messages_test.py b/08-def-type-hints/messages/no_hints/messages_test.py new file mode 100644 index 0000000..a8ec100 --- /dev/null +++ b/08-def-type-hints/messages/no_hints/messages_test.py @@ -0,0 +1,15 @@ +from pytest import mark + +from messages import show_count + +@mark.parametrize('qty, expected', [ + (1, '1 part'), + (2, '2 parts'), +]) +def test_show_count(qty, expected): + got = show_count(qty, 'part') + assert got == expected + +def test_show_count_zero(): + got = show_count(0, 'part') + assert got == 'no part' diff --git a/08-def-type-hints/mode/mode_T.py b/08-def-type-hints/mode/mode_T.py new file mode 100644 index 0000000..d646f5c --- /dev/null +++ b/08-def-type-hints/mode/mode_T.py @@ -0,0 +1,25 @@ +from collections import Counter +from typing import Iterable, TypeVar + +T = TypeVar('T') + +def mode(data: Iterable[T]) -> T: + data = iter(data) + pairs = Counter(data).most_common(1) + if len(pairs) == 0: + raise ValueError('no mode for empty data') + return pairs[0][0] + + +def demo() -> None: + from typing import List, Set, TYPE_CHECKING + pop:List[Set] = [set(), set()] + m = mode(pop) + if TYPE_CHECKING: + reveal_type(pop) + reveal_type(m) + print(pop) + print(repr(m), type(m)) + +if __name__ == '__main__': + demo() \ No newline at end of file diff --git a/08-def-type-hints/mode/mode_float.py b/08-def-type-hints/mode/mode_float.py new file mode 100644 index 0000000..0684202 --- /dev/null +++ b/08-def-type-hints/mode/mode_float.py @@ -0,0 +1,23 @@ +# tag::MODE_FLOAT[] +from collections import Counter +from typing import Iterable + +def mode(data: Iterable[float]) -> float: + pairs = Counter(data).most_common(1) + if len(pairs) == 0: + raise ValueError('no mode for empty data') + return pairs[0][0] +# end::MODE_FLOAT[] + +def demo() -> None: + import typing + pop = [1, 1, 2, 3, 3, 3, 3, 4] + m = mode(pop) + if typing.TYPE_CHECKING: + reveal_type(pop) + reveal_type(m) + print(pop) + print(repr(m), type(m)) + +if __name__ == '__main__': + demo() \ No newline at end of file diff --git a/08-def-type-hints/mode/mode_hashable.py b/08-def-type-hints/mode/mode_hashable.py new file mode 100644 index 0000000..aa7f313 --- /dev/null +++ b/08-def-type-hints/mode/mode_hashable.py @@ -0,0 +1,28 @@ +# tag::MODE_HASHABLE_T[] +from collections import Counter +from typing import Iterable, Hashable, TypeVar + +HashableT = TypeVar('HashableT', bound=Hashable) + +def mode(data: Iterable[HashableT]) -> HashableT: + pairs = Counter(data).most_common(1) + if len(pairs) == 0: + raise ValueError('no mode for empty data') + return pairs[0][0] +# end::MODE_HASHABLE_T[] + + +def demo() -> None: + import typing + + pop = 'abracadabra' + m = mode(pop) + if typing.TYPE_CHECKING: + reveal_type(pop) + reveal_type(m) + print(pop) + print(m.upper(), type(m)) + + +if __name__ == '__main__': + demo() diff --git a/08-def-type-hints/mode/mode_hashable_wrong.py b/08-def-type-hints/mode/mode_hashable_wrong.py new file mode 100644 index 0000000..ff770a7 --- /dev/null +++ b/08-def-type-hints/mode/mode_hashable_wrong.py @@ -0,0 +1,24 @@ +# tag::MODE_FLOAT[] +from collections import Counter +from typing import Iterable, Hashable + +def mode(data: Iterable[Hashable]) -> Hashable: + data = iter(data) + pairs = Counter(data).most_common(1) + if len(pairs) == 0: + raise ValueError('no mode for empty data') + return pairs[0][0] +# end::MODE_FLOAT[] + +def demo() -> None: + import typing + pop = 'abracadabra' + m = mode(pop) + if typing.TYPE_CHECKING: + reveal_type(pop) + reveal_type(m) + print(pop) + print(m.upper(), type(m)) + +if __name__ == '__main__': + demo() \ No newline at end of file diff --git a/08-def-type-hints/mode/mode_number.py b/08-def-type-hints/mode/mode_number.py new file mode 100644 index 0000000..3eed434 --- /dev/null +++ b/08-def-type-hints/mode/mode_number.py @@ -0,0 +1,26 @@ +from collections import Counter +from typing import Iterable, TypeVar +from decimal import Decimal +from fractions import Fraction + +NumberT = TypeVar('NumberT', float, Decimal, Fraction) + +def mode(data: Iterable[NumberT]) -> NumberT: + pairs = Counter(data).most_common(1) + if len(pairs) == 0: + raise ValueError('no mode for empty data') + return pairs[0][0] + + +def demo() -> None: + from typing import List, Set, TYPE_CHECKING + pop = [Fraction(1, 2), Fraction(1, 3), Fraction(1, 4), Fraction(1, 2)] + m = mode(pop) + if TYPE_CHECKING: + reveal_type(pop) + reveal_type(m) + print(pop) + print(repr(m), type(m)) + +if __name__ == '__main__': + demo() \ No newline at end of file diff --git a/08-def-type-hints/mysum.py b/08-def-type-hints/mysum.py new file mode 100644 index 0000000..3fb041a --- /dev/null +++ b/08-def-type-hints/mysum.py @@ -0,0 +1,13 @@ +from functools import reduce # <1> +from operator import add +from typing import overload, Iterable, Union, TypeVar + +T = TypeVar('T') +S = TypeVar('S') # <2> + +@overload +def sum(it: Iterable[T]) -> Union[T, int]: ... # <3> +@overload +def sum(it: Iterable[T], /, start: S) -> Union[T, S]: ... # <4> +def sum(it, /, start=0): # <5> + return reduce(add, it, start) diff --git a/08-def-type-hints/passdrill.py b/08-def-type-hints/passdrill.py new file mode 100755 index 0000000..7523f5e --- /dev/null +++ b/08-def-type-hints/passdrill.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 + +"""passdrill: typing drills for practicing passphrases +""" + +import sys +import os +from getpass import getpass +from hashlib import scrypt +from base64 import b64encode, b64decode + +from typing import Sequence, Tuple + +HASH_FILENAME = 'passdrill.hash' +HELP = 'Use -s to save passphrase hash for practice.' + + +def prompt() -> str: + print('WARNING: the passphrase WILL BE SHOWN so that you can check it!') + confirmed = '' + while confirmed != 'y': + passphrase = input('Type passphrase to hash (it will be echoed): ') + if passphrase == '' or passphrase == 'q': + print('ERROR: the passphrase cannot be empty or "q".') + continue + print(f'Passphrase to be hashed -> {passphrase}') + confirmed = input('Confirm (y/n): ').lower() + return passphrase + + +def crypto_hash(salt: bytes, passphrase: str) -> bytes: + octets = passphrase.encode('utf-8') + # Recommended parameters for interactive logins as of 2017: + # N=32768, r=8 and p=1 (https://godoc.org/golang.org/x/crypto/scrypt) + return scrypt(octets, salt=salt, n=32768, r=8, p=1, maxmem=2 ** 26) + + +def build_hash(passphrase: str) -> bytes: + salt = os.urandom(32) + payload = crypto_hash(salt, passphrase) + return b64encode(salt) + b':' + b64encode(payload) + + +def save_hash() -> None: + salted_hash = build_hash(prompt()) + with open(HASH_FILENAME, 'wb') as fp: + fp.write(salted_hash) + print(f'Passphrase hash saved to', HASH_FILENAME) + + +def load_hash() -> Tuple[bytes, bytes]: + try: + with open(HASH_FILENAME, 'rb') as fp: + salted_hash = fp.read() + except FileNotFoundError: + print('ERROR: passphrase hash file not found.', HELP) + sys.exit(2) + + salt, stored_hash = salted_hash.split(b':') + return (b64decode(salt), b64decode(stored_hash)) + + +def practice() -> None: + salt, stored_hash = load_hash() + print('Type q to end practice.') + turn = 0 + correct = 0 + while True: + turn += 1 + response = getpass(f'{turn}:') + if response == '': + print('Type q to quit.') + turn -= 1 # don't count this response + continue + elif response == 'q': + turn -= 1 # don't count this response + break + if crypto_hash(salt, response) == stored_hash: + correct += 1 + answer = 'OK' + else: + answer = 'wrong' + print(f' {answer}\thits={correct}\tmisses={turn-correct}') + + if turn: + pct = correct / turn * 100 + print(f'\n{turn} turns. {pct:0.1f}% correct.') + + +def main(argv: Sequence[str]) -> None: + if len(argv) < 2: + practice() + elif len(argv) == 2 and argv[1] == '-s': + save_hash() + else: + print('ERROR: invalid argument.', HELP) + sys.exit(1) + + +if __name__ == '__main__': + main(sys.argv) diff --git a/08-def-type-hints/replacer.py b/08-def-type-hints/replacer.py new file mode 100644 index 0000000..ea24a88 --- /dev/null +++ b/08-def-type-hints/replacer.py @@ -0,0 +1,36 @@ +""" +``zip_replace`` replaces multiple calls to ``str.replace``:: + + >>> changes = [ + ... ('(', ' ( '), + ... (')', ' ) '), + ... (' ', ' '), + ... ] + >>> expr = '(+ 2 (* 3 7))' + >>> zip_replace(expr, changes) + ' ( + 2 ( * 3 7 ) ) ' + +""" + +# tag::ZIP_REPLACE[] +from typing import Iterable, Tuple + +FromTo = Tuple[str, str] # <1> + +def zip_replace(text: str, changes: Iterable[FromTo]) -> str: # <2> + for from_, to in changes: + text = text.replace(from_, to) + return text +# end::ZIP_REPLACE[] + +def demo() -> None: + import doctest + failed, count = doctest.testmod() + print(f'{count-failed} of {count} doctests OK') + l33t = [(p[0], p[1]) for p in 'a4 e3 i1 o0'.split()] + text = 'mad skilled noob powned leet' + print(zip_replace(text, l33t)) + + +if __name__ == '__main__': + demo() diff --git a/08-def-type-hints/replacer2.py b/08-def-type-hints/replacer2.py new file mode 100644 index 0000000..9bf3986 --- /dev/null +++ b/08-def-type-hints/replacer2.py @@ -0,0 +1,39 @@ +""" +``zip_replace`` replaces multiple calls to ``str.replace``:: + + >>> changes = [ + ... ('(', ' ( '), + ... (')', ' ) '), + ... (' ', ' '), + ... ] + >>> expr = '(+ 2 (* 3 7))' + >>> zip_replace(expr, changes) + ' ( + 2 ( * 3 7 ) ) ' + +""" + +from typing import Iterable, NamedTuple + + +class FromTo(NamedTuple): + from_: str + to: str + + +def zip_replace(text: str, changes: Iterable[FromTo], count:int = -1) -> str: + for from_, to in changes: + text = text.replace(from_, to, count) + return text + + +def demo() -> None: + import doctest + failed, count = doctest.testmod() + print(f'{count-failed} of {count} doctests OK') + l33t = [FromTo(*p) for p in 'a4 e3 i1 o0'.split()] + text = 'mad skilled noob powned leet' + print(zip_replace(text, l33t)) + + +if __name__ == '__main__': + demo() diff --git a/08-def-type-hints/reveal_array.py b/08-def-type-hints/reveal_array.py new file mode 100644 index 0000000..149c85a --- /dev/null +++ b/08-def-type-hints/reveal_array.py @@ -0,0 +1,8 @@ +from array import array +from typing import MutableSequence + +a = array('d') +reveal_type(a) +b: MutableSequence[float] = array('b') +reveal_type(b) + diff --git a/08-def-type-hints/romans.py b/08-def-type-hints/romans.py new file mode 100644 index 0000000..a8bd6b6 --- /dev/null +++ b/08-def-type-hints/romans.py @@ -0,0 +1,16 @@ +values_map = [ + (1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1), + ( 'M', 'CM', 'D', 'CD', 'C', 'XC', 'L', 'XL', 'X', 'IX', 'V', 'IV','I') +] + +def to_roman(arabic: int) -> str: + """ Convert an integer to a Roman numeral. """ + if not 0 < arabic < 4000: + raise ValueError('Argument must be between 1 and 3999') + + result = [] + for value, numeral in zip(*values_map): + repeat = arabic // value + result.append(numeral * repeat) + arabic -= value * repeat + return ''.join(result) diff --git a/08-def-type-hints/romans_test.py b/08-def-type-hints/romans_test.py new file mode 100644 index 0000000..915c862 --- /dev/null +++ b/08-def-type-hints/romans_test.py @@ -0,0 +1,18 @@ +import pytest + +from romans import to_roman + + +def test_to_roman_1(): + assert to_roman(1) == 'I' + + +@pytest.mark.parametrize('arabic, roman', [ + (3, 'III'), + (4, 'IV'), + (1009, 'MIX'), + (1969, 'MCMLXIX'), + (3999, 'MMMCMXCIX') +]) +def test_to_roman(arabic, roman): + assert to_roman(arabic) == roman diff --git a/08-def-type-hints/sample.py b/08-def-type-hints/sample.py new file mode 100644 index 0000000..0e7e2e9 --- /dev/null +++ b/08-def-type-hints/sample.py @@ -0,0 +1,34 @@ +# tag::SAMPLE[] +from random import shuffle +from typing import Sequence, List, TypeVar + +T = TypeVar('T') + +def sample(population: Sequence[T], size: int) -> List[T]: + if size < 1: + raise ValueError('size must be >= 1') + result = list(population) + shuffle(result) + return result[:size] +# end::SAMPLE[] + +def demo() -> None: + import typing + p1 = tuple(range(10)) + s1 = sample(p1, 3) + if typing.TYPE_CHECKING: + reveal_type(p1) + reveal_type(s1) + print(p1) + print(s1) + p2 = 'The quick brown fox jumps over the lazy dog' + s2 = sample(p2, 10) + if typing.TYPE_CHECKING: + reveal_type(p2) + reveal_type(s2) + print(p2) + print(s2) + + +if __name__ == '__main__': + demo() diff --git a/08-def-type-hints/typeddict/books.py b/08-def-type-hints/typeddict/books.py new file mode 100644 index 0000000..5a0bc82 --- /dev/null +++ b/08-def-type-hints/typeddict/books.py @@ -0,0 +1,32 @@ +# tag::BOOKDICT[] +from typing import TypedDict, List +import json + +class BookDict(TypedDict): + isbn: str + title: str + authors: List[str] + pagecount: int +# end::BOOKDICT[] + +# tag::TOXML[] +AUTHOR_EL = '{}' + +def to_xml(book: BookDict) -> str: # <1> + elements: List[str] = [] # <2> + for key, value in book.items(): + if isinstance(value, list): # <3> + elements.extend( + AUTHOR_EL.format(n) for n in value) # <4> + else: + tag = key.upper() + elements.append(f'<{tag}>{value}') + xml = '\n\t'.join(elements) + return f'\n\t{xml}\n' +# end::TOXML[] + +# tag::FROMJSON[] +def from_json(data: str) -> BookDict: + whatever: BookDict = json.loads(data) # <1> + return whatever # <2> +# end::FROMJSON[] \ No newline at end of file diff --git a/08-def-type-hints/typeddict/books_any.py b/08-def-type-hints/typeddict/books_any.py new file mode 100644 index 0000000..49d500f --- /dev/null +++ b/08-def-type-hints/typeddict/books_any.py @@ -0,0 +1,32 @@ +# tag::BOOKDICT[] +from typing import TypedDict, List +import json + +class BookDict(TypedDict): + isbn: str + title: str + authors: List[str] + pagecount: int +# end::BOOKDICT[] + +# tag::TOXML[] +AUTHOR_EL = '{}' + +def to_xml(book: BookDict) -> str: # <1> + elements: List[str] = [] # <2> + for key, value in book.items(): + if isinstance(value, list): # <3> + elements.extend(AUTHOR_EL.format(n) + for n in value) + else: + tag = key.upper() + elements.append(f'<{tag}>{value}') + xml = '\n\t'.join(elements) + return f'\n\t{xml}\n' +# end::TOXML[] + +# tag::FROMJSON[] +def from_json(data: str) -> BookDict: + whatever = json.loads(data) # <1> + return whatever # <2> +# end::FROMJSON[] \ No newline at end of file diff --git a/08-def-type-hints/typeddict/demo_books.py b/08-def-type-hints/typeddict/demo_books.py new file mode 100644 index 0000000..5203acb --- /dev/null +++ b/08-def-type-hints/typeddict/demo_books.py @@ -0,0 +1,20 @@ +from books import BookDict +from typing import TYPE_CHECKING + +def demo() -> None: # <1> + book = BookDict( # <2> + isbn='0134757599', + title='Refactoring, 2e', + authors=['Martin Fowler', 'Kent Beck'], + pagecount=478 + ) + authors = book['authors'] # <3> + if TYPE_CHECKING: # <4> + reveal_type(authors) # <5> + authors = 'Bob' # <6> + book['weight'] = 4.2 + del book['title'] + + +if __name__ == '__main__': + demo() diff --git a/08-def-type-hints/typeddict/demo_not_book.py b/08-def-type-hints/typeddict/demo_not_book.py new file mode 100644 index 0000000..7bf0711 --- /dev/null +++ b/08-def-type-hints/typeddict/demo_not_book.py @@ -0,0 +1,23 @@ +from books import to_xml, from_json +from typing import TYPE_CHECKING + +def demo() -> None: + NOT_BOOK_JSON = """ + {"title": "Andromeda Strain", + "flavor": "pistachio", + "authors": true} + """ + not_book = from_json(NOT_BOOK_JSON) # <1> + if TYPE_CHECKING: # <2> + reveal_type(not_book) + reveal_type(not_book['authors']) + + print(not_book) # <3> + print(not_book['flavor']) # <4> + + xml = to_xml(not_book) # <5> + print(xml) # <6> + + +if __name__ == '__main__': + demo() diff --git a/08-def-type-hints/typeddict/test_books.py b/08-def-type-hints/typeddict/test_books.py new file mode 100644 index 0000000..fc9d245 --- /dev/null +++ b/08-def-type-hints/typeddict/test_books.py @@ -0,0 +1,112 @@ +import json +from typing import cast + +from books import BookDict, to_xml, from_json + +XML_SAMPLE = """ + +\t0134757599 +\tRefactoring, 2e +\tMartin Fowler +\tKent Beck +\t478 + +""".strip() + + +# using plain dicts + +def test_1() -> None: + xml = to_xml({ + 'isbn': '0134757599', + 'title': 'Refactoring, 2e', + 'authors': ['Martin Fowler', 'Kent Beck'], + 'pagecount': 478, + }) + assert xml == XML_SAMPLE + +def test_2() -> None: + xml = to_xml(dict( + isbn='0134757599', + title='Refactoring, 2e', + authors=['Martin Fowler', 'Kent Beck'], + pagecount=478)) + assert xml == XML_SAMPLE + +def test_5() -> None: + book_data: BookDict = dict( + isbn='0134757599', + title='Refactoring, 2e', + authors=['Martin Fowler', 'Kent Beck'], + pagecount=478 + ) + xml = to_xml(book_data) + assert xml == XML_SAMPLE + +def test_6() -> None: + book_data = dict( + isbn='0134757599', + title='Refactoring, 2e', + authors=['Martin Fowler', 'Kent Beck'], + pagecount=478 + ) + xml = to_xml(cast(BookDict, book_data)) # cast needed + assert xml == XML_SAMPLE + +def test_4() -> None: + xml = to_xml(BookDict( + isbn='0134757599', + title='Refactoring, 2e', + authors=['Martin Fowler', 'Kent Beck'], + pagecount=478)) + assert xml == XML_SAMPLE + +def test_7() -> None: + book_data = BookDict( + isbn='0134757599', + title='Refactoring, 2e', + authors=['Martin Fowler', 'Kent Beck'], + pagecount=478 + ) + xml = to_xml(book_data) + assert xml == XML_SAMPLE + +def test_8() -> None: + book_data: BookDict = { + 'isbn': '0134757599', + 'title': 'Refactoring, 2e', + 'authors': ['Martin Fowler', 'Kent Beck'], + 'pagecount': 478, + } + xml = to_xml(book_data) + assert xml == XML_SAMPLE + +BOOK_JSON = """ + {"isbn": "0134757599", + "title": "Refactoring, 2e", + "authors": ["Martin Fowler", "Kent Beck"], + "pagecount": 478} +""" + +def test_load_book_0() -> None: + book_data: BookDict = json.loads(BOOK_JSON) # typed var + xml = to_xml(book_data) + assert xml == XML_SAMPLE + +def test_load_book() -> None: + book_data = from_json(BOOK_JSON) + xml = to_xml(book_data) + assert xml == XML_SAMPLE + + +NOT_BOOK_JSON = """ + {"isbn": 3.141592653589793 + "title": [1, 2, 3], + "authors": ["Martin Fowler", "Kent Beck"], + "flavor": "strawberry"} +""" + +def test_load_not_book() -> None: + book_data: BookDict = json.loads(BOOK_JSON) # typed var + xml = to_xml(book_data) + assert xml == XML_SAMPLE diff --git a/08-def-type-hints/typeddict/test_books_check_fails.py b/08-def-type-hints/typeddict/test_books_check_fails.py new file mode 100644 index 0000000..45cbe83 --- /dev/null +++ b/08-def-type-hints/typeddict/test_books_check_fails.py @@ -0,0 +1,23 @@ +import json +from typing import cast + +from books import BookDict, to_xml + +XML_SAMPLE = """ + +\t0134757599 +\tRefactoring, 2e +\tMartin Fowler +\tKent Beck +\t478 + +""".strip() + +def test_3() -> None: + xml = to_xml(BookDict(dict([ # Expected keyword arguments, {...}, or dict(...) in TypedDict constructor + ('isbn', '0134757599'), + ('title', 'Refactoring, 2e'), + ('authors', ['Martin Fowler', 'Kent Beck']), + ('pagecount', 478), + ]))) + assert xml == XML_SAMPLE diff --git a/09-closure-deco/README.rst b/09-closure-deco/README.rst new file mode 100644 index 0000000..f80f315 --- /dev/null +++ b/09-closure-deco/README.rst @@ -0,0 +1,4 @@ +Sample code for Chapter 8 - "Closures and decorators" + +From the book "Fluent Python, Second Edition" by Luciano Ramalho (O'Reilly, 2020) +http://shop.oreilly.com/product/0636920273196.do \ No newline at end of file diff --git a/09-closure-deco/average.py b/09-closure-deco/average.py new file mode 100644 index 0000000..5ac9855 --- /dev/null +++ b/09-closure-deco/average.py @@ -0,0 +1,33 @@ +""" +>>> avg = make_averager() +>>> avg(10) +10.0 +>>> avg(11) +10.5 +>>> avg(12) +11.0 +>>> avg.__code__.co_varnames +('new_value', 'total') +>>> avg.__code__.co_freevars +('series',) +>>> avg.__closure__ # doctest: +ELLIPSIS +(,) +>>> avg.__closure__[0].cell_contents +[10, 11, 12] +""" + +DEMO = """ +>>> avg.__closure__ +(,) +""" + + +def make_averager(): + series = [] + + def averager(new_value): + series.append(new_value) + total = sum(series) + return total/len(series) + + return averager diff --git a/09-closure-deco/average_oo.py b/09-closure-deco/average_oo.py new file mode 100644 index 0000000..154f055 --- /dev/null +++ b/09-closure-deco/average_oo.py @@ -0,0 +1,21 @@ +""" +>>> avg = Averager() +>>> avg(10) +10.0 +>>> avg(11) +10.5 +>>> avg(12) +11.0 + +""" + + +class Averager(): + + def __init__(self): + self.series = [] + + def __call__(self, new_value): + self.series.append(new_value) + total = sum(self.series) + return total/len(self.series) diff --git a/09-closure-deco/clockdeco.py b/09-closure-deco/clockdeco.py new file mode 100644 index 0000000..e45470d --- /dev/null +++ b/09-closure-deco/clockdeco.py @@ -0,0 +1,21 @@ +import time +import functools + + +def clock(func): + @functools.wraps(func) + def clocked(*args, **kwargs): + t0 = time.perf_counter() + result = func(*args, **kwargs) + elapsed = time.perf_counter() - t0 + name = func.__name__ + arg_lst = [] + if args: + arg_lst.append(', '.join(repr(arg) for arg in args)) + if kwargs: + pairs = [f'{k}={v!r}' for k, v in kwargs.items()] + arg_lst.append(', '.join(pairs)) + arg_str = ', '.join(arg_lst) + print(f'[{elapsed:0.8f}s] {name}({arg_str}) -> {result!r}') + return result + return clocked diff --git a/09-closure-deco/clockdeco0.py b/09-closure-deco/clockdeco0.py new file mode 100644 index 0000000..4d60f4e --- /dev/null +++ b/09-closure-deco/clockdeco0.py @@ -0,0 +1,13 @@ +import time + + +def clock(func): + def clocked(*args): # <1> + t0 = time.perf_counter() + result = func(*args) # <2> + elapsed = time.perf_counter() - t0 + name = func.__name__ + arg_str = ', '.join(repr(arg) for arg in args) + print(f'[{elapsed:0.8f}s] {name}({arg_str}) -> {result!r}') + return result + return clocked # <3> diff --git a/09-closure-deco/clockdeco_cls.py b/09-closure-deco/clockdeco_cls.py new file mode 100644 index 0000000..edb1ac4 --- /dev/null +++ b/09-closure-deco/clockdeco_cls.py @@ -0,0 +1,44 @@ +# clockdeco_class.py + +""" +>>> snooze(.1) # doctest: +ELLIPSIS +[0.101...s] snooze(0.1) -> None +>>> clock('{name}: {elapsed}')(time.sleep)(.2) # doctest: +ELLIPSIS +sleep: 0.20... +>>> clock('{name}({args}) dt={elapsed:0.3f}s')(time.sleep)(.2) +sleep(0.2) dt=0.201s +""" + +# tag::CLOCKDECO_CLS[] +import time + +DEFAULT_FMT = '[{elapsed:0.8f}s] {name}({args}) -> {result}' + +class clock: # <1> + + def __init__(self, fmt=DEFAULT_FMT): # <2> + self.fmt = fmt + + def __call__(self, func): # <3> + def clocked(*_args): + t0 = time.perf_counter() + _result = func(*_args) # <4> + elapsed = time.perf_counter() - t0 + name = func.__name__ + args = ', '.join(repr(arg) for arg in _args) + result = repr(_result) + print(self.fmt.format(**locals())) + return _result + return clocked +# end::CLOCKDECO_CLS[] + +if __name__ == '__main__': + + @clock() + def snooze(seconds): + time.sleep(seconds) + + for i in range(3): + snooze(.123) + + diff --git a/09-closure-deco/clockdeco_demo.py b/09-closure-deco/clockdeco_demo.py new file mode 100644 index 0000000..121b52f --- /dev/null +++ b/09-closure-deco/clockdeco_demo.py @@ -0,0 +1,19 @@ +import time +from clockdeco import clock + + +@clock +def snooze(seconds): + time.sleep(seconds) + + +@clock +def factorial(n): + return 1 if n < 2 else n*factorial(n-1) + + +if __name__ == '__main__': + print('*' * 40, 'Calling snooze(.123)') + snooze(.123) + print('*' * 40, 'Calling factorial(6)') + print('6! =', factorial(6)) diff --git a/09-closure-deco/clockdeco_param.py b/09-closure-deco/clockdeco_param.py new file mode 100644 index 0000000..2dbb571 --- /dev/null +++ b/09-closure-deco/clockdeco_param.py @@ -0,0 +1,40 @@ +# clockdeco_param.py + +""" +>>> snooze(.1) # doctest: +ELLIPSIS +[0.101...s] snooze(0.1) -> None +>>> clock('{name}: {elapsed}')(time.sleep)(.2) # doctest: +ELLIPSIS +sleep: 0.20... +>>> clock('{name}({args}) dt={elapsed:0.3f}s')(time.sleep)(.2) +sleep(0.2) dt=0.201s +""" + +# tag::CLOCKDECO_PARAM[] +import time + +DEFAULT_FMT = '[{elapsed:0.8f}s] {name}({args}) -> {result}' + +def clock(fmt=DEFAULT_FMT): # <1> + def decorate(func): # <2> + def clocked(*_args): # <3> + t0 = time.perf_counter() + _result = func(*_args) # <4> + elapsed = time.perf_counter() - t0 + name = func.__name__ + args = ', '.join(repr(arg) for arg in _args) # <5> + result = repr(_result) # <6> + print(fmt.format(**locals())) # <7> + return _result # <8> + return clocked # <9> + return decorate # <10> + +if __name__ == '__main__': + + @clock() # <11> + def snooze(seconds): + time.sleep(seconds) + + for i in range(3): + snooze(.123) + +# end::CLOCKDECO_PARAM[] diff --git a/09-closure-deco/clockdeco_param_demo1.py b/09-closure-deco/clockdeco_param_demo1.py new file mode 100644 index 0000000..053d275 --- /dev/null +++ b/09-closure-deco/clockdeco_param_demo1.py @@ -0,0 +1,9 @@ +import time +from clockdeco_param import clock + +@clock('{name}: {elapsed}s') +def snooze(seconds): + time.sleep(seconds) + +for i in range(3): + snooze(.123) diff --git a/09-closure-deco/clockdeco_param_demo2.py b/09-closure-deco/clockdeco_param_demo2.py new file mode 100644 index 0000000..df394f9 --- /dev/null +++ b/09-closure-deco/clockdeco_param_demo2.py @@ -0,0 +1,9 @@ +import time +from clockdeco_param import clock + +@clock('{name}({args}) dt={elapsed:0.3f}s') +def snooze(seconds): + time.sleep(seconds) + +for i in range(3): + snooze(.123) diff --git a/09-closure-deco/fibo_compare.py b/09-closure-deco/fibo_compare.py new file mode 100644 index 0000000..33f0e1d --- /dev/null +++ b/09-closure-deco/fibo_compare.py @@ -0,0 +1,17 @@ +from clockdeco import clock +import fibo_demo +import fibo_demo_lru + + +@clock +def demo1(): + fibo_demo.fibonacci(30) + + +@clock +def demo2(): + fibo_demo_lru.fibonacci(30) + + +demo1() +demo2() diff --git a/09-closure-deco/fibo_demo.py b/09-closure-deco/fibo_demo.py new file mode 100644 index 0000000..8b76796 --- /dev/null +++ b/09-closure-deco/fibo_demo.py @@ -0,0 +1,12 @@ +from clockdeco import clock + + +@clock +def fibonacci(n): + if n < 2: + return n + return fibonacci(n-2) + fibonacci(n-1) + + +if __name__ == '__main__': + print(fibonacci(6)) diff --git a/09-closure-deco/fibo_demo_lru.py b/09-closure-deco/fibo_demo_lru.py new file mode 100644 index 0000000..1a0cf11 --- /dev/null +++ b/09-closure-deco/fibo_demo_lru.py @@ -0,0 +1,15 @@ +import functools + +from clockdeco import clock + + +@functools.lru_cache # <1> +@clock # <2> +def fibonacci(n): + if n < 2: + return n + return fibonacci(n-2) + fibonacci(n-1) + + +if __name__ == '__main__': + print(fibonacci(6)) diff --git a/09-closure-deco/global_x_local.rst b/09-closure-deco/global_x_local.rst new file mode 100644 index 0000000..ef4d0e1 --- /dev/null +++ b/09-closure-deco/global_x_local.rst @@ -0,0 +1,124 @@ +>>> def f1(a): +... print(a) +... print(b) +... +>>> f1(3) +3 +Traceback (most recent call last): + File "", line 1, in + File "", line 3, in f1 +NameError: name 'b' is not defined +>>> b = 6 +>>> f1(3) +3 +6 + +>>> def f2(a): +... print(a) +... print(b) +... b = 9 +... +>>> f2(3) +3 +Traceback (most recent call last): + File "", line 1, in + File "", line 3, in f2 +UnboundLocalError: local variable 'b' referenced before assignment + + +# tag::F1_DIS[] +>>> from dis import dis +>>> dis(f1) + 2 0 LOAD_GLOBAL 0 (print) <1> + 3 LOAD_FAST 0 (a) <2> + 6 CALL_FUNCTION 1 (1 positional, 0 keyword pair) + 9 POP_TOP + + 3 10 LOAD_GLOBAL 0 (print) + 13 LOAD_GLOBAL 1 (b) <3> + 16 CALL_FUNCTION 1 (1 positional, 0 keyword pair) + 19 POP_TOP + 20 LOAD_CONST 0 (None) + 23 RETURN_VALUE +# end::F1_DIS[] +# tag::F2_DIS[] +>>> dis(f2) + 2 0 LOAD_GLOBAL 0 (print) + 3 LOAD_FAST 0 (a) + 6 CALL_FUNCTION 1 (1 positional, 0 keyword pair) + 9 POP_TOP + + 3 10 LOAD_GLOBAL 0 (print) + 13 LOAD_FAST 1 (b) <1> + 16 CALL_FUNCTION 1 (1 positional, 0 keyword pair) + 19 POP_TOP + + 4 20 LOAD_CONST 1 (9) + 23 STORE_FAST 1 (b) + 26 LOAD_CONST 0 (None) + 29 RETURN_VALUE +# end::F2_DIS[] +>>> def f3(a): +... global b +... print(a) +... print(b) +... b = 9 +... +>>> f3(3) +3 +6 +>>> b +9 +# tag::F3_DIS[] +>>> dis(f3) + 3 0 LOAD_GLOBAL 0 (print) + 3 LOAD_FAST 0 (a) + 6 CALL_FUNCTION 1 (1 positional, 0 keyword pair) + 9 POP_TOP + + 4 10 LOAD_GLOBAL 0 (print) + 13 LOAD_GLOBAL 1 (b) + 16 CALL_FUNCTION 1 (1 positional, 0 keyword pair) + 19 POP_TOP + + 5 20 LOAD_CONST 1 (9) + 23 STORE_GLOBAL 1 (b) + 26 LOAD_CONST 0 (None) + 29 RETURN_VALUE +# end::F3_DIS[] + +>>> def f4(b): +... def f5(a): +... nonlocal b +... print(a) +... print(b) +... b = 7 +... return f5 +... +>>> f5 = f4(8) +>>> f5(2) +2 +8 +>>> b +9 +>>> f5(3) +3 +7???? + +>>> dis(f5) + 4 0 LOAD_GLOBAL 0 (print) + 3 LOAD_FAST 0 (a) + 6 CALL_FUNCTION 1 (1 positional, 0 keyword pair) + 9 POP_TOP + + 5 10 LOAD_GLOBAL 0 (print) + 13 LOAD_DEREF 0 (b) + 16 CALL_FUNCTION 1 (1 positional, 0 keyword pair) + 19 POP_TOP + + 6 20 LOAD_CONST 1 (7) + 23 STORE_DEREF 0 (b) + 26 LOAD_CONST 0 (None) + 29 RETURN_VALUE + + diff --git a/09-closure-deco/htmlizer.py b/09-closure-deco/htmlizer.py new file mode 100644 index 0000000..cfd92b6 --- /dev/null +++ b/09-closure-deco/htmlizer.py @@ -0,0 +1,76 @@ +r""" +htmlize(): generic function example + +# tag::HTMLIZE_DEMO[] + +>>> htmlize({1, 2, 3}) # <1> +'
{1, 2, 3}
' +>>> htmlize(abs) +'
<built-in function abs>
' +>>> htmlize('Heimlich & Co.\n- a game') # <2> +'

Heimlich & Co.
\n- a game

' +>>> htmlize(42) # <3> +'
42 (0x2a)
' +>>> print(htmlize(['alpha', 66, {3, 2, 1}])) # <4> +
    +
  • alpha

  • +
  • 66 (0x42)
  • +
  • {1, 2, 3}
  • +
+>>> htmlize(True) # <5> +'
True
' +>>> htmlize(fractions.Fraction(2, 3)) # <6> +'
2/3
' +>>> htmlize(2/3) # <7> +'
0.6666666666666666 (2/3)
' +>>> htmlize(decimal.Decimal('0.02380952')) +'
0.02380952 (1/42)
' + +# end::HTMLIZE_DEMO[] +""" + +# tag::HTMLIZE[] + +from functools import singledispatch +from collections import abc +import fractions +import decimal +import html +import numbers + +@singledispatch # <1> +def htmlize(obj: object) -> str: + content = html.escape(repr(obj)) + return f'
{content}
' + +@htmlize.register # <2> +def _(text: str) -> str: # <3> + content = html.escape(text).replace('\n', '
\n') + return f'

{content}

' + +@htmlize.register # <4> +def _(seq: abc.Sequence) -> str: + inner = '\n
  • '.join(htmlize(item) for item in seq) + return '
      \n
    • ' + inner + '
    • \n
    ' + +@htmlize.register # <5> +def _(n: numbers.Integral) -> str: + return f'
    {n} (0x{n:x})
    ' + +@htmlize.register # <6> +def _(n: bool) -> str: + return f'
    {n}
    ' + +@htmlize.register(fractions.Fraction) # <7> +def _(x) -> str: + frac = fractions.Fraction(x) + return f'
    {frac.numerator}/{frac.denominator}
    ' + +@htmlize.register(decimal.Decimal) # <8> +@htmlize.register(float) +def _(x) -> str: + frac = fractions.Fraction(x).limit_denominator() + return f'
    {x} ({frac.numerator}/{frac.denominator})
    ' + +# end::HTMLIZE[] + diff --git a/09-closure-deco/registration.py b/09-closure-deco/registration.py new file mode 100644 index 0000000..855b6da --- /dev/null +++ b/09-closure-deco/registration.py @@ -0,0 +1,31 @@ +# tag::REGISTRATION[] + +registry = [] # <1> + +def register(func): # <2> + print(f'running register({func})') # <3> + registry.append(func) # <4> + return func # <5> + +@register # <6> +def f1(): + print('running f1()') + +@register +def f2(): + print('running f2()') + +def f3(): # <7> + print('running f3()') + +def main(): # <8> + print('running main()') + print('registry ->', registry) + f1() + f2() + f3() + +if __name__=='__main__': + main() # <9> + +# end::REGISTRATION[] \ No newline at end of file diff --git a/09-closure-deco/registration_abridged.py b/09-closure-deco/registration_abridged.py new file mode 100644 index 0000000..85db1af --- /dev/null +++ b/09-closure-deco/registration_abridged.py @@ -0,0 +1,16 @@ +# tag::REGISTRATION_ABRIDGED[] +registry = [] + +def register(func): + print(f'running register({func})') + registry.append(func) + return func + +@register +def f1(): + print('running f1()') + +print('running main()') +print('registry ->', registry) +f1() +# end::REGISTRATION_ABRIDGED[] diff --git a/09-closure-deco/registration_param.py b/09-closure-deco/registration_param.py new file mode 100644 index 0000000..e6bae5b --- /dev/null +++ b/09-closure-deco/registration_param.py @@ -0,0 +1,28 @@ +# tag::REGISTRATION_PARAM[] + +registry = set() # <1> + +def register(active=True): # <2> + def decorate(func): # <3> + print('running register' + f'(active={active})->decorate({func})') + if active: # <4> + registry.add(func) + else: + registry.discard(func) # <5> + + return func # <6> + return decorate # <7> + +@register(active=False) # <8> +def f1(): + print('running f1()') + +@register() # <9> +def f2(): + print('running f2()') + +def f3(): + print('running f3()') + +# end::REGISTRATION_PARAM[] diff --git a/09-closure-deco/stacked.py b/09-closure-deco/stacked.py new file mode 100644 index 0000000..4c94375 --- /dev/null +++ b/09-closure-deco/stacked.py @@ -0,0 +1,36 @@ +def first(f): + print(f'apply first({f.__name__})') + + def inner1st(n): + result = f(n) + print(f'inner1({n}): called {f.__name__}({n}) -> {result}') + return result + return inner1st + + +def second(f): + print(f'apply second({f.__name__})') + + def inner2nd(n): + result = f(n) + print(f'inner2({n}): called {f.__name__}({n}) -> {result}') + return result + return inner2nd + + +@first +@second +def double(n): + return n * 2 + + +print(double(3)) + + +def double_(n): + return n * 2 + + +double_ = first(second(double_)) + +print(double_(3)) diff --git a/10-dp-1class-func/README.rst b/10-dp-1class-func/README.rst new file mode 100644 index 0000000..16d4293 --- /dev/null +++ b/10-dp-1class-func/README.rst @@ -0,0 +1,77 @@ +Sample code for Chapter 10 - "Design patterns with first class functions" + +From the book "Fluent Python" by Luciano Ramalho (O'Reilly, 2015) +http://shop.oreilly.com/product/0636920032519.do + +Notes +===== + +No issues on file with zero type hints +-------------------------------------- + +Running Mypy on ``classic_strategy.py`` from the first edition, with no +type hints:: + + $ mypy classic_strategy.py + Success: no issues found in 1 source file + + +Type inference at play +---------------------- + +When the ``Order.due`` method made first assignment to discount as ``discount = 0``, +Mypy complained:: + + mypy classic_strategy.py + classic_strategy.py:68: error: Incompatible types in assignment (expression has type "float", variable has type "int") + Found 1 error in 1 file (checked 1 source file) + +To fix it, I made the first assigment as ``discount = 0``. +I never explicitly declared a type for ``discount``. + + +Mypy ignores functions with no annotations +------------------------------------------ + +Mypy did not raise any issues with this test case:: + + + def test_bulk_item_promo_with_discount(customer_fidelity_0): + cart = [LineItem('banana', 30, .5), + LineItem('apple', 10, 1.5)] + order = Order(customer_fidelity_0, 10, BulkItemPromo()) + assert order.total() == 30.0 + assert order.due() == 28.5 + + +The second argument to ``Order`` is declared as ``Sequence[LineItem]``. +Mypy only checks the body of a function the signature as at least one annotation, +like this:: + + def test_bulk_item_promo_with_discount(customer_fidelity_0) -> None: + cart = [LineItem('banana', 30, .5), + LineItem('apple', 10, 1.5)] + order = Order(customer_fidelity_0, 10, BulkItemPromo()) + assert order.total() == 30.0 + assert order.due() == 28.5 + + +Now Mypy complains that "Argument 2 of Order has incompatible type". + +However, even with the annotation in the test function signature, +Mypy did not find any problem when I mistyped the name of the ``cart`` argument. +Here, ``cart_plain`` should be ``cart``:: + + + def test_bulk_item_promo_with_discount(customer_fidelity_0) -> None: + cart = [LineItem('banana', 30, .5), + LineItem('apple', 10, 1.5)] + order = Order(customer_fidelity_0, cart_plain, BulkItemPromo()) + assert order.total() == 30.0 + assert order.due() == 28.5 + + +Hypotesis: ``cart_plain`` is a function decorated with ``@pytest.fixture``, +and at the top of the test file I told Mypy to ignore the Pytest import:: + + import pytest # type: ignore diff --git a/10-dp-1class-func/classic_strategy.py b/10-dp-1class-func/classic_strategy.py new file mode 100644 index 0000000..c2ffe34 --- /dev/null +++ b/10-dp-1class-func/classic_strategy.py @@ -0,0 +1,113 @@ +# classic_strategy.py +# Strategy pattern -- classic implementation + +""" +# tag::CLASSIC_STRATEGY_TESTS[] + + >>> joe = Customer('John Doe', 0) # <1> + >>> ann = Customer('Ann Smith', 1100) + >>> cart = [LineItem('banana', 4, .5), # <2> + ... LineItem('apple', 10, 1.5), + ... LineItem('watermellon', 5, 5.0)] + >>> Order(joe, cart, FidelityPromo()) # <3> + + >>> Order(ann, cart, FidelityPromo()) # <4> + + >>> banana_cart = [LineItem('banana', 30, .5), # <5> + ... LineItem('apple', 10, 1.5)] + >>> Order(joe, banana_cart, BulkItemPromo()) # <6> + + >>> big_cart = [LineItem(str(item_code), 1, 1.0) # <7> + ... for item_code in range(10)] + >>> Order(joe, big_cart, LargeOrderPromo()) # <8> + + >>> Order(joe, cart, LargeOrderPromo()) + + +# end::CLASSIC_STRATEGY_TESTS[] +""" +# tag::CLASSIC_STRATEGY[] + +from abc import ABC, abstractmethod +import typing +from typing import Sequence, Optional + + +class Customer(typing.NamedTuple): + name: str + fidelity: int + + +class LineItem: + def __init__(self, product: str, quantity: int, price: float): + self.product = product + self.quantity = quantity + self.price = price + + def total(self): + return self.price * self.quantity + + +class Order: # the Context + def __init__( + self, + customer: Customer, + cart: Sequence[LineItem], + promotion: Optional['Promotion'] = None, + ): + self.customer = customer + self.cart = list(cart) + self.promotion = promotion + + def total(self) -> float: + if not hasattr(self, '__total'): + self.__total = sum(item.total() for item in self.cart) + return self.__total + + def due(self) -> float: + if self.promotion is None: + discount = 0.0 + else: + discount = self.promotion.discount(self) + return self.total() - discount + + def __repr__(self): + fmt = '' + return fmt.format(self.total(), self.due()) + + +class Promotion(ABC): # the Strategy: an abstract base class + @abstractmethod + def discount(self, order: Order) -> float: + """Return discount as a positive dollar amount""" + + +class FidelityPromo(Promotion): # first Concrete Strategy + """5% discount for customers with 1000 or more fidelity points""" + + def discount(self, order: Order) -> float: + return order.total() * 0.05 if order.customer.fidelity >= 1000 else 0 + + +class BulkItemPromo(Promotion): # second Concrete Strategy + """10% discount for each LineItem with 20 or more units""" + + def discount(self, order: Order) -> float: + discount = 0 + for item in order.cart: + if item.quantity >= 20: + discount += item.total() * 0.1 + return discount + + +class LargeOrderPromo(Promotion): # third Concrete Strategy + """7% discount for orders with 10 or more distinct items""" + + def discount(self, order: Order) -> float: + distinct_items = {item.product for item in order.cart} + if len(distinct_items) >= 10: + return order.total() * 0.07 + return 0 + + +# end::CLASSIC_STRATEGY[] diff --git a/10-dp-1class-func/classic_strategy_test.py b/10-dp-1class-func/classic_strategy_test.py new file mode 100644 index 0000000..94999ec --- /dev/null +++ b/10-dp-1class-func/classic_strategy_test.py @@ -0,0 +1,63 @@ +from typing import List + +import pytest # type: ignore + +from classic_strategy import Customer, LineItem, Order +from classic_strategy import FidelityPromo, BulkItemPromo, LargeOrderPromo + + +@pytest.fixture +def customer_fidelity_0() -> Customer: + return Customer('John Doe', 0) + + +@pytest.fixture +def customer_fidelity_1100() -> Customer: + return Customer('Ann Smith', 1100) + + +@pytest.fixture +def cart_plain() -> List[LineItem]: + return [ + LineItem('banana', 4, 0.5), + LineItem('apple', 10, 1.5), + LineItem('watermellon', 5, 5.0), + ] + + +def test_fidelity_promo_no_discount(customer_fidelity_0, cart_plain) -> None: + order = Order(customer_fidelity_0, cart_plain, FidelityPromo()) + assert order.total() == 42.0 + assert order.due() == 42.0 + + +def test_fidelity_promo_with_discount(customer_fidelity_1100, cart_plain) -> None: + order = Order(customer_fidelity_1100, cart_plain, FidelityPromo()) + assert order.total() == 42.0 + assert order.due() == 39.9 + + +def test_bulk_item_promo_no_discount(customer_fidelity_0, cart_plain) -> None: + order = Order(customer_fidelity_0, cart_plain, BulkItemPromo()) + assert order.total() == 42.0 + assert order.due() == 42.0 + + +def test_bulk_item_promo_with_discount(customer_fidelity_0) -> None: + cart = [LineItem('banana', 30, 0.5), LineItem('apple', 10, 1.5)] + order = Order(customer_fidelity_0, cart, BulkItemPromo()) + assert order.total() == 30.0 + assert order.due() == 28.5 + + +def test_large_order_promo_no_discount(customer_fidelity_0, cart_plain) -> None: + order = Order(customer_fidelity_0, cart_plain, LargeOrderPromo()) + assert order.total() == 42.0 + assert order.due() == 42.0 + + +def test_large_order_promo_with_discount(customer_fidelity_0) -> None: + cart = [LineItem(str(item_code), 1, 1.0) for item_code in range(10)] + order = Order(customer_fidelity_0, cart, LargeOrderPromo()) + assert order.total() == 10.0 + assert order.due() == 9.3 diff --git a/10-dp-1class-func/monkeytype/classic_strategy.py b/10-dp-1class-func/monkeytype/classic_strategy.py new file mode 100644 index 0000000..06bfedd --- /dev/null +++ b/10-dp-1class-func/monkeytype/classic_strategy.py @@ -0,0 +1,110 @@ +# classic_strategy.py +# Strategy pattern -- classic implementation + +""" +# tag::CLASSIC_STRATEGY_TESTS[] + + >>> joe = Customer('John Doe', 0) # <1> + >>> ann = Customer('Ann Smith', 1100) + >>> cart = [LineItem('banana', 4, .5), # <2> + ... LineItem('apple', 10, 1.5), + ... LineItem('watermellon', 5, 5.0)] + >>> Order(joe, cart, FidelityPromo()) # <3> + + >>> Order(ann, cart, FidelityPromo()) # <4> + + >>> banana_cart = [LineItem('banana', 30, .5), # <5> + ... LineItem('apple', 10, 1.5)] + >>> Order(joe, banana_cart, BulkItemPromo()) # <6> + + >>> long_order = [LineItem(str(item_code), 1, 1.0) # <7> + ... for item_code in range(10)] + >>> Order(joe, long_order, LargeOrderPromo()) # <8> + + >>> Order(joe, cart, LargeOrderPromo()) + + +# end::CLASSIC_STRATEGY_TESTS[] +""" +# tag::CLASSIC_STRATEGY[] + +from abc import ABC, abstractmethod +from collections import namedtuple +import typing + + +class Customer(typing.NamedTuple): + name: str + fidelity: int + + +class LineItem: + + def __init__(self, product, quantity, price): + self.product = product + self.quantity = quantity + self.price = price + + def total(self): + return self.price * self.quantity + + +class Order: # the Context + + def __init__(self, customer, cart, promotion=None): + self.customer = customer + self.cart = list(cart) + self.promotion = promotion + + def total(self): + if not hasattr(self, '__total'): + self.__total = sum(item.total() for item in self.cart) + return self.__total + + def due(self): + if self.promotion is None: + discount = 0 + else: + discount = self.promotion.discount(self) + return self.total() - discount + + def __repr__(self): + fmt = '' + return fmt.format(self.total(), self.due()) + + +class Promotion(ABC): # the Strategy: an abstract base class + + @abstractmethod + def discount(self, order): + """Return discount as a positive dollar amount""" + + +class FidelityPromo(Promotion): # first Concrete Strategy + """5% discount for customers with 1000 or more fidelity points""" + + def discount(self, order): + return order.total() * .05 if order.customer.fidelity >= 1000 else 0 + + +class BulkItemPromo(Promotion): # second Concrete Strategy + """10% discount for each LineItem with 20 or more units""" + + def discount(self, order): + discount = 0 + for item in order.cart: + if item.quantity >= 20: + discount += item.total() * .1 + return discount + + +class LargeOrderPromo(Promotion): # third Concrete Strategy + """7% discount for orders with 10 or more distinct items""" + + def discount(self, order): + distinct_items = {item.product for item in order.cart} + if len(distinct_items) >= 10: + return order.total() * .07 + return 0 + +# end::CLASSIC_STRATEGY[] diff --git a/10-dp-1class-func/monkeytype/classic_strategy.pyi b/10-dp-1class-func/monkeytype/classic_strategy.pyi new file mode 100644 index 0000000..b81fd31 --- /dev/null +++ b/10-dp-1class-func/monkeytype/classic_strategy.pyi @@ -0,0 +1,33 @@ +from typing import ( + List, + Optional, + Union, +) + + +class BulkItemPromo: + def discount(self, order: Order) -> Union[float, int]: ... + + +class FidelityPromo: + def discount(self, order: Order) -> Union[float, int]: ... + + +class LargeOrderPromo: + def discount(self, order: Order) -> Union[float, int]: ... + + +class LineItem: + def __init__(self, product: str, quantity: int, price: float) -> None: ... + def total(self) -> float: ... + + +class Order: + def __init__( + self, + customer: Customer, + cart: List[LineItem], + promotion: Optional[Union[BulkItemPromo, LargeOrderPromo, FidelityPromo]] = ... + ) -> None: ... + def due(self) -> float: ... + def total(self) -> float: ... diff --git a/10-dp-1class-func/monkeytype/classic_strategy_test.py b/10-dp-1class-func/monkeytype/classic_strategy_test.py new file mode 100644 index 0000000..ede42e3 --- /dev/null +++ b/10-dp-1class-func/monkeytype/classic_strategy_test.py @@ -0,0 +1,63 @@ +from typing import List + +import pytest # type: ignore + +from classic_strategy import Customer, LineItem, Order +from classic_strategy import FidelityPromo, BulkItemPromo, LargeOrderPromo + + +@pytest.fixture +def customer_fidelity_0() -> Customer: + return Customer('John Doe', 0) + + +@pytest.fixture +def customer_fidelity_1100() -> Customer: + return Customer('Ann Smith', 1100) + + +@pytest.fixture +def cart_plain() -> List[LineItem]: + return [LineItem('banana', 4, .5), + LineItem('apple', 10, 1.5), + LineItem('watermellon', 5, 5.0)] + + +def test_fidelity_promo_no_discount(customer_fidelity_0, cart_plain) -> None: + order = Order(customer_fidelity_0, cart_plain, FidelityPromo()) + assert order.total() == 42.0 + assert order.due() == 42.0 + + +def test_fidelity_promo_with_discount(customer_fidelity_1100, cart_plain) -> None: + order = Order(customer_fidelity_1100, cart_plain, FidelityPromo()) + assert order.total() == 42.0 + assert order.due() == 39.9 + + +def test_bulk_item_promo_no_discount(customer_fidelity_0, cart_plain) -> None: + order = Order(customer_fidelity_0, cart_plain, BulkItemPromo()) + assert order.total() == 42.0 + assert order.due() == 42.0 + + +def test_bulk_item_promo_with_discount(customer_fidelity_0) -> None: + cart = [LineItem('banana', 30, .5), + LineItem('apple', 10, 1.5)] + order = Order(customer_fidelity_0, cart, BulkItemPromo()) + assert order.total() == 30.0 + assert order.due() == 28.5 + + +def test_large_order_promo_no_discount(customer_fidelity_0, cart_plain) -> None: + order = Order(customer_fidelity_0, cart_plain, LargeOrderPromo()) + assert order.total() == 42.0 + assert order.due() == 42.0 + + +def test_large_order_promo_with_discount(customer_fidelity_0) -> None: + cart = [LineItem(str(item_code), 1, 1.0) + for item_code in range(10)] + order = Order(customer_fidelity_0, cart, LargeOrderPromo()) + assert order.total() == 10.0 + assert order.due() == 9.3 diff --git a/10-dp-1class-func/monkeytype/run.py b/10-dp-1class-func/monkeytype/run.py new file mode 100644 index 0000000..4fb8591 --- /dev/null +++ b/10-dp-1class-func/monkeytype/run.py @@ -0,0 +1,2 @@ +import pytest +pytest.main(['.']) diff --git a/10-dp-1class-func/promotions.py b/10-dp-1class-func/promotions.py new file mode 100644 index 0000000..0f8a823 --- /dev/null +++ b/10-dp-1class-func/promotions.py @@ -0,0 +1,20 @@ +def fidelity_promo(order): + """5% discount for customers with 1000 or more fidelity points""" + return order.total() * 0.05 if order.customer.fidelity >= 1000 else 0 + + +def bulk_item_promo(order): + """10% discount for each LineItem with 20 or more units""" + discount = 0 + for item in order.cart: + if item.quantity >= 20: + discount += item.total() * 0.1 + return discount + + +def large_order_promo(order): + """7% discount for orders with 10 or more distinct items""" + distinct_items = {item.product for item in order.cart} + if len(distinct_items) >= 10: + return order.total() * 0.07 + return 0 diff --git a/10-dp-1class-func/pytypes/classic_strategy.py b/10-dp-1class-func/pytypes/classic_strategy.py new file mode 100644 index 0000000..68b19c0 --- /dev/null +++ b/10-dp-1class-func/pytypes/classic_strategy.py @@ -0,0 +1,116 @@ +# classic_strategy.py +# Strategy pattern -- classic implementation + +""" +# tag::CLASSIC_STRATEGY_TESTS[] + + >>> joe = Customer('John Doe', 0) # <1> + >>> ann = Customer('Ann Smith', 1100) + >>> cart = [LineItem('banana', 4, .5), # <2> + ... LineItem('apple', 10, 1.5), + ... LineItem('watermellon', 5, 5.0)] + >>> Order(joe, cart, FidelityPromo()) # <3> + + >>> Order(ann, cart, FidelityPromo()) # <4> + + >>> banana_cart = [LineItem('banana', 30, .5), # <5> + ... LineItem('apple', 10, 1.5)] + >>> Order(joe, banana_cart, BulkItemPromo()) # <6> + + >>> long_order = [LineItem(str(item_code), 1, 1.0) # <7> + ... for item_code in range(10)] + >>> Order(joe, long_order, LargeOrderPromo()) # <8> + + >>> Order(joe, cart, LargeOrderPromo()) + + +# end::CLASSIC_STRATEGY_TESTS[] +""" +# tag::CLASSIC_STRATEGY[] + +from abc import ABC, abstractmethod +from collections import namedtuple +import typing + +from pytypes import typelogged + +class Customer(typing.NamedTuple): + name: str + fidelity: int + +@typelogged +class LineItem: + + def __init__(self, product, quantity, price): + self.product = product + self.quantity = quantity + self.price = price + + def total(self): + return self.price * self.quantity + + +@typelogged +class Order: # the Context + + def __init__(self, customer, cart, promotion=None): + self.customer = customer + self.cart = list(cart) + self.promotion = promotion + + def total(self): + if not hasattr(self, '__total'): + self.__total = sum(item.total() for item in self.cart) + return self.__total + + def due(self): + if self.promotion is None: + discount = 0 + else: + discount = self.promotion.discount(self) + return self.total() - discount + + def __repr__(self): + fmt = '' + return fmt.format(self.total(), self.due()) + + +@typelogged +class Promotion(ABC): # the Strategy: an abstract base class + + @abstractmethod + def discount(self, order): + """Return discount as a positive dollar amount""" + + +@typelogged +class FidelityPromo(Promotion): # first Concrete Strategy + """5% discount for customers with 1000 or more fidelity points""" + + def discount(self, order): + return order.total() * .05 if order.customer.fidelity >= 1000 else 0 + + +@typelogged +class BulkItemPromo(Promotion): # second Concrete Strategy + """10% discount for each LineItem with 20 or more units""" + + def discount(self, order): + discount = 0 + for item in order.cart: + if item.quantity >= 20: + discount += item.total() * .1 + return discount + + +@typelogged +class LargeOrderPromo(Promotion): # third Concrete Strategy + """7% discount for orders with 10 or more distinct items""" + + def discount(self, order): + distinct_items = {item.product for item in order.cart} + if len(distinct_items) >= 10: + return order.total() * .07 + return 0 + +# end::CLASSIC_STRATEGY[] diff --git a/10-dp-1class-func/pytypes/classic_strategy_test.py b/10-dp-1class-func/pytypes/classic_strategy_test.py new file mode 100644 index 0000000..ede42e3 --- /dev/null +++ b/10-dp-1class-func/pytypes/classic_strategy_test.py @@ -0,0 +1,63 @@ +from typing import List + +import pytest # type: ignore + +from classic_strategy import Customer, LineItem, Order +from classic_strategy import FidelityPromo, BulkItemPromo, LargeOrderPromo + + +@pytest.fixture +def customer_fidelity_0() -> Customer: + return Customer('John Doe', 0) + + +@pytest.fixture +def customer_fidelity_1100() -> Customer: + return Customer('Ann Smith', 1100) + + +@pytest.fixture +def cart_plain() -> List[LineItem]: + return [LineItem('banana', 4, .5), + LineItem('apple', 10, 1.5), + LineItem('watermellon', 5, 5.0)] + + +def test_fidelity_promo_no_discount(customer_fidelity_0, cart_plain) -> None: + order = Order(customer_fidelity_0, cart_plain, FidelityPromo()) + assert order.total() == 42.0 + assert order.due() == 42.0 + + +def test_fidelity_promo_with_discount(customer_fidelity_1100, cart_plain) -> None: + order = Order(customer_fidelity_1100, cart_plain, FidelityPromo()) + assert order.total() == 42.0 + assert order.due() == 39.9 + + +def test_bulk_item_promo_no_discount(customer_fidelity_0, cart_plain) -> None: + order = Order(customer_fidelity_0, cart_plain, BulkItemPromo()) + assert order.total() == 42.0 + assert order.due() == 42.0 + + +def test_bulk_item_promo_with_discount(customer_fidelity_0) -> None: + cart = [LineItem('banana', 30, .5), + LineItem('apple', 10, 1.5)] + order = Order(customer_fidelity_0, cart, BulkItemPromo()) + assert order.total() == 30.0 + assert order.due() == 28.5 + + +def test_large_order_promo_no_discount(customer_fidelity_0, cart_plain) -> None: + order = Order(customer_fidelity_0, cart_plain, LargeOrderPromo()) + assert order.total() == 42.0 + assert order.due() == 42.0 + + +def test_large_order_promo_with_discount(customer_fidelity_0) -> None: + cart = [LineItem(str(item_code), 1, 1.0) + for item_code in range(10)] + order = Order(customer_fidelity_0, cart, LargeOrderPromo()) + assert order.total() == 10.0 + assert order.due() == 9.3 diff --git a/10-dp-1class-func/pytypes/typelogger_output/classic_strategy.pyi b/10-dp-1class-func/pytypes/typelogger_output/classic_strategy.pyi new file mode 100644 index 0000000..8d6ab78 --- /dev/null +++ b/10-dp-1class-func/pytypes/typelogger_output/classic_strategy.pyi @@ -0,0 +1,48 @@ +""" +Automatically generated stubfile of + +/home/luciano/flupy/priv/2e-atlas/code/10-dp-1class-func/pytypes/classic_strategy.py +MD5-Checksum: a02fa3b98639f84a81b87d4f46007d51 + +This file was generated by pytypes.typelogger v1.0b5 +at 2020-05-02T15:50:59.987984. + +Type information is based on runtime observations while running +CPython 3.8.2 final 0 +/home/luciano/flupy/venv-3.8/bin/python3 + /home/luciano/flupy/venv-3.8/bin/pytest + +WARNING: +If you edit this file, be aware that it was automatically generated. +Save your customized version to a distinct place; +this file might be overwritten without notice. +""" + +from classic_strategy import Customer, Promotion +from typing import Union + + + +class LineItem(object): + + def __init__(self, product: str, quantity: int, price: float) -> None: ... + def total(self) -> float: ... + +class Order(object): + + def __init__(self, customer: Customer, cart: List[LineItem], promotion: Union[BulkItemPromo, FidelityPromo, LargeOrderPromo]) -> None: ... + def total(self) -> float: ... + def due(self) -> float: ... + +class FidelityPromo(Promotion): + + def discount(self, order: Order) -> float: ... + +class BulkItemPromo(Promotion): + + def discount(self, order: Order) -> float: ... + +class LargeOrderPromo(Promotion): + + def discount(self, order: Order) -> float: ... + diff --git a/10-dp-1class-func/requirements.txt b/10-dp-1class-func/requirements.txt new file mode 100644 index 0000000..2b20c05 --- /dev/null +++ b/10-dp-1class-func/requirements.txt @@ -0,0 +1,13 @@ +mypy==0.770 +mypy-extensions==0.4.3 +typed-ast==1.4.1 +typing-extensions==3.7.4.1 +attrs==19.3.0 +more-itertools==8.2.0 +packaging==20.3 +pluggy==0.13.1 +py==1.8.1 +pyparsing==2.4.6 +pytest==5.4.1 +six==1.14.0 +wcwidth==0.1.9 diff --git a/10-dp-1class-func/strategy.py b/10-dp-1class-func/strategy.py new file mode 100644 index 0000000..6e464c7 --- /dev/null +++ b/10-dp-1class-func/strategy.py @@ -0,0 +1,103 @@ +# strategy.py +# Strategy pattern -- function-based implementation + +""" +# tag::STRATEGY_TESTS[] + + >>> joe = Customer('John Doe', 0) # <1> + >>> ann = Customer('Ann Smith', 1100) + >>> cart = [LineItem('banana', 4, .5), + ... LineItem('apple', 10, 1.5), + ... LineItem('watermellon', 5, 5.0)] + >>> Order(joe, cart, fidelity_promo) # <2> + + >>> Order(ann, cart, fidelity_promo) + + >>> banana_cart = [LineItem('banana', 30, .5), + ... LineItem('apple', 10, 1.5)] + >>> Order(joe, banana_cart, bulk_item_promo) # <3> + + >>> big_cart = [LineItem(str(item_code), 1, 1.0) + ... for item_code in range(10)] + >>> Order(joe, big_cart, large_order_promo) + + >>> Order(joe, cart, large_order_promo) + + +# end::STRATEGY_TESTS[] +""" +# tag::STRATEGY[] + +import typing +from typing import Sequence, Optional, Callable + + +class Customer(typing.NamedTuple): + name: str + fidelity: int + + +class LineItem: + def __init__(self, product: str, quantity: int, price: float): + self.product = product + self.quantity = quantity + self.price = price + + def total(self): + return self.price * self.quantity + + +class Order: # the Context + def __init__( + self, + customer: Customer, + cart: Sequence[LineItem], + promotion: Optional[Callable[['Order'], float]] = None, + ) -> None: + self.customer = customer + self.cart = list(cart) + self.promotion = promotion + + def total(self) -> float: + if not hasattr(self, '__total'): + self.__total = sum(item.total() for item in self.cart) + return self.__total + + def due(self) -> float: + if self.promotion is None: + discount = 0.0 + else: + discount = self.promotion(self) # <1> + return self.total() - discount + + def __repr__(self): + fmt = '' + return fmt.format(self.total(), self.due()) + + +# <2> + + +def fidelity_promo(order: Order) -> float: # <3> + """5% discount for customers with 1000 or more fidelity points""" + return order.total() * 0.05 if order.customer.fidelity >= 1000 else 0 + + +def bulk_item_promo(order: Order): + """10% discount for each LineItem with 20 or more units""" + discount = 0 + for item in order.cart: + if item.quantity >= 20: + discount += item.total() * 0.1 + return discount + + +def large_order_promo(order: Order): + """7% discount for orders with 10 or more distinct items""" + distinct_items = {item.product for item in order.cart} + if len(distinct_items) >= 10: + return order.total() * 0.07 + return 0 + + +# end::STRATEGY[] diff --git a/10-dp-1class-func/strategy_best.py b/10-dp-1class-func/strategy_best.py new file mode 100644 index 0000000..4054175 --- /dev/null +++ b/10-dp-1class-func/strategy_best.py @@ -0,0 +1,42 @@ +# strategy_best.py +# Strategy pattern -- function-based implementation +# selecting best promotion from static list of functions + +""" + >>> joe = Customer('John Doe', 0) + >>> ann = Customer('Ann Smith', 1100) + >>> cart = [LineItem('banana', 4, .5), + ... LineItem('apple', 10, 1.5), + ... LineItem('watermellon', 5, 5.0)] + >>> banana_cart = [LineItem('banana', 30, .5), + ... LineItem('apple', 10, 1.5)] + >>> big_cart = [LineItem(str(item_code), 1, 1.0) + ... for item_code in range(10)] + +# tag::STRATEGY_BEST_TESTS[] + + >>> Order(joe, big_cart, best_promo) # <1> + + >>> Order(joe, banana_cart, best_promo) # <2> + + >>> Order(ann, cart, best_promo) # <3> + + +# end::STRATEGY_BEST_TESTS[] +""" + +from strategy import Customer, LineItem, Order +from strategy import fidelity_promo, bulk_item_promo, large_order_promo + +# tag::STRATEGY_BEST[] + +promos = [fidelity_promo, bulk_item_promo, large_order_promo] # <1> + + +def best_promo(order) -> float: # <2> + """Select best discount available + """ + return max(promo(order) for promo in promos) # <3> + + +# end::STRATEGY_BEST[] diff --git a/10-dp-1class-func/strategy_best2.py b/10-dp-1class-func/strategy_best2.py new file mode 100644 index 0000000..65b3556 --- /dev/null +++ b/10-dp-1class-func/strategy_best2.py @@ -0,0 +1,113 @@ +# strategy_best2.py +# Strategy pattern -- function-based implementation +# selecting best promotion from current module globals + +""" + >>> joe = Customer('John Doe', 0) + >>> ann = Customer('Ann Smith', 1100) + >>> cart = [LineItem('banana', 4, .5), + ... LineItem('apple', 10, 1.5), + ... LineItem('watermellon', 5, 5.0)] + >>> Order(joe, cart, fidelity_promo) + + >>> Order(ann, cart, fidelity_promo) + + >>> banana_cart = [LineItem('banana', 30, .5), + ... LineItem('apple', 10, 1.5)] + >>> Order(joe, banana_cart, bulk_item_promo) + + >>> long_order = [LineItem(str(item_code), 1, 1.0) + ... for item_code in range(10)] + >>> Order(joe, long_order, large_order_promo) + + >>> Order(joe, cart, large_order_promo) + + +# tag::STRATEGY_BEST_TESTS[] + + >>> Order(joe, long_order, best_promo) + + >>> Order(joe, banana_cart, best_promo) + + >>> Order(ann, cart, best_promo) + + +# end::STRATEGY_BEST_TESTS[] +""" + +from collections import namedtuple + +Customer = namedtuple('Customer', 'name fidelity') + + +class LineItem: + def __init__(self, product, quantity, price): + self.product = product + self.quantity = quantity + self.price = price + + def total(self): + return self.price * self.quantity + + +class Order: # the Context + def __init__(self, customer, cart, promotion=None): + self.customer = customer + self.cart = list(cart) + self.promotion = promotion + + def total(self): + if not hasattr(self, '__total'): + self.__total = sum(item.total() for item in self.cart) + return self.__total + + def due(self): + if self.promotion is None: + discount = 0 + else: + discount = self.promotion(self) + return self.total() - discount + + def __repr__(self): + fmt = '' + return fmt.format(self.total(), self.due()) + + +def fidelity_promo(order): + """5% discount for customers with 1000 or more fidelity points""" + return order.total() * 0.05 if order.customer.fidelity >= 1000 else 0 + + +def bulk_item_promo(order): + """10% discount for each LineItem with 20 or more units""" + discount = 0 + for item in order.cart: + if item.quantity >= 20: + discount += item.total() * 0.1 + return discount + + +def large_order_promo(order): + """7% discount for orders with 10 or more distinct items""" + distinct_items = {item.product for item in order.cart} + if len(distinct_items) >= 10: + return order.total() * 0.07 + return 0 + + +# tag::STRATEGY_BEST2[] + +promos = [ + globals()[name] + for name in globals() # <1> + if name.endswith('_promo') and name != 'best_promo' # <2> +] # <3> + + +def best_promo(order): + """Select best discount available + """ + return max(promo(order) for promo in promos) # <4> + + +# end::STRATEGY_BEST2[] diff --git a/10-dp-1class-func/strategy_best3.py b/10-dp-1class-func/strategy_best3.py new file mode 100644 index 0000000..0574505 --- /dev/null +++ b/10-dp-1class-func/strategy_best3.py @@ -0,0 +1,91 @@ +# strategy_best3.py +# Strategy pattern -- function-based implementation +# selecting best promotion from imported module + +""" + >>> from promotions import * + >>> joe = Customer('John Doe', 0) + >>> ann = Customer('Ann Smith', 1100) + >>> cart = [LineItem('banana', 4, .5), + ... LineItem('apple', 10, 1.5), + ... LineItem('watermellon', 5, 5.0)] + >>> Order(joe, cart, fidelity_promo) + + >>> Order(ann, cart, fidelity_promo) + + >>> banana_cart = [LineItem('banana', 30, .5), + ... LineItem('apple', 10, 1.5)] + >>> Order(joe, banana_cart, bulk_item_promo) + + >>> long_order = [LineItem(str(item_code), 1, 1.0) + ... for item_code in range(10)] + >>> Order(joe, long_order, large_order_promo) + + >>> Order(joe, cart, large_order_promo) + + +# tag::STRATEGY_BEST_TESTS[] + + >>> Order(joe, long_order, best_promo) + + >>> Order(joe, banana_cart, best_promo) + + >>> Order(ann, cart, best_promo) + + +# end::STRATEGY_BEST_TESTS[] +""" + +from collections import namedtuple +import inspect + +import promotions + +Customer = namedtuple('Customer', 'name fidelity') + + +class LineItem: + def __init__(self, product, quantity, price): + self.product = product + self.quantity = quantity + self.price = price + + def total(self): + return self.price * self.quantity + + +class Order: # the Context + def __init__(self, customer, cart, promotion=None): + self.customer = customer + self.cart = list(cart) + self.promotion = promotion + + def total(self): + if not hasattr(self, '__total'): + self.__total = sum(item.total() for item in self.cart) + return self.__total + + def due(self): + if self.promotion is None: + discount = 0 + else: + discount = self.promotion(self) + return self.total() - discount + + def __repr__(self): + fmt = '' + return fmt.format(self.total(), self.due()) + + +# tag::STRATEGY_BEST3[] + +promos = [func for name, func in inspect.getmembers(promotions, inspect.isfunction)] + + +def best_promo(order): + """Select best discount available + """ + return max(promo(order) for promo in promos) + + +# end::STRATEGY_BEST3[] diff --git a/10-dp-1class-func/strategy_best4.py b/10-dp-1class-func/strategy_best4.py new file mode 100644 index 0000000..b2d12ae --- /dev/null +++ b/10-dp-1class-func/strategy_best4.py @@ -0,0 +1,122 @@ +# strategy_best4.py +# Strategy pattern -- function-based implementation +# selecting best promotion from list of functions +# registered by a decorator + +""" + >>> joe = Customer('John Doe', 0) + >>> ann = Customer('Ann Smith', 1100) + >>> cart = [LineItem('banana', 4, .5), + ... LineItem('apple', 10, 1.5), + ... LineItem('watermellon', 5, 5.0)] + >>> Order(joe, cart, fidelity) + + >>> Order(ann, cart, fidelity) + + >>> banana_cart = [LineItem('banana', 30, .5), + ... LineItem('apple', 10, 1.5)] + >>> Order(joe, banana_cart, bulk_item) + + >>> long_order = [LineItem(str(item_code), 1, 1.0) + ... for item_code in range(10)] + >>> Order(joe, long_order, large_order) + + >>> Order(joe, cart, large_order) + + +# tag::STRATEGY_BEST_TESTS[] + + >>> Order(joe, long_order, best_promo) + + >>> Order(joe, banana_cart, best_promo) + + >>> Order(ann, cart, best_promo) + + +# end::STRATEGY_BEST_TESTS[] +""" + +from collections import namedtuple +from typing import Callable, List + +Customer = namedtuple('Customer', 'name fidelity') + + +class LineItem: + def __init__(self, product, quantity, price): + self.product = product + self.quantity = quantity + self.price = price + + def total(self): + return self.price * self.quantity + + +class Order: # the Context + def __init__(self, customer, cart, promotion=None): + self.customer = customer + self.cart = list(cart) + self.promotion = promotion + + def total(self): + if not hasattr(self, '__total'): + self.__total = sum(item.total() for item in self.cart) + return self.__total + + def due(self): + if self.promotion is None: + discount = 0 + else: + discount = self.promotion(self) + return self.total() - discount + + def __repr__(self): + fmt = '' + return fmt.format(self.total(), self.due()) + + +# tag::STRATEGY_BEST4[] + +Promotion = Callable[[Order], float] # <2> + + +def promotion(promo: Promotion) -> Promotion: # <2> + promos.append(promo) + return promo + + +promos: List[Promotion] = [] # <1> + + +@promotion # <3> +def fidelity(order: Order) -> float: + """5% discount for customers with 1000 or more fidelity points""" + return order.total() * 0.05 if order.customer.fidelity >= 1000 else 0 + + +@promotion +def bulk_item(order: Order) -> float: + """10% discount for each LineItem with 20 or more units""" + discount = 0 + for item in order.cart: + if item.quantity >= 20: + discount += item.total() * 0.1 + return discount + + +@promotion +def large_order(order: Order) -> float: + """7% discount for orders with 10 or more distinct items""" + distinct_items = {item.product for item in order.cart} + if len(distinct_items) >= 10: + return order.total() * 0.07 + return 0 + + +def best_promo(order: Order) -> float: # <4> + """Select best discount available + """ + return max(promo(order) for promo in promos) + + +# end::STRATEGY_BEST4[] diff --git a/10-dp-1class-func/strategy_param.py b/10-dp-1class-func/strategy_param.py new file mode 100644 index 0000000..5447189 --- /dev/null +++ b/10-dp-1class-func/strategy_param.py @@ -0,0 +1,123 @@ +# strategy_param.py +# Strategy pattern -- parametrized with closure + +""" + >>> joe = Customer('John Doe', 0) + >>> ann = Customer('Ann Smith', 1100) + >>> cart = [LineItem('banana', 4, .5), + ... LineItem('apple', 10, 1.5), + ... LineItem('watermellon', 5, 5.0)] + >>> Order(joe, cart, fidelity_promo(10)) + + >>> Order(ann, cart, fidelity_promo(10)) + + >>> banana_cart = [LineItem('banana', 30, .5), + ... LineItem('apple', 10, 1.5)] + >>> Order(joe, banana_cart, bulk_item_promo(10)) + + >>> big_cart = [LineItem(str(item_code), 1, 1.0) + ... for item_code in range(10)] + >>> Order(joe, big_cart, LargeOrderPromo(7)) + + >>> Order(joe, cart, LargeOrderPromo(7)) + + +Using ``partial`` to build a parametrized discounter on the fly:: + + >>> from functools import partial + >>> Order(joe, cart, partial(general_discount, 5)) + + +""" + +import typing +from typing import Sequence, Optional, Callable + + +class Customer(typing.NamedTuple): + name: str + fidelity: int + + +class LineItem: + def __init__(self, product: str, quantity: int, price: float): + self.product = product + self.quantity = quantity + self.price = price + + def total(self): + return self.price * self.quantity + + +class Order: # the Context + def __init__( + self, + customer: Customer, + cart: Sequence[LineItem], + promotion: Optional['Promotion'] = None, + ): + self.customer = customer + self.cart = list(cart) + self.promotion = promotion + + def total(self) -> float: + if not hasattr(self, '__total'): + self.__total = sum(item.total() for item in self.cart) + return self.__total + + def due(self) -> float: + if self.promotion is None: + discount = 0.0 + else: + discount = self.promotion(self) # <1> + return self.total() - discount + + def __repr__(self): + fmt = '' + return fmt.format(self.total(), self.due()) + + +# tag::STRATEGY_PARAM[] + +Promotion = Callable[[Order], float] # <2> + + +def fidelity_promo(percent: float) -> Promotion: + """discount for customers with 1000 or more fidelity points""" + return lambda order: ( + order.total() * percent / 100.0 if order.customer.fidelity >= 1000 else 0 + ) + + +def bulk_item_promo(percent: float) -> Promotion: + """discount for each LineItem with 20 or more units""" + + def discounter(order: Order) -> float: + discount = 0 + for item in order.cart: + if item.quantity >= 20: + discount += item.total() * percent / 100.0 + return discount + + return discounter + + +class LargeOrderPromo: + """discount for orders with 10 or more distinct items""" + + def __init__(self, percent: float): + self.percent = percent + + def __call__(self, order: Order) -> float: + distinct_items = {item.product for item in order.cart} + if len(distinct_items) >= 10: + return order.total() * self.percent / 100.0 + return 0 + + +def general_discount(percent: float, order: Order) -> float: + """unrestricted discount; usage: ``partial(general_discount, 5)``""" + return order.total() * percent / 100.0 + + +# end::STRATEGY[] diff --git a/10-dp-1class-func/strategy_param_test.py b/10-dp-1class-func/strategy_param_test.py new file mode 100644 index 0000000..f7b07e3 --- /dev/null +++ b/10-dp-1class-func/strategy_param_test.py @@ -0,0 +1,54 @@ +from typing import List +import functools + +import pytest # type: ignore + +from strategy_param import Customer, LineItem, Order, Promotion +from strategy_param import fidelity_promo, bulk_item_promo, LargeOrderPromo +from strategy_param import general_discount + + +@pytest.fixture +def customer_fidelity_0() -> Customer: + return Customer('John Doe', 0) + + +@pytest.fixture +def customer_fidelity_1100() -> Customer: + return Customer('Ann Smith', 1100) + + +@pytest.fixture +def cart_plain() -> List[LineItem]: + return [ + LineItem('banana', 4, 0.5), + LineItem('apple', 10, 1.5), + LineItem('watermellon', 5, 5.0), + ] + + +def test_fidelity_promo_with_discount(customer_fidelity_1100, cart_plain) -> None: + order = Order(customer_fidelity_1100, cart_plain, fidelity_promo(10)) + assert order.total() == 42.0 + assert order.due() == 37.8 + + +def test_bulk_item_promo_with_discount(customer_fidelity_0) -> None: + cart = [LineItem('banana', 30, 0.5), LineItem('apple', 10, 1.5)] + order = Order(customer_fidelity_0, cart, bulk_item_promo(10)) + assert order.total() == 30.0 + assert order.due() == 28.5 + + +def test_large_order_promo_with_discount(customer_fidelity_0) -> None: + cart = [LineItem(str(item_code), 1, 1.0) for item_code in range(10)] + order = Order(customer_fidelity_0, cart, LargeOrderPromo(7)) + assert order.total() == 10.0 + assert order.due() == 9.3 + + +def test_general_discount(customer_fidelity_0, cart_plain) -> None: + general_promo: Promotion = functools.partial(general_discount, 5) + order = Order(customer_fidelity_1100, cart_plain, general_promo) + assert order.total() == 42.0 + assert order.due() == 39.9 diff --git a/10-dp-1class-func/strategy_test.py b/10-dp-1class-func/strategy_test.py new file mode 100644 index 0000000..26640fa --- /dev/null +++ b/10-dp-1class-func/strategy_test.py @@ -0,0 +1,63 @@ +from typing import List + +import pytest # type: ignore + +from strategy import Customer, LineItem, Order +from strategy import fidelity_promo, bulk_item_promo, large_order_promo + + +@pytest.fixture +def customer_fidelity_0() -> Customer: + return Customer('John Doe', 0) + + +@pytest.fixture +def customer_fidelity_1100() -> Customer: + return Customer('Ann Smith', 1100) + + +@pytest.fixture +def cart_plain() -> List[LineItem]: + return [ + LineItem('banana', 4, 0.5), + LineItem('apple', 10, 1.5), + LineItem('watermellon', 5, 5.0), + ] + + +def test_fidelity_promo_no_discount(customer_fidelity_0, cart_plain) -> None: + order = Order(customer_fidelity_0, cart_plain, fidelity_promo) + assert order.total() == 42.0 + assert order.due() == 42.0 + + +def test_fidelity_promo_with_discount(customer_fidelity_1100, cart_plain) -> None: + order = Order(customer_fidelity_1100, cart_plain, fidelity_promo) + assert order.total() == 42.0 + assert order.due() == 39.9 + + +def test_bulk_item_promo_no_discount(customer_fidelity_0, cart_plain) -> None: + order = Order(customer_fidelity_0, cart_plain, bulk_item_promo) + assert order.total() == 42.0 + assert order.due() == 42.0 + + +def test_bulk_item_promo_with_discount(customer_fidelity_0) -> None: + cart = [LineItem('banana', 30, 0.5), LineItem('apple', 10, 1.5)] + order = Order(customer_fidelity_0, cart, bulk_item_promo) + assert order.total() == 30.0 + assert order.due() == 28.5 + + +def test_large_order_promo_no_discount(customer_fidelity_0, cart_plain) -> None: + order = Order(customer_fidelity_0, cart_plain, large_order_promo) + assert order.total() == 42.0 + assert order.due() == 42.0 + + +def test_large_order_promo_with_discount(customer_fidelity_0) -> None: + cart = [LineItem(str(item_code), 1, 1.0) for item_code in range(10)] + order = Order(customer_fidelity_0, cart, large_order_promo) + assert order.total() == 10.0 + assert order.due() == 9.3 diff --git a/10-dp-1class-func/untyped/classic_strategy.py b/10-dp-1class-func/untyped/classic_strategy.py new file mode 100644 index 0000000..2242fdd --- /dev/null +++ b/10-dp-1class-func/untyped/classic_strategy.py @@ -0,0 +1,106 @@ +# classic_strategy.py +# Strategy pattern -- classic implementation + +""" +# tag::CLASSIC_STRATEGY_TESTS[] + + >>> joe = Customer('John Doe', 0) # <1> + >>> ann = Customer('Ann Smith', 1100) + >>> cart = [LineItem('banana', 4, .5), # <2> + ... LineItem('apple', 10, 1.5), + ... LineItem('watermellon', 5, 5.0)] + >>> Order(joe, cart, FidelityPromo()) # <3> + + >>> Order(ann, cart, FidelityPromo()) # <4> + + >>> banana_cart = [LineItem('banana', 30, .5), # <5> + ... LineItem('apple', 10, 1.5)] + >>> Order(joe, banana_cart, BulkItemPromo()) # <6> + + >>> long_order = [LineItem(str(item_code), 1, 1.0) # <7> + ... for item_code in range(10)] + >>> Order(joe, long_order, LargeOrderPromo()) # <8> + + >>> Order(joe, cart, LargeOrderPromo()) + + +# end::CLASSIC_STRATEGY_TESTS[] +""" +# tag::CLASSIC_STRATEGY[] + +from abc import ABC, abstractmethod +from collections import namedtuple + +Customer = namedtuple('Customer', 'name fidelity') + + +class LineItem: + + def __init__(self, product, quantity, price): + self.product = product + self.quantity = quantity + self.price = price + + def total(self): + return self.price * self.quantity + + +class Order: # the Context + + def __init__(self, customer, cart, promotion=None): + self.customer = customer + self.cart = list(cart) + self.promotion = promotion + + def total(self): + if not hasattr(self, '__total'): + self.__total = sum(item.total() for item in self.cart) + return self.__total + + def due(self): + if self.promotion is None: + discount = 0 + else: + discount = self.promotion.discount(self) + return self.total() - discount + + def __repr__(self): + fmt = '' + return fmt.format(self.total(), self.due()) + + +class Promotion(ABC): # the Strategy: an abstract base class + + @abstractmethod + def discount(self, order): + """Return discount as a positive dollar amount""" + + +class FidelityPromo(Promotion): # first Concrete Strategy + """5% discount for customers with 1000 or more fidelity points""" + + def discount(self, order): + return order.total() * .05 if order.customer.fidelity >= 1000 else 0 + + +class BulkItemPromo(Promotion): # second Concrete Strategy + """10% discount for each LineItem with 20 or more units""" + + def discount(self, order): + discount = 0 + for item in order.cart: + if item.quantity >= 20: + discount += item.total() * .1 + return discount + + +class LargeOrderPromo(Promotion): # third Concrete Strategy + """7% discount for orders with 10 or more distinct items""" + + def discount(self, order): + distinct_items = {item.product for item in order.cart} + if len(distinct_items) >= 10: + return order.total() * .07 + return 0 + +# end::CLASSIC_STRATEGY[] diff --git a/10-dp-1class-func/untyped/promotions.py b/10-dp-1class-func/untyped/promotions.py new file mode 100644 index 0000000..a8795a0 --- /dev/null +++ b/10-dp-1class-func/untyped/promotions.py @@ -0,0 +1,20 @@ + +def fidelity_promo(order): + """5% discount for customers with 1000 or more fidelity points""" + return order.total() * .05 if order.customer.fidelity >= 1000 else 0 + + +def bulk_item_promo(order): + """10% discount for each LineItem with 20 or more units""" + discount = 0 + for item in order.cart: + if item.quantity >= 20: + discount += item.total() * .1 + return discount + +def large_order_promo(order): + """7% discount for orders with 10 or more distinct items""" + distinct_items = {item.product for item in order.cart} + if len(distinct_items) >= 10: + return order.total() * .07 + return 0 diff --git a/10-dp-1class-func/untyped/strategy.py b/10-dp-1class-func/untyped/strategy.py new file mode 100644 index 0000000..b0c9c86 --- /dev/null +++ b/10-dp-1class-func/untyped/strategy.py @@ -0,0 +1,93 @@ +# strategy.py +# Strategy pattern -- function-based implementation + +""" +# tag::STRATEGY_TESTS[] + + >>> joe = Customer('John Doe', 0) # <1> + >>> ann = Customer('Ann Smith', 1100) + >>> cart = [LineItem('banana', 4, .5), + ... LineItem('apple', 10, 1.5), + ... LineItem('watermellon', 5, 5.0)] + >>> Order(joe, cart, fidelity_promo) # <2> + + >>> Order(ann, cart, fidelity_promo) + + >>> banana_cart = [LineItem('banana', 30, .5), + ... LineItem('apple', 10, 1.5)] + >>> Order(joe, banana_cart, bulk_item_promo) # <3> + + >>> long_order = [LineItem(str(item_code), 1, 1.0) + ... for item_code in range(10)] + >>> Order(joe, long_order, large_order_promo) + + >>> Order(joe, cart, large_order_promo) + + +# end::STRATEGY_TESTS[] +""" +# tag::STRATEGY[] + +from collections import namedtuple + +Customer = namedtuple('Customer', 'name fidelity') + + +class LineItem: + + def __init__(self, product, quantity, price): + self.product = product + self.quantity = quantity + self.price = price + + def total(self): + return self.price * self.quantity + + +class Order: # the Context + + def __init__(self, customer, cart, promotion=None): + self.customer = customer + self.cart = list(cart) + self.promotion = promotion + + def total(self): + if not hasattr(self, '__total'): + self.__total = sum(item.total() for item in self.cart) + return self.__total + + def due(self): + if self.promotion is None: + discount = 0 + else: + discount = self.promotion(self) # <1> + return self.total() - discount + + def __repr__(self): + fmt = '' + return fmt.format(self.total(), self.due()) + +# <2> + +def fidelity_promo(order): # <3> + """5% discount for customers with 1000 or more fidelity points""" + return order.total() * .05 if order.customer.fidelity >= 1000 else 0 + + +def bulk_item_promo(order): + """10% discount for each LineItem with 20 or more units""" + discount = 0 + for item in order.cart: + if item.quantity >= 20: + discount += item.total() * .1 + return discount + + +def large_order_promo(order): + """7% discount for orders with 10 or more distinct items""" + distinct_items = {item.product for item in order.cart} + if len(distinct_items) >= 10: + return order.total() * .07 + return 0 + +# end::STRATEGY[] diff --git a/10-dp-1class-func/untyped/strategy_best.py b/10-dp-1class-func/untyped/strategy_best.py new file mode 100644 index 0000000..89281a8 --- /dev/null +++ b/10-dp-1class-func/untyped/strategy_best.py @@ -0,0 +1,108 @@ +# strategy_best.py +# Strategy pattern -- function-based implementation +# selecting best promotion from static list of functions + +""" + >>> joe = Customer('John Doe', 0) + >>> ann = Customer('Ann Smith', 1100) + >>> cart = [LineItem('banana', 4, .5), + ... LineItem('apple', 10, 1.5), + ... LineItem('watermellon', 5, 5.0)] + >>> Order(joe, cart, fidelity_promo) + + >>> Order(ann, cart, fidelity_promo) + + >>> banana_cart = [LineItem('banana', 30, .5), + ... LineItem('apple', 10, 1.5)] + >>> Order(joe, banana_cart, bulk_item_promo) + + >>> long_order = [LineItem(str(item_code), 1, 1.0) + ... for item_code in range(10)] + >>> Order(joe, long_order, large_order_promo) + + >>> Order(joe, cart, large_order_promo) + + +# tag::STRATEGY_BEST_TESTS[] + + >>> Order(joe, long_order, best_promo) # <1> + + >>> Order(joe, banana_cart, best_promo) # <2> + + >>> Order(ann, cart, best_promo) # <3> + + +# end::STRATEGY_BEST_TESTS[] +""" + +from collections import namedtuple + +Customer = namedtuple('Customer', 'name fidelity') + + +class LineItem: + + def __init__(self, product, quantity, price): + self.product = product + self.quantity = quantity + self.price = price + + def total(self): + return self.price * self.quantity + + +class Order: # the Context + + def __init__(self, customer, cart, promotion=None): + self.customer = customer + self.cart = list(cart) + self.promotion = promotion + + def total(self): + if not hasattr(self, '__total'): + self.__total = sum(item.total() for item in self.cart) + return self.__total + + def due(self): + if self.promotion is None: + discount = 0 + else: + discount = self.promotion(self) + return self.total() - discount + + def __repr__(self): + fmt = '' + return fmt.format(self.total(), self.due()) + + +def fidelity_promo(order): + """5% discount for customers with 1000 or more fidelity points""" + return order.total() * .05 if order.customer.fidelity >= 1000 else 0 + + +def bulk_item_promo(order): + """10% discount for each LineItem with 20 or more units""" + discount = 0 + for item in order.cart: + if item.quantity >= 20: + discount += item.total() * .1 + return discount + + +def large_order_promo(order): + """7% discount for orders with 10 or more distinct items""" + distinct_items = {item.product for item in order.cart} + if len(distinct_items) >= 10: + return order.total() * .07 + return 0 + +# tag::STRATEGY_BEST[] + +promos = [fidelity_promo, bulk_item_promo, large_order_promo] # <1> + +def best_promo(order): # <2> + """Select best discount available + """ + return max(promo(order) for promo in promos) # <3> + +# end::STRATEGY_BEST[] diff --git a/10-dp-1class-func/untyped/strategy_best2.py b/10-dp-1class-func/untyped/strategy_best2.py new file mode 100644 index 0000000..c7a9eff --- /dev/null +++ b/10-dp-1class-func/untyped/strategy_best2.py @@ -0,0 +1,110 @@ +# strategy_best2.py +# Strategy pattern -- function-based implementation +# selecting best promotion from current module globals + +""" + >>> joe = Customer('John Doe', 0) + >>> ann = Customer('Ann Smith', 1100) + >>> cart = [LineItem('banana', 4, .5), + ... LineItem('apple', 10, 1.5), + ... LineItem('watermellon', 5, 5.0)] + >>> Order(joe, cart, fidelity_promo) + + >>> Order(ann, cart, fidelity_promo) + + >>> banana_cart = [LineItem('banana', 30, .5), + ... LineItem('apple', 10, 1.5)] + >>> Order(joe, banana_cart, bulk_item_promo) + + >>> long_order = [LineItem(str(item_code), 1, 1.0) + ... for item_code in range(10)] + >>> Order(joe, long_order, large_order_promo) + + >>> Order(joe, cart, large_order_promo) + + +# tag::STRATEGY_BEST_TESTS[] + + >>> Order(joe, long_order, best_promo) + + >>> Order(joe, banana_cart, best_promo) + + >>> Order(ann, cart, best_promo) + + +# end::STRATEGY_BEST_TESTS[] +""" + +from collections import namedtuple + +Customer = namedtuple('Customer', 'name fidelity') + + +class LineItem: + + def __init__(self, product, quantity, price): + self.product = product + self.quantity = quantity + self.price = price + + def total(self): + return self.price * self.quantity + + +class Order: # the Context + + def __init__(self, customer, cart, promotion=None): + self.customer = customer + self.cart = list(cart) + self.promotion = promotion + + def total(self): + if not hasattr(self, '__total'): + self.__total = sum(item.total() for item in self.cart) + return self.__total + + def due(self): + if self.promotion is None: + discount = 0 + else: + discount = self.promotion(self) + return self.total() - discount + + def __repr__(self): + fmt = '' + return fmt.format(self.total(), self.due()) + + +def fidelity_promo(order): + """5% discount for customers with 1000 or more fidelity points""" + return order.total() * .05 if order.customer.fidelity >= 1000 else 0 + + +def bulk_item_promo(order): + """10% discount for each LineItem with 20 or more units""" + discount = 0 + for item in order.cart: + if item.quantity >= 20: + discount += item.total() * .1 + return discount + + +def large_order_promo(order): + """7% discount for orders with 10 or more distinct items""" + distinct_items = {item.product for item in order.cart} + if len(distinct_items) >= 10: + return order.total() * .07 + return 0 + +# tag::STRATEGY_BEST2[] + +promos = [globals()[name] for name in globals() # <1> + if name.endswith('_promo') # <2> + and name != 'best_promo'] # <3> + +def best_promo(order): + """Select best discount available + """ + return max(promo(order) for promo in promos) # <4> + +# end::STRATEGY_BEST2[] diff --git a/10-dp-1class-func/untyped/strategy_best3.py b/10-dp-1class-func/untyped/strategy_best3.py new file mode 100644 index 0000000..1b30114 --- /dev/null +++ b/10-dp-1class-func/untyped/strategy_best3.py @@ -0,0 +1,93 @@ +# strategy_best3.py +# Strategy pattern -- function-based implementation +# selecting best promotion from imported module + +""" + >>> from promotions import * + >>> joe = Customer('John Doe', 0) + >>> ann = Customer('Ann Smith', 1100) + >>> cart = [LineItem('banana', 4, .5), + ... LineItem('apple', 10, 1.5), + ... LineItem('watermellon', 5, 5.0)] + >>> Order(joe, cart, fidelity_promo) + + >>> Order(ann, cart, fidelity_promo) + + >>> banana_cart = [LineItem('banana', 30, .5), + ... LineItem('apple', 10, 1.5)] + >>> Order(joe, banana_cart, bulk_item_promo) + + >>> long_order = [LineItem(str(item_code), 1, 1.0) + ... for item_code in range(10)] + >>> Order(joe, long_order, large_order_promo) + + >>> Order(joe, cart, large_order_promo) + + +# tag::STRATEGY_BEST_TESTS[] + + >>> Order(joe, long_order, best_promo) + + >>> Order(joe, banana_cart, best_promo) + + >>> Order(ann, cart, best_promo) + + +# end::STRATEGY_BEST_TESTS[] +""" + +from collections import namedtuple +import inspect + +import promotions + +Customer = namedtuple('Customer', 'name fidelity') + + +class LineItem: + + def __init__(self, product, quantity, price): + self.product = product + self.quantity = quantity + self.price = price + + def total(self): + return self.price * self.quantity + + +class Order: # the Context + + def __init__(self, customer, cart, promotion=None): + self.customer = customer + self.cart = list(cart) + self.promotion = promotion + + def total(self): + if not hasattr(self, '__total'): + self.__total = sum(item.total() for item in self.cart) + return self.__total + + def due(self): + if self.promotion is None: + discount = 0 + else: + discount = self.promotion(self) + return self.total() - discount + + def __repr__(self): + fmt = '' + return fmt.format(self.total(), self.due()) + +# tag::STRATEGY_BEST3[] + +promos = [func for name, func in + inspect.getmembers(promotions, inspect.isfunction)] + +def best_promo(order): + """Select best discount available + """ + return max(promo(order) for promo in promos) + +# end::STRATEGY_BEST3[] + + diff --git a/10-dp-1class-func/untyped/strategy_best4.py b/10-dp-1class-func/untyped/strategy_best4.py new file mode 100644 index 0000000..afa05e1 --- /dev/null +++ b/10-dp-1class-func/untyped/strategy_best4.py @@ -0,0 +1,113 @@ +# strategy_best4.py +# Strategy pattern -- function-based implementation +# selecting best promotion from list of functions +# registered by a decorator + +""" + >>> joe = Customer('John Doe', 0) + >>> ann = Customer('Ann Smith', 1100) + >>> cart = [LineItem('banana', 4, .5), + ... LineItem('apple', 10, 1.5), + ... LineItem('watermellon', 5, 5.0)] + >>> Order(joe, cart, fidelity) + + >>> Order(ann, cart, fidelity) + + >>> banana_cart = [LineItem('banana', 30, .5), + ... LineItem('apple', 10, 1.5)] + >>> Order(joe, banana_cart, bulk_item) + + >>> long_order = [LineItem(str(item_code), 1, 1.0) + ... for item_code in range(10)] + >>> Order(joe, long_order, large_order) + + >>> Order(joe, cart, large_order) + + +# tag::STRATEGY_BEST_TESTS[] + + >>> Order(joe, long_order, best_promo) + + >>> Order(joe, banana_cart, best_promo) + + >>> Order(ann, cart, best_promo) + + +# end::STRATEGY_BEST_TESTS[] +""" + +from collections import namedtuple + +Customer = namedtuple('Customer', 'name fidelity') + + +class LineItem: + + def __init__(self, product, quantity, price): + self.product = product + self.quantity = quantity + self.price = price + + def total(self): + return self.price * self.quantity + + +class Order: # the Context + + def __init__(self, customer, cart, promotion=None): + self.customer = customer + self.cart = list(cart) + self.promotion = promotion + + def total(self): + if not hasattr(self, '__total'): + self.__total = sum(item.total() for item in self.cart) + return self.__total + + def due(self): + if self.promotion is None: + discount = 0 + else: + discount = self.promotion(self) + return self.total() - discount + + def __repr__(self): + fmt = '' + return fmt.format(self.total(), self.due()) + +# tag::STRATEGY_BEST4[] + +promos = [] # <1> + +def promotion(promo_func): # <2> + promos.append(promo_func) + return promo_func + +@promotion # <3> +def fidelity(order): + """5% discount for customers with 1000 or more fidelity points""" + return order.total() * .05 if order.customer.fidelity >= 1000 else 0 + +@promotion +def bulk_item(order): + """10% discount for each LineItem with 20 or more units""" + discount = 0 + for item in order.cart: + if item.quantity >= 20: + discount += item.total() * .1 + return discount + +@promotion +def large_order(order): + """7% discount for orders with 10 or more distinct items""" + distinct_items = {item.product for item in order.cart} + if len(distinct_items) >= 10: + return order.total() * .07 + return 0 + +def best_promo(order): # <4> + """Select best discount available + """ + return max(promo(order) for promo in promos) + +# end::STRATEGY_BEST4[] diff --git a/10-dp-1class-func/untyped/strategy_param.py b/10-dp-1class-func/untyped/strategy_param.py new file mode 100644 index 0000000..4c23623 --- /dev/null +++ b/10-dp-1class-func/untyped/strategy_param.py @@ -0,0 +1,91 @@ +# strategy_param.py +# Strategy pattern -- parametrized with closure + +""" + >>> joe = Customer('John Doe', 0) + >>> ann = Customer('Ann Smith', 1100) + >>> cart = [LineItem('banana', 4, .5), + ... LineItem('apple', 10, 1.5), + ... LineItem('watermellon', 5, 5.0)] + >>> Order(joe, cart, fidelity_promo(10)) + + >>> Order(ann, cart, fidelity_promo(10)) + + >>> banana_cart = [LineItem('banana', 30, .5), + ... LineItem('apple', 10, 1.5)] + >>> Order(joe, banana_cart, bulk_item_promo(10)) + + >>> long_order = [LineItem(str(item_code), 1, 1.0) + ... for item_code in range(10)] + >>> Order(joe, long_order, large_order_promo(7)) + + >>> Order(joe, cart, large_order_promo(7)) + + +""" + +from collections import namedtuple + +Customer = namedtuple('Customer', 'name fidelity') + + +class LineItem: + + def __init__(self, product, quantity, price): + self.product = product + self.quantity = quantity + self.price = price + + def total(self): + return self.price * self.quantity + + +class Order: # the Context + + def __init__(self, customer, cart, promotion=None): + self.customer = customer + self.cart = list(cart) + self.promotion = promotion + + def total(self): + if not hasattr(self, '__total'): + self.__total = sum(item.total() for item in self.cart) + return self.__total + + def due(self): + if self.promotion is None: + discount = 0 + else: + discount = self.promotion(self) # <1> + return self.total() - discount + + def __repr__(self): + fmt = '' + return fmt.format(self.total(), self.due()) + + +def fidelity_promo(percent): + """discount for customers with 1000 or more fidelity points""" + return lambda order: (order.total() * percent/100.0 + if order.customer.fidelity >= 1000 else 0) + + +def bulk_item_promo(percent): + """discount for each LineItem with 20 or more units""" + def discounter(order): + discount = 0 + for item in order.cart: + if item.quantity >= 20: + discount += item.total() * percent/100.0 + return discount + return discounter + + +def large_order_promo(percent): + """discount for orders with 10 or more distinct items""" + def discounter(order): + distinct_items = {item.product for item in order.cart} + if len(distinct_items) >= 10: + return order.total() * percent / 100.0 + return 0 + return discounter diff --git a/10-dp-1class-func/untyped/strategy_param2.py b/10-dp-1class-func/untyped/strategy_param2.py new file mode 100644 index 0000000..91d77f2 --- /dev/null +++ b/10-dp-1class-func/untyped/strategy_param2.py @@ -0,0 +1,104 @@ +# strategy_param2.py +# Strategy pattern — parametrized with callable + +""" + >>> joe = Customer('John Doe', 0) + >>> ann = Customer('Ann Smith', 1100) + >>> cart = [LineItem('banana', 4, .5), + ... LineItem('apple', 10, 1.5), + ... LineItem('watermellon', 5, 5.0)] + >>> Order(joe, cart, FidelityPromo(10)) + + >>> Order(ann, cart, FidelityPromo(10)) + + >>> banana_cart = [LineItem('banana', 30, .5), + ... LineItem('apple', 10, 1.5)] + >>> Order(joe, banana_cart, BulkItemPromo(10)) + + >>> long_order = [LineItem(str(item_code), 1, 1.0) + ... for item_code in range(10)] + >>> Order(joe, long_order, LargeOrderPromo(7)) + + >>> Order(joe, cart, LargeOrderPromo(7)) + + +""" + +from collections import namedtuple + +Customer = namedtuple('Customer', 'name fidelity') + + +class LineItem: + + def __init__(self, product, quantity, price): + self.product = product + self.quantity = quantity + self.price = price + + def total(self): + return self.price * self.quantity + + +class Order: # the Context + + def __init__(self, customer, cart, promotion=None): + self.customer = customer + self.cart = list(cart) + self.promotion = promotion + + def total(self): + if not hasattr(self, '__total'): + self.__total = sum(item.total() for item in self.cart) + return self.__total + + def due(self): + if self.promotion is None: + discount = 0 + else: + discount = self.promotion(self) # <1> + return self.total() - discount + + def __repr__(self): + fmt = '' + return fmt.format(self.total(), self.due()) + + +class Promotion(): + """compute discount for order""" + + def __init__(self, percent): + self.percent = percent + + def __call__(self, order): + raise NotImplementedError("Subclass responsibility") + + +class FidelityPromo(Promotion): + """discount for customers with 1000 or more fidelity points""" + + def __call__(self, order): + if order.customer.fidelity >= 1000: + return order.total() * self.percent/100.0 + return 0 + + +class BulkItemPromo(Promotion): + """discount for each LineItem with 20 or more units""" + + def __call__(self, order): + discount = 0 + for item in order.cart: + if item.quantity >= 20: + discount += item.total() * self.percent/100.0 + return discount + + +class LargeOrderPromo(Promotion): + """discount for orders with 10 or more distinct items""" + + def __call__(self, order): + distinct_items = {item.product for item in order.cart} + if len(distinct_items) >= 10: + return order.total() * self.percent / 100.0 + return 0