├── .gitignore ├── Chapter01 ├── Makefile ├── README.rst ├── before_black.py ├── requirements.txt ├── src │ ├── __init__.py │ ├── annotations.py │ └── test_annotations.py └── tests ├── Chapter02 ├── Makefile ├── README.rst ├── callables.py ├── caveats.py ├── container.py ├── contextmanagers.py ├── dynamic.py ├── indices.py ├── iterables.py ├── properties.py ├── sequences.py └── test_ch02.py ├── Chapter03 ├── Makefile ├── README.rst ├── exceptions_1.py ├── exceptions_2.py ├── exceptions_3.py ├── inheritance_antipattern.py ├── inheritance_patterns.py ├── kis.py ├── multiple_inheritance.py ├── multiple_inheritance_2.py ├── orthogonal.py ├── packing_1.py ├── test_exceptions_1.py ├── test_exceptions_2.py └── test_exceptions_3.py ├── Chapter04 ├── Makefile ├── README.rst ├── lsp_1.py ├── lsp_2.py ├── openclosed_1.py ├── openclosed_2.py ├── openclosed_3.py └── srp_1.py ├── Chapter05 ├── Makefile ├── README.rst ├── decorator_SoC_1.py ├── decorator_SoC_2.py ├── decorator_class_1.py ├── decorator_class_2.py ├── decorator_class_3.py ├── decorator_function_1.py ├── decorator_function_2.py ├── decorator_parametrized_1.py ├── decorator_parametrized_2.py ├── decorator_side_effects_1.py ├── decorator_side_effects_2.py ├── decorator_universal_1.py ├── decorator_universal_2.py ├── decorator_wraps_1.py ├── decorator_wraps_2.py ├── log.py ├── test_decorator_SoC.py ├── test_decorator_parametrized.py ├── test_decorator_universal.py └── test_wraps.py ├── Chapter06 ├── Makefile ├── README.rst ├── descriptors_1.py ├── descriptors_cpython_1.py ├── descriptors_cpython_2.py ├── descriptors_cpython_3.py ├── descriptors_implementation_1.py ├── descriptors_implementation_2.py ├── descriptors_methods_1.py ├── descriptors_methods_2.py ├── descriptors_methods_3.py ├── descriptors_methods_4.py ├── descriptors_pythonic_1.py ├── descriptors_pythonic_2.py ├── descriptors_types_1.py ├── descriptors_types_2.py ├── descriptors_uses_1.py ├── log.py ├── test_descriptors_cpython.py ├── test_descriptors_methods.py └── test_descriptors_uses_1.py ├── Chapter07 ├── .gitignore ├── Makefile ├── README.rst ├── _generate_data.py ├── generators_1.py ├── generators_2.py ├── generators_coroutines_1.py ├── generators_coroutines_2.py ├── generators_iteration_1.py ├── generators_iteration_2.py ├── generators_pythonic_1.py ├── generators_pythonic_2.py ├── generators_pythonic_3.py ├── generators_pythonic_4.py ├── generators_yieldfrom_1.py ├── generators_yieldfrom_2.py ├── generators_yieldfrom_3.py ├── log.py ├── test_generators.py ├── test_generators_coroutines.py ├── test_generators_iteration.py └── test_generators_pythonic.py ├── Chapter08 ├── .gitignore ├── Makefile ├── README.rst ├── constants.py ├── coverage_1.py ├── doctest_module.py ├── doctest_module_test.py ├── mock_1.py ├── mock_2.py ├── mrstatus.py ├── mutation-testing.sh ├── mutation_testing_1.py ├── mutation_testing_2.py ├── refactoring_1.py ├── refactoring_2.py ├── requirements.txt ├── run-coverage.sh ├── test_coverage_1.py ├── test_mock_1.py ├── test_mock_2.py ├── test_mutation_testing_1.py ├── test_mutation_testing_2.py ├── test_refactoring_1.py ├── test_refactoring_2.py ├── test_ut_design_2.py ├── test_ut_frameworks.py ├── test_ut_frameworks_4.py ├── ut_design_1.py ├── ut_design_2.py ├── ut_frameworks_1.py ├── ut_frameworks_2.py ├── ut_frameworks_3.py ├── ut_frameworks_4.py └── ut_frameworks_5.py ├── Chapter09 ├── Makefile ├── README.rst ├── _adapter_base.py ├── adapter_1.py ├── adapter_2.py ├── chain_of_responsibility_1.py ├── composite_1.py ├── decorator_1.py ├── decorator_2.py ├── log.py ├── monostate_1.py ├── monostate_2.py ├── monostate_3.py ├── monostate_4.py ├── state_1.py ├── state_2.py ├── test_chain_of_responsibility_1.py ├── test_composite_1.py ├── test_decorator_1.py ├── test_decorator_2.py ├── test_monostate_1.py ├── test_monostate_2.py ├── test_monostate_3.py ├── test_monostate_4.py ├── test_state_1.py └── test_state_2.py ├── Chapter10 ├── README.rst └── service │ ├── .gitignore │ ├── Dockerfile │ ├── Makefile │ ├── README.rst │ ├── libs │ ├── README.rst │ ├── storage │ │ ├── .gitignore │ │ ├── README.rst │ │ ├── db │ │ │ ├── Dockerfile │ │ │ ├── Makefile │ │ │ ├── README.rst │ │ │ └── sql │ │ │ │ ├── data.sql │ │ │ │ └── schema.sql │ │ ├── setup.py │ │ ├── src │ │ │ └── storage │ │ │ │ ├── __init__.py │ │ │ │ ├── client.py │ │ │ │ ├── converters.py │ │ │ │ ├── status.py │ │ │ │ └── storage.py │ │ └── tests │ │ │ └── integration │ │ │ └── test_retrieve_data.py │ └── web │ │ ├── README.rst │ │ ├── setup.py │ │ └── src │ │ └── web │ │ ├── __init__.py │ │ └── view.py │ ├── setup.py │ └── statusweb │ ├── README.rst │ ├── __init__.py │ └── service.py ├── LICENSE ├── Makefile ├── README.md └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.sw[op] 3 | .mypy_cache/ 4 | *.tar.gz 5 | .pytest_cache/ 6 | -------------------------------------------------------------------------------- /Chapter01/Makefile: -------------------------------------------------------------------------------- 1 | typehint: 2 | mypy --ignore-missing-imports src/ 3 | 4 | test: 5 | pytest tests/ 6 | 7 | lint: 8 | pylint src/ 9 | 10 | checklist: lint typehint test 11 | 12 | black: 13 | black -l 79 *.py 14 | 15 | setup: 16 | $(VIRTUAL_ENV)/bin/pip install -r requirements.txt 17 | 18 | .PHONY: typehint test lint checklist black 19 | -------------------------------------------------------------------------------- /Chapter01/README.rst: -------------------------------------------------------------------------------- 1 | Chapter 01 - Introduction, Tools, and Formatting 2 | ================================================ 3 | 4 | Install dependencies:: 5 | 6 | make setup 7 | 8 | Run the tests:: 9 | 10 | make test 11 | -------------------------------------------------------------------------------- /Chapter01/before_black.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 1: Introduction, Tools, and Formatting 2 | 3 | > Black: 4 | A code that is compliant with PEP-8, but that still can be modified by back 5 | 6 | 7 | Run:: 8 | black -l 79 before_black.py 9 | 10 | To see the difference 11 | """ 12 | 13 | 14 | def my_function(name): 15 | """ 16 | >>> my_function('black') 17 | 'received Black' 18 | """ 19 | return 'received {0}'.format(name.title()) 20 | -------------------------------------------------------------------------------- /Chapter01/requirements.txt: -------------------------------------------------------------------------------- 1 | -r ../requirements.txt 2 | pytest==3.7.1 -------------------------------------------------------------------------------- /Chapter01/src/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PacktPublishing/Clean-Code-in-Python/7348d0f9f42871f499b352e0696e0cef51c4f8c6/Chapter01/src/__init__.py -------------------------------------------------------------------------------- /Chapter01/src/annotations.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 1: Introduction, Tools, and Formatting 2 | 3 | > Annotations 4 | """ 5 | 6 | 7 | class Point: # pylint: disable=R0903 8 | """Example to be used as return type of locate""" 9 | def __init__(self, lat, long): 10 | self.lat = lat 11 | self.long = long 12 | 13 | 14 | def locate(latitude: float, longitude: float) -> Point: 15 | """Find an object in the map by its coordinates""" 16 | return Point(latitude, longitude) 17 | 18 | 19 | class NewPoint: # pylint: disable=R0903 20 | """Example to display its __annotations__ attribute.""" 21 | lat: float 22 | long: float 23 | -------------------------------------------------------------------------------- /Chapter01/src/test_annotations.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 01: Introcution, Tools, and Formatting 2 | 3 | Tests for annotations examples 4 | 5 | """ 6 | import pytest 7 | 8 | from src.annotations import NewPoint, Point, locate 9 | 10 | 11 | @pytest.mark.parametrize( 12 | "element,expected", 13 | ( 14 | (locate, {"latitude": float, "longitude": float, "return": Point}), 15 | (NewPoint, {"lat": float, "long": float}), 16 | ), 17 | ) 18 | def test_annotations(element, expected): 19 | """test the class/functions againts its expected annotations""" 20 | assert getattr(element, "__annotations__") == expected 21 | -------------------------------------------------------------------------------- /Chapter01/tests: -------------------------------------------------------------------------------- 1 | src -------------------------------------------------------------------------------- /Chapter02/Makefile: -------------------------------------------------------------------------------- 1 | PYTHON:=$(VIRTUAL_ENV)/bin/python 2 | 3 | test: 4 | @$(PYTHON) -m doctest *.py 5 | @$(PYTHON) -m unittest *.py 6 | 7 | .PHONY: test 8 | -------------------------------------------------------------------------------- /Chapter02/README.rst: -------------------------------------------------------------------------------- 1 | Chapter 02: Pythonic Code 2 | ========================= 3 | 4 | Test the code:: 5 | 6 | make test 7 | -------------------------------------------------------------------------------- /Chapter02/callables.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 2: Pythonic Code 2 | 3 | > Callable objects 4 | 5 | """ 6 | 7 | from collections import defaultdict 8 | 9 | 10 | class CallCount: 11 | """ 12 | >>> cc = CallCount() 13 | >>> cc(1) 14 | 1 15 | >>> cc(2) 16 | 1 17 | >>> cc(1) 18 | 2 19 | >>> cc(1) 20 | 3 21 | >>> cc("something") 22 | 1 23 | 24 | >>> callable(cc) 25 | True 26 | """ 27 | 28 | def __init__(self): 29 | self._counts = defaultdict(int) 30 | 31 | def __call__(self, argument): 32 | self._counts[argument] += 1 33 | return self._counts[argument] 34 | -------------------------------------------------------------------------------- /Chapter02/caveats.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 2: Pythonic Code 2 | 3 | > Caveats in Python 4 | """ 5 | 6 | from collections import UserList 7 | 8 | 9 | def wrong_user_display(user_metadata: dict = {"name": "John", "age": 30}): 10 | name = user_metadata.pop("name") 11 | age = user_metadata.pop("age") 12 | 13 | return f"{name} ({age})" 14 | 15 | 16 | def user_display(user_metadata: dict = None): 17 | user_metadata = user_metadata or {"name": "John", "age": 30} 18 | 19 | name = user_metadata.pop("name") 20 | age = user_metadata.pop("age") 21 | 22 | return f"{name} ({age})" 23 | 24 | 25 | class BadList(list): 26 | def __getitem__(self, index): 27 | value = super().__getitem__(index) 28 | if index % 2 == 0: 29 | prefix = "even" 30 | else: 31 | prefix = "odd" 32 | return f"[{prefix}] {value}" 33 | 34 | 35 | class GoodList(UserList): 36 | def __getitem__(self, index): 37 | value = super().__getitem__(index) 38 | if index % 2 == 0: 39 | prefix = "even" 40 | else: 41 | prefix = "odd" 42 | return f"[{prefix}] {value}" 43 | -------------------------------------------------------------------------------- /Chapter02/container.py: -------------------------------------------------------------------------------- 1 | """Chapter 2 - Containers""" 2 | 3 | 4 | def mark_coordinate(grid, coord): 5 | if 0 <= coord.x < grid.width and 0 <= coord.y < grid.height: 6 | grid[coord] = 1 7 | 8 | if coord in grid: 9 | grid[coord] = 1 10 | 11 | 12 | class Boundaries: 13 | def __init__(self, width, height): 14 | self.width = width 15 | self.height = height 16 | 17 | def __contains__(self, coord): 18 | x, y = coord 19 | return 0 <= x < self.width and 0 <= y < self.height 20 | 21 | 22 | class Grid: 23 | def __init__(self, width, height): 24 | self.width = width 25 | self.height = height 26 | self.limits = Boundaries(width, height) 27 | 28 | def __contains__(self, coord): 29 | return coord in self.limits 30 | -------------------------------------------------------------------------------- /Chapter02/contextmanagers.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | 3 | 4 | run = print 5 | 6 | 7 | def stop_database(): 8 | run("systemctl stop postgresql.service") 9 | 10 | 11 | def start_database(): 12 | run("systemctl start postgresql.service") 13 | 14 | 15 | class DBHandler: 16 | def __enter__(self): 17 | stop_database() 18 | return self 19 | 20 | def __exit__(self, exc_type, ex_value, ex_traceback): 21 | start_database() 22 | 23 | 24 | def db_backup(): 25 | run("pg_dump database") 26 | 27 | 28 | @contextlib.contextmanager 29 | def db_handler(): 30 | stop_database() 31 | yield 32 | start_database() 33 | 34 | 35 | class dbhandler_decorator(contextlib.ContextDecorator): 36 | def __enter__(self): 37 | stop_database() 38 | 39 | def __exit__(self, ext_type, ex_value, ex_traceback): 40 | start_database() 41 | 42 | 43 | @dbhandler_decorator() 44 | def offline_backup(): 45 | run("pg_dump database") 46 | 47 | 48 | def main(): 49 | with DBHandler(): 50 | db_backup() 51 | 52 | with db_handler(): 53 | db_backup() 54 | 55 | offline_backup() 56 | 57 | 58 | if __name__ == "__main__": 59 | main() 60 | -------------------------------------------------------------------------------- /Chapter02/dynamic.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 2: Pythonic Code 2 | 3 | > Dynamic Attributes 4 | 5 | """ 6 | 7 | 8 | class DynamicAttributes: 9 | """ 10 | >>> dyn = DynamicAttributes("value") 11 | >>> dyn.attribute 12 | 'value' 13 | 14 | >>> dyn.fallback_test 15 | '[fallback resolved] test' 16 | 17 | >>> dyn.__dict__["fallback_new"] = "new value" 18 | >>> dyn.fallback_new 19 | 'new value' 20 | 21 | >>> getattr(dyn, "something", "default") 22 | 'default' 23 | """ 24 | 25 | def __init__(self, attribute): 26 | self.attribute = attribute 27 | 28 | def __getattr__(self, attr): 29 | if attr.startswith("fallback_"): 30 | name = attr.replace("fallback_", "") 31 | return f"[fallback resolved] {name}" 32 | raise AttributeError( 33 | f"{self.__class__.__name__} has no attribute {attr}" 34 | ) 35 | -------------------------------------------------------------------------------- /Chapter02/indices.py: -------------------------------------------------------------------------------- 1 | """Indexes and slices 2 | Getting elements by an index or range 3 | """ 4 | import doctest 5 | 6 | 7 | def index_last(): 8 | """ 9 | >>> my_numbers = (4, 5, 3, 9) 10 | >>> my_numbers[-1] 11 | 9 12 | >>> my_numbers[-3] 13 | 5 14 | """ 15 | 16 | 17 | def get_slices(): 18 | """ 19 | >>> my_numbers = (1, 1, 2, 3, 5, 8, 13, 21) 20 | >>> my_numbers[2:5] 21 | (2, 3, 5) 22 | >>> my_numbers[:3] 23 | (1, 1, 2) 24 | >>> my_numbers[3:] 25 | (3, 5, 8, 13, 21) 26 | >>> my_numbers[::] 27 | (1, 1, 2, 3, 5, 8, 13, 21) 28 | >>> my_numbers[1:7:2] 29 | (1, 3, 8) 30 | 31 | >>> interval = slice(1, 7, 2) 32 | >>> my_numbers[interval] 33 | (1, 3, 8) 34 | 35 | >>> interval = slice(None, 3) 36 | >>> my_numbers[interval] == my_numbers[:3] 37 | True 38 | """ 39 | 40 | 41 | def main(): 42 | index_last() 43 | get_slices() 44 | fail_count, _ = doctest.testmod(verbose=True) 45 | raise SystemExit(fail_count) 46 | 47 | 48 | if __name__ == "__main__": 49 | main() 50 | -------------------------------------------------------------------------------- /Chapter02/iterables.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 2: Pythonic Code 2 | 3 | > Iterable Objects 4 | """ 5 | from datetime import timedelta 6 | from datetime import date 7 | 8 | 9 | class DateRangeIterable: 10 | """An iterable that contains its own iterator object.""" 11 | 12 | def __init__(self, start_date, end_date): 13 | self.start_date = start_date 14 | self.end_date = end_date 15 | self._present_day = start_date 16 | 17 | def __iter__(self): 18 | return self 19 | 20 | def __next__(self): 21 | if self._present_day >= self.end_date: 22 | raise StopIteration 23 | today = self._present_day 24 | self._present_day += timedelta(days=1) 25 | return today 26 | 27 | 28 | class DateRangeContainerIterable: 29 | """An range that builds its iteration through a generator.""" 30 | 31 | def __init__(self, start_date, end_date): 32 | self.start_date = start_date 33 | self.end_date = end_date 34 | 35 | def __iter__(self): 36 | current_day = self.start_date 37 | while current_day < self.end_date: 38 | yield current_day 39 | current_day += timedelta(days=1) 40 | 41 | 42 | class DateRangeSequence: 43 | """An range created by wrapping a sequence.""" 44 | 45 | def __init__(self, start_date, end_date): 46 | self.start_date = start_date 47 | self.end_date = end_date 48 | self._range = self._create_range() 49 | 50 | def _create_range(self): 51 | days = [] 52 | current_day = self.start_date 53 | while current_day < self.end_date: 54 | days.append(current_day) 55 | current_day += timedelta(days=1) 56 | return days 57 | 58 | def __getitem__(self, day_no): 59 | return self._range[day_no] 60 | 61 | def __len__(self): 62 | return len(self._range) 63 | -------------------------------------------------------------------------------- /Chapter02/properties.py: -------------------------------------------------------------------------------- 1 | """Chapter 2 - Properties""" 2 | 3 | import re 4 | 5 | EMAIL_FORMAT = re.compile(r"[^@]+@[^@]+\.[^@]+") 6 | 7 | 8 | def is_valid_email(potentially_valid_email: str): 9 | return re.match(EMAIL_FORMAT, potentially_valid_email) is not None 10 | 11 | 12 | class User: 13 | def __init__(self, username): 14 | self.username = username 15 | self._email = None 16 | 17 | @property 18 | def email(self): 19 | return self._email 20 | 21 | @email.setter 22 | def email(self, new_email): 23 | if not is_valid_email(new_email): 24 | raise ValueError( 25 | f"Can't set {new_email} as it's not a valid email" 26 | ) 27 | self._email = new_email 28 | -------------------------------------------------------------------------------- /Chapter02/sequences.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 2: Pythonic Code 2 | 3 | > Sequences 4 | """ 5 | 6 | 7 | class Items: 8 | def __init__(self, *values): 9 | self._values = list(values) 10 | 11 | def __len__(self): 12 | return len(self._values) 13 | 14 | def __getitem__(self, item): 15 | return self._values.__getitem__(item) 16 | -------------------------------------------------------------------------------- /Chapter02/test_ch02.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from datetime import datetime, timedelta 3 | 4 | from caveats import BadList, GoodList 5 | from dynamic import DynamicAttributes 6 | from iterables import ( 7 | DateRangeContainerIterable, 8 | DateRangeIterable, 9 | DateRangeSequence, 10 | ) 11 | from properties import User, is_valid_email 12 | from sequences import Items 13 | 14 | 15 | class TestCaveats(unittest.TestCase): 16 | def test_bad_list(self): 17 | bl = BadList((0, 1, 2, 3, 4, 5)) 18 | self.assertEqual(bl[0], "[even] 0") 19 | self.assertEqual(bl[3], "[odd] 3") 20 | self.assertRaises(TypeError, str.join, bl) 21 | 22 | def test_good_list(self): 23 | gl = GoodList((0, 1, 2)) 24 | self.assertEqual(gl[0], "[even] 0") 25 | self.assertEqual(gl[1], "[odd] 1") 26 | 27 | expected = "[even] 0; [odd] 1; [even] 2" 28 | self.assertEqual("; ".join(gl), expected) 29 | 30 | 31 | class TestSequences(unittest.TestCase): 32 | def test_items(self): 33 | items = Items(1, 2, 3, 4, 5) 34 | 35 | self.assertEqual(items[-1], 5) 36 | self.assertEqual(items[0], 1) 37 | self.assertEqual(len(items), 5) 38 | 39 | 40 | class TestProperties(unittest.TestCase): 41 | def test_is_valid_email(self): 42 | data = ("user@domain.com", "user.surname@something.org") 43 | for email in data: 44 | self.assertTrue(is_valid_email(email)) 45 | 46 | def test_invalid_email(self): 47 | self.assertFalse(is_valid_email("invalid")) 48 | 49 | def test_user_valid_email(self): 50 | user = User("username") 51 | user.email = "something@domain.com" 52 | self.assertEqual(user.email, "something@domain.com") 53 | 54 | def test_user_invalid_domain(self): 55 | user = User("username") 56 | with self.assertRaisesRegex( 57 | ValueError, "Can't set .* not a valid email" 58 | ): 59 | user.email = "something" 60 | 61 | 62 | class TestIterables(unittest.TestCase): 63 | def setUp(self): 64 | self.start_date = datetime(2016, 7, 17) 65 | self.end_date = datetime(2016, 7, 24) 66 | self.expected = [datetime(2016, 7, i) for i in range(17, 24)] 67 | 68 | def _base_test_date_range(self, range_cls): 69 | date_range = range_cls(self.start_date, self.end_date) 70 | self.assertListEqual(list(date_range), self.expected) 71 | self.assertEqual(date_range.start_date, self.start_date) 72 | self.assertEqual(date_range.end_date, self.end_date) 73 | 74 | def test_date_range(self): 75 | for range_cls in ( 76 | DateRangeIterable, 77 | DateRangeContainerIterable, 78 | DateRangeSequence, 79 | ): 80 | with self.subTest(type_=range_cls.__name__): 81 | self._base_test_date_range(range_cls) 82 | 83 | def test_date_range_sequence(self): 84 | date_range = DateRangeSequence(self.start_date, self.end_date) 85 | 86 | self.assertEqual(date_range[0], self.start_date) 87 | self.assertEqual(date_range[-1], self.end_date - timedelta(days=1)) 88 | self.assertEqual(len(date_range), len(self.expected)) 89 | 90 | 91 | class TestDynamic(unittest.TestCase): 92 | def test_dynamic_attributes(self): 93 | dyn = DynamicAttributes("value") 94 | 95 | self.assertEqual(dyn.attribute, "value") 96 | self.assertEqual(dyn.fallback_test, "[fallback resolved] test") 97 | self.assertEqual(getattr(dyn, "something", "default"), "default") 98 | with self.assertRaisesRegex(AttributeError, ".* has no attribute \S+"): 99 | dyn.something_not_found 100 | 101 | 102 | if __name__ == "__main__": 103 | unittest.main() 104 | -------------------------------------------------------------------------------- /Chapter03/Makefile: -------------------------------------------------------------------------------- 1 | PYTHON:=$(VIRTUAL_ENV)/bin/python 2 | 3 | test: 4 | @$(PYTHON) -m doctest *.py 5 | @$(PYTHON) -m unittest *.py 6 | 7 | clean: 8 | find . -name "*.swp" -o -name "__pycache__" | xargs rm -fr 9 | 10 | .PHONY: test clean 11 | -------------------------------------------------------------------------------- /Chapter03/README.rst: -------------------------------------------------------------------------------- 1 | Chapter 03 - General traits of good code 2 | ======================================== 3 | 4 | Run the tests:: 5 | 6 | make test -------------------------------------------------------------------------------- /Chapter03/exceptions_1.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 3: General Traits of Good Code 2 | 3 | > Error Handling - Exceptions 4 | """ 5 | import logging 6 | import time 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class Connector: 12 | """Abstract the connection to a database.""" 13 | 14 | def connect(self): 15 | """Connect to a data source.""" 16 | return self 17 | 18 | @staticmethod 19 | def send(data): 20 | return data 21 | 22 | 23 | class Event: 24 | def __init__(self, payload): 25 | self._payload = payload 26 | 27 | def decode(self): 28 | return f"decoded {self._payload}" 29 | 30 | 31 | class DataTransport: 32 | """An example of an object badly handling exceptions of different levels.""" 33 | 34 | retry_threshold: int = 5 35 | retry_n_times: int = 3 36 | 37 | def __init__(self, connector): 38 | self._connector = connector 39 | self.connection = None 40 | 41 | def deliver_event(self, event): 42 | try: 43 | self.connect() 44 | data = event.decode() 45 | self.send(data) 46 | except ConnectionError as e: 47 | logger.info("connection error detected: %s", e) 48 | raise 49 | except ValueError as e: 50 | logger.error("%r contains incorrect data: %s", event, e) 51 | raise 52 | 53 | def connect(self): 54 | for _ in range(self.retry_n_times): 55 | try: 56 | self.connection = self._connector.connect() 57 | except ConnectionError as e: 58 | logger.info( 59 | "%s: attempting new connection in %is", 60 | e, 61 | self.retry_threshold, 62 | ) 63 | time.sleep(self.retry_threshold) 64 | else: 65 | return self.connection 66 | raise ConnectionError( 67 | f"Couldn't connect after {self.retry_n_times} times" 68 | ) 69 | 70 | def send(self, data): 71 | return self.connection.send(data) 72 | -------------------------------------------------------------------------------- /Chapter03/exceptions_2.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 3: General Traits of Good Code 2 | 3 | > Error Handling - Exceptions 4 | """ 5 | import logging 6 | import time 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class Connector: 12 | """Abstract the connection to a database.""" 13 | 14 | def connect(self): 15 | """Connect to a data source.""" 16 | return self 17 | 18 | @staticmethod 19 | def send(data): 20 | return data 21 | 22 | 23 | class Event: 24 | def __init__(self, payload): 25 | self._payload = payload 26 | 27 | def decode(self): 28 | return f"decoded {self._payload}" 29 | 30 | 31 | def connect_with_retry(connector, retry_n_times, retry_threshold=5): 32 | """Tries to establish the connection of retrying 33 | . 34 | 35 | If it can connect, returns the connection object. 36 | If it's not possible after the retries, raises ConnectionError 37 | 38 | :param connector: An object with a `.connect()` method. 39 | :param retry_n_times int: The number of times to try to call 40 | ``connector.connect()``. 41 | :param retry_threshold int: The time lapse between retry calls. 42 | 43 | """ 44 | for _ in range(retry_n_times): 45 | try: 46 | return connector.connect() 47 | except ConnectionError as e: 48 | logger.info( 49 | "%s: attempting new connection in %is", e, retry_threshold 50 | ) 51 | time.sleep(retry_threshold) 52 | exc = ConnectionError(f"Couldn't connect after {retry_n_times} times") 53 | logger.exception(exc) 54 | raise exc 55 | 56 | 57 | class DataTransport: 58 | """An example of an object that separates the exception handling by 59 | abstraction levels. 60 | """ 61 | 62 | retry_threshold: int = 5 63 | retry_n_times: int = 3 64 | 65 | def __init__(self, connector): 66 | self._connector = connector 67 | self.connection = None 68 | 69 | def deliver_event(self, event): 70 | self.connection = connect_with_retry( 71 | self._connector, self.retry_n_times, self.retry_threshold 72 | ) 73 | self.send(event) 74 | 75 | def send(self, event): 76 | try: 77 | return self.connection.send(event.decode()) 78 | except ValueError as e: 79 | logger.error("%r contains incorrect data: %s", event, e) 80 | raise 81 | -------------------------------------------------------------------------------- /Chapter03/exceptions_3.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 3: General Traits of Good Code 2 | 3 | > Exceptions 4 | """ 5 | 6 | 7 | class InternalDataError(Exception): 8 | """An exception with the data of our domain problem.""" 9 | 10 | 11 | def process(data_dictionary, record_id): 12 | try: 13 | return data_dictionary[record_id] 14 | except KeyError as e: 15 | raise InternalDataError("Record not present") from e 16 | -------------------------------------------------------------------------------- /Chapter03/inheritance_antipattern.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 3: Traits of good code 2 | 3 | > Inheritance & Composition - Inheritance anti-pattern 4 | """ 5 | import collections 6 | from datetime import datetime 7 | from unittest import TestCase, main 8 | 9 | 10 | class TransactionalPolicy(collections.UserDict): 11 | """Example of an incorrect use of inheritance.""" 12 | 13 | def change_in_policy(self, customer_id, **new_policy_data): 14 | self[customer_id].update(**new_policy_data) 15 | 16 | 17 | class TestPolicy(TestCase): 18 | def test_get_policy(self): 19 | policy = TransactionalPolicy( 20 | { 21 | "client001": { 22 | "fee": 1000.0, 23 | "expiration_date": datetime(2020, 1, 3), 24 | } 25 | } 26 | ) 27 | self.assertDictEqual( 28 | policy["client001"], 29 | {"fee": 1000.0, "expiration_date": datetime(2020, 1, 3)}, 30 | ) 31 | 32 | policy.change_in_policy( 33 | "client001", expiration_date=datetime(2020, 1, 4) 34 | ) 35 | self.assertDictEqual( 36 | policy["client001"], 37 | {"fee": 1000.0, "expiration_date": datetime(2020, 1, 4)}, 38 | ) 39 | 40 | 41 | if __name__ == "__main__": 42 | main() 43 | -------------------------------------------------------------------------------- /Chapter03/inheritance_patterns.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 3: Traits of good code 2 | 3 | > Inheritance & Composition: use of composition 4 | """ 5 | 6 | from datetime import datetime 7 | from unittest import TestCase, main 8 | 9 | 10 | class TransactionalPolicy: 11 | """Example refactored to use composition.""" 12 | 13 | def __init__(self, policy_data, **extra_data): 14 | self._data = {**policy_data, **extra_data} 15 | 16 | def change_in_policy(self, customer_id, **new_policy_data): 17 | self._data[customer_id].update(**new_policy_data) 18 | 19 | def __getitem__(self, customer_id): 20 | return self._data[customer_id] 21 | 22 | def __len__(self): 23 | return len(self._data) 24 | 25 | 26 | class TestPolicy(TestCase): 27 | def test_get_policy(self): 28 | policy = TransactionalPolicy( 29 | { 30 | "client001": { 31 | "fee": 1000.0, 32 | "expiration_date": datetime(2020, 1, 3), 33 | } 34 | } 35 | ) 36 | self.assertDictEqual( 37 | policy["client001"], 38 | {"fee": 1000.0, "expiration_date": datetime(2020, 1, 3)}, 39 | ) 40 | 41 | policy.change_in_policy( 42 | "client001", expiration_date=datetime(2020, 1, 4) 43 | ) 44 | self.assertDictEqual( 45 | policy["client001"], 46 | {"fee": 1000.0, "expiration_date": datetime(2020, 1, 4)}, 47 | ) 48 | 49 | 50 | if __name__ == "__main__": 51 | main() 52 | -------------------------------------------------------------------------------- /Chapter03/kis.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 3: General traits of good code 2 | 3 | > Keep It Simple 4 | """ 5 | 6 | 7 | class ComplicatedNamespace: 8 | """An convoluted example of initializing an object with some properties. 9 | 10 | >>> cn = ComplicatedNamespace.init_with_data( 11 | ... id_=42, user="root", location="127.0.0.1", extra="excluded" 12 | ... ) 13 | >>> cn.id_, cn.user, cn.location 14 | (42, 'root', '127.0.0.1') 15 | 16 | >>> hasattr(cn, "extra") 17 | False 18 | 19 | """ 20 | 21 | ACCEPTED_VALUES = ("id_", "user", "location") 22 | 23 | @classmethod 24 | def init_with_data(cls, **data): 25 | instance = cls() 26 | for key, value in data.items(): 27 | if key in cls.ACCEPTED_VALUES: 28 | setattr(instance, key, value) 29 | return instance 30 | 31 | 32 | class Namespace: 33 | """Create an object from keyword arguments. 34 | 35 | >>> cn = Namespace( 36 | ... id_=42, user="root", location="127.0.0.1", extra="excluded" 37 | ... ) 38 | >>> cn.id_, cn.user, cn.location 39 | (42, 'root', '127.0.0.1') 40 | 41 | >>> hasattr(cn, "extra") 42 | False 43 | """ 44 | 45 | ACCEPTED_VALUES = ("id_", "user", "location") 46 | 47 | def __init__(self, **data): 48 | accepted_data = { 49 | k: v for k, v in data.items() if k in self.ACCEPTED_VALUES 50 | } 51 | self.__dict__.update(accepted_data) 52 | -------------------------------------------------------------------------------- /Chapter03/multiple_inheritance.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 3: General traits of good code 2 | 3 | > Multiple inheritance: MRO 4 | 5 | """ 6 | 7 | 8 | class BaseModule: 9 | module_name = "top" 10 | 11 | def __init__(self, module_name): 12 | self.name = module_name 13 | 14 | def __str__(self): 15 | return f"{self.module_name}:{self.name}" 16 | 17 | 18 | class BaseModule1(BaseModule): 19 | module_name = "module-1" 20 | 21 | 22 | class BaseModule2(BaseModule): 23 | module_name = "module-2" 24 | 25 | 26 | class BaseModule3(BaseModule): 27 | module_name = "module-3" 28 | 29 | 30 | class ConcreteModuleA12(BaseModule1, BaseModule2): 31 | """Extend 1 & 2 32 | 33 | >>> str(ConcreteModuleA12('name')) 34 | 'module-1:name' 35 | """ 36 | 37 | 38 | class ConcreteModuleB23(BaseModule2, BaseModule3): 39 | """Extend 2 & 3 40 | 41 | >>> str(ConcreteModuleB23("test")) 42 | 'module-2:test' 43 | """ 44 | -------------------------------------------------------------------------------- /Chapter03/multiple_inheritance_2.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 3: General traits of good code 2 | 3 | > Multiple inheritance: Mixins 4 | 5 | """ 6 | 7 | 8 | class BaseTokenizer: 9 | """ 10 | >>> tk = BaseTokenizer("28a2320b-fd3f-4627-9792-a2b38e3c46b0") 11 | >>> list(tk) 12 | ['28a2320b', 'fd3f', '4627', '9792', 'a2b38e3c46b0'] 13 | """ 14 | 15 | def __init__(self, str_token): 16 | self.str_token = str_token 17 | 18 | def __iter__(self): 19 | yield from self.str_token.split("-") 20 | 21 | 22 | class UpperIterableMixin: 23 | def __iter__(self): 24 | return map(str.upper, super().__iter__()) 25 | 26 | 27 | class Tokenizer(UpperIterableMixin, BaseTokenizer): 28 | """ 29 | >>> tk = Tokenizer("28a2320b-fd3f-4627-9792-a2b38e3c46b0") 30 | >>> list(tk) 31 | ['28A2320B', 'FD3F', '4627', '9792', 'A2B38E3C46B0'] 32 | """ 33 | -------------------------------------------------------------------------------- /Chapter03/orthogonal.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 3: General traits of good code 2 | 3 | > Orthogonality 4 | 5 | """ 6 | 7 | 8 | def calculate_price(base_price: float, tax: float, discount: float) -> float: 9 | """ 10 | >>> calculate_price(10, 0.2, 0.5) 11 | 6.0 12 | 13 | >>> calculate_price(10, 0.2, 0) 14 | 12.0 15 | """ 16 | return (base_price * (1 + tax)) * (1 - discount) 17 | 18 | 19 | def show_price(price: float) -> str: 20 | """ 21 | >>> show_price(1000) 22 | '$ 1,000.00' 23 | 24 | >>> show_price(1_250.75) 25 | '$ 1,250.75' 26 | """ 27 | return "$ {0:,.2f}".format(price) 28 | 29 | 30 | def str_final_price( 31 | base_price: float, tax: float, discount: float, fmt_function=str 32 | ) -> str: 33 | """ 34 | 35 | >>> str_final_price(10, 0.2, 0.5) 36 | '6.0' 37 | 38 | >>> str_final_price(1000, 0.2, 0) 39 | '1200.0' 40 | 41 | >>> str_final_price(1000, 0.2, 0.1, fmt_function=show_price) 42 | '$ 1,080.00' 43 | 44 | """ 45 | return fmt_function(calculate_price(base_price, tax, discount)) 46 | -------------------------------------------------------------------------------- /Chapter03/packing_1.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 3: General Traits of Good Code 2 | 3 | > Packing / unpacking 4 | """ 5 | 6 | 7 | USERS = [(i, f"first_name_{i}", f"last_name_{i}") for i in range(1_000)] 8 | 9 | 10 | class User: 11 | def __init__(self, user_id, first_name, last_name): 12 | self.user_id = user_id 13 | self.first_name = first_name 14 | self.last_name = last_name 15 | 16 | def __repr__(self): 17 | return f"{self.__class__.__name__}({self.user_id!r}, {self.first_name!r}, {self.last_name!r})" 18 | 19 | 20 | def bad_users_from_rows(dbrows) -> list: 21 | """A bad case (non-pythonic) of creating ``User``s from DB rows.""" 22 | return [User(row[0], row[1], row[2]) for row in dbrows] 23 | 24 | 25 | def users_from_rows(dbrows) -> list: 26 | """Create ``User``s from DB rows.""" 27 | return [ 28 | User(user_id, first_name, last_name) 29 | for (user_id, first_name, last_name) in dbrows 30 | ] 31 | -------------------------------------------------------------------------------- /Chapter03/test_exceptions_1.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 3: General Traits of Good Code 2 | 3 | """ 4 | 5 | import unittest 6 | from unittest.mock import Mock, patch 7 | 8 | from exceptions_1 import DataTransport, Event 9 | 10 | 11 | class FailsAfterNTimes: 12 | def __init__(self, n_times: int, with_exception) -> None: 13 | self._remaining_failures = n_times 14 | self._exception = with_exception 15 | 16 | def connect(self): 17 | self._remaining_failures -= 1 18 | if self._remaining_failures >= 0: 19 | raise self._exception 20 | return self 21 | 22 | def send(self, data): 23 | return data 24 | 25 | 26 | @patch("time.sleep", return_value=0) 27 | class TestTransport(unittest.TestCase): 28 | def test_connects_after_retries(self, sleep): 29 | data_transport = DataTransport( 30 | FailsAfterNTimes(2, with_exception=ConnectionError) 31 | ) 32 | data_transport.send = Mock() 33 | data_transport.deliver_event(Event("test")) 34 | 35 | data_transport.send.assert_called_once_with("decoded test") 36 | 37 | assert ( 38 | sleep.call_count == DataTransport.retry_n_times - 1 39 | ), sleep.call_count 40 | 41 | def test_connects_directly(self, sleep): 42 | connector = Mock() 43 | data_transport = DataTransport(connector) 44 | data_transport.send = Mock() 45 | data_transport.deliver_event(Event("test")) 46 | 47 | connector.connect.assert_called_once() 48 | assert sleep.call_count == 0 49 | 50 | def test_connection_error(self, sleep): 51 | data_transport = DataTransport( 52 | Mock(connect=Mock(side_effect=ConnectionError)) 53 | ) 54 | 55 | self.assertRaisesRegex( 56 | ConnectionError, 57 | "Couldn't connect after \d+ times", 58 | data_transport.deliver_event, 59 | Event("connection error"), 60 | ) 61 | assert sleep.call_count == DataTransport.retry_n_times 62 | 63 | def test_error_in_event(self, sleep): 64 | data_transport = DataTransport(Mock()) 65 | event = Mock(decode=Mock(side_effect=ValueError)) 66 | with patch("exceptions_1.logger.error"): 67 | self.assertRaises(ValueError, data_transport.deliver_event, event) 68 | 69 | assert not sleep.called 70 | 71 | 72 | if __name__ == "__main__": 73 | unittest.main() 74 | -------------------------------------------------------------------------------- /Chapter03/test_exceptions_2.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 3: General Traits of Good Code 2 | 3 | """ 4 | 5 | import unittest 6 | from unittest.mock import Mock, patch 7 | 8 | from exceptions_2 import DataTransport, Event 9 | 10 | 11 | class FailsAfterNTimes: 12 | def __init__(self, n_times: int, with_exception) -> None: 13 | self._remaining_failures = n_times 14 | self._exception = with_exception 15 | 16 | def connect(self): 17 | self._remaining_failures -= 1 18 | if self._remaining_failures >= 0: 19 | raise self._exception 20 | return self 21 | 22 | def send(self, data): 23 | return data 24 | 25 | 26 | @patch("time.sleep", return_value=0) 27 | class TestTransport(unittest.TestCase): 28 | def setUp(self): 29 | self.error_log = patch("exceptions_2.logger.error") 30 | self.error_log.start() 31 | 32 | def tearDown(self): 33 | self.error_log.stop() 34 | 35 | def test_connects_after_retries(self, sleep): 36 | data_transport = DataTransport( 37 | FailsAfterNTimes(2, with_exception=ConnectionError) 38 | ) 39 | data_transport.send = Mock() 40 | event = Event("test") 41 | data_transport.deliver_event(event) 42 | 43 | data_transport.send.assert_called_once_with(event) 44 | 45 | assert ( 46 | sleep.call_count == DataTransport.retry_n_times - 1 47 | ), sleep.call_count 48 | 49 | def test_connects_directly(self, sleep): 50 | connector = Mock() 51 | data_transport = DataTransport(connector) 52 | data_transport.send = Mock() 53 | data_transport.deliver_event(Event("test")) 54 | 55 | connector.connect.assert_called_once() 56 | assert sleep.call_count == 0 57 | 58 | def test_connection_error(self, sleep): 59 | data_transport = DataTransport( 60 | Mock(connect=Mock(side_effect=ConnectionError)) 61 | ) 62 | 63 | self.assertRaisesRegex( 64 | ConnectionError, 65 | "Couldn't connect after \d+ times", 66 | data_transport.deliver_event, 67 | Event("connection error"), 68 | ) 69 | assert sleep.call_count == DataTransport.retry_n_times 70 | 71 | def test_error_in_event(self, sleep): 72 | data_transport = DataTransport(Mock()) 73 | event = Mock(decode=Mock(side_effect=ValueError)) 74 | self.assertRaises(ValueError, data_transport.deliver_event, event) 75 | 76 | assert not sleep.called 77 | 78 | 79 | if __name__ == "__main__": 80 | unittest.main() 81 | -------------------------------------------------------------------------------- /Chapter03/test_exceptions_3.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 3: General Traits of Good Code 2 | """ 3 | 4 | 5 | import unittest 6 | 7 | from exceptions_3 import InternalDataError, process 8 | 9 | 10 | class TestExceptions(unittest.TestCase): 11 | def test_original_exception(self): 12 | try: 13 | process({}, "anything") 14 | except InternalDataError as e: 15 | self.assertIsInstance(e.__cause__, KeyError) 16 | -------------------------------------------------------------------------------- /Chapter04/Makefile: -------------------------------------------------------------------------------- 1 | PYTHON:=$(VIRTUAL_ENV)/bin/python 2 | 3 | clean: 4 | find . -name "*.swp" -o -name "__pycache__" | xargs rm -fr 5 | 6 | test: 7 | @$(PYTHON) -m doctest *.py 8 | 9 | .PHONY: clean test 10 | -------------------------------------------------------------------------------- /Chapter04/README.rst: -------------------------------------------------------------------------------- 1 | Chapter 04: The SOLID Principles 2 | ================================ 3 | 4 | Run tests:: 5 | 6 | make test -------------------------------------------------------------------------------- /Chapter04/lsp_1.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 4, The SOLID Principles 2 | 3 | > Liskov's Substitution Principle (LSP) 4 | 5 | Detecting violations of LSP through tools (mypy, pylint, etc.) 6 | """ 7 | 8 | 9 | class Event: 10 | ... 11 | 12 | def meets_condition(self, event_data: dict) -> bool: 13 | return False 14 | 15 | 16 | class LoginEvent(Event): 17 | def meets_condition(self, event_data: list) -> bool: 18 | return bool(event_data) 19 | 20 | 21 | class LogoutEvent(Event): 22 | def meets_condition(self, event_data: dict, override: bool) -> bool: 23 | if override: 24 | return True 25 | ... 26 | -------------------------------------------------------------------------------- /Chapter04/lsp_2.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 4, The SOLID Principles 2 | 3 | Liskov's Substitution Principle (LSP) 4 | 5 | Detecting violations of LSP on methods that violate the contract defined. 6 | 7 | * Events are defined by a contract (DbC, Design by Contract). 8 | * The contract precondition is exercised only once, and after that the 9 | ``SystemMonitor`` should be able to work with any of them interchangeably. 10 | 11 | """ 12 | 13 | 14 | class Event: 15 | def __init__(self, raw_data): 16 | self.raw_data = raw_data 17 | 18 | @staticmethod 19 | def meets_condition(event_data: dict): 20 | return False 21 | 22 | @staticmethod 23 | def meets_condition_pre(event_data: dict): 24 | """Precondition of the contract of this interface. 25 | 26 | Validate that the ``event_data`` parameter is properly formed. 27 | """ 28 | assert isinstance(event_data, dict), f"{event_data!r} is not a dict" 29 | for moment in ("before", "after"): 30 | assert moment in event_data, f"{moment} not in {event_data}" 31 | assert isinstance(event_data[moment], dict) 32 | 33 | 34 | class UnknownEvent(Event): 35 | """A type of event that cannot be identified from its data""" 36 | 37 | 38 | class LoginEvent(Event): 39 | @staticmethod 40 | def meets_condition(event_data: dict): 41 | return ( 42 | event_data["before"].get("session") == 0 43 | and event_data["after"].get("session") == 1 44 | ) 45 | 46 | 47 | class LogoutEvent(Event): 48 | @staticmethod 49 | def meets_condition(event_data: dict): 50 | return ( 51 | event_data["before"].get("session") == 1 52 | and event_data["after"].get("session") == 0 53 | ) 54 | 55 | 56 | class TransactionEvent(Event): 57 | """Represents a transaction that has just occurred on the system.""" 58 | 59 | @staticmethod 60 | def meets_condition(event_data: dict): 61 | return event_data["after"].get("transaction") is not None 62 | 63 | 64 | class SystemMonitor: 65 | """Identify events that occurred in the system 66 | 67 | >>> l1 = SystemMonitor({"before": {"session": 0}, "after": {"session": 1}}) 68 | >>> l1.identify_event().__class__.__name__ 69 | 'LoginEvent' 70 | 71 | >>> l2 = SystemMonitor({"before": {"session": 1}, "after": {"session": 0}}) 72 | >>> l2.identify_event().__class__.__name__ 73 | 'LogoutEvent' 74 | 75 | >>> l3 = SystemMonitor({"before": {"session": 1}, "after": {"session": 1}}) 76 | >>> l3.identify_event().__class__.__name__ 77 | 'UnknownEvent' 78 | 79 | >>> l4 = SystemMonitor({"before": {}, "after": {"transaction": "Tx001"}}) 80 | >>> l4.identify_event().__class__.__name__ 81 | 'TransactionEvent' 82 | 83 | """ 84 | 85 | def __init__(self, event_data): 86 | self.event_data = event_data 87 | 88 | def identify_event(self): 89 | Event.meets_condition_pre(self.event_data) 90 | event_cls = next( 91 | ( 92 | event_cls 93 | for event_cls in Event.__subclasses__() 94 | if event_cls.meets_condition(self.event_data) 95 | ), 96 | UnknownEvent, 97 | ) 98 | return event_cls(self.event_data) 99 | -------------------------------------------------------------------------------- /Chapter04/openclosed_1.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 4 2 | 3 | The open/closed principle 4 | 5 | Counter-example of the open/closed principle. 6 | 7 | An example that does not comply with this principle and should be refactored. 8 | """ 9 | 10 | 11 | class Event: 12 | def __init__(self, raw_data): 13 | self.raw_data = raw_data 14 | 15 | 16 | class UnknownEvent(Event): 17 | """A type of event that cannot be identified from its data.""" 18 | 19 | 20 | class LoginEvent(Event): 21 | """A event representing a user that has just entered the system.""" 22 | 23 | 24 | class LogoutEvent(Event): 25 | """An event representing a user that has just left the system.""" 26 | 27 | 28 | class SystemMonitor: 29 | """Identify events that occurred in the system 30 | 31 | >>> l1 = SystemMonitor({"before": {"session": 0}, "after": {"session": 1}}) 32 | >>> l1.identify_event().__class__.__name__ 33 | 'LoginEvent' 34 | 35 | >>> l2 = SystemMonitor({"before": {"session": 1}, "after": {"session": 0}}) 36 | >>> l2.identify_event().__class__.__name__ 37 | 'LogoutEvent' 38 | 39 | >>> l3 = SystemMonitor({"before": {"session": 1}, "after": {"session": 1}}) 40 | >>> l3.identify_event().__class__.__name__ 41 | 'UnknownEvent' 42 | 43 | """ 44 | 45 | def __init__(self, event_data): 46 | self.event_data = event_data 47 | 48 | def identify_event(self): 49 | if ( 50 | self.event_data["before"]["session"] == 0 51 | and self.event_data["after"]["session"] == 1 52 | ): 53 | return LoginEvent(self.event_data) 54 | elif ( 55 | self.event_data["before"]["session"] == 1 56 | and self.event_data["after"]["session"] == 0 57 | ): 58 | return LogoutEvent(self.event_data) 59 | 60 | return UnknownEvent(self.event_data) 61 | -------------------------------------------------------------------------------- /Chapter04/openclosed_2.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 4 2 | 3 | The open/closed principle. 4 | 5 | Example with the corrected code, in order to comply with the principle. 6 | """ 7 | 8 | 9 | class Event: 10 | def __init__(self, raw_data): 11 | self.raw_data = raw_data 12 | 13 | @staticmethod 14 | def meets_condition(event_data: dict): 15 | return False 16 | 17 | 18 | class UnknownEvent(Event): 19 | """A type of event that cannot be identified from its data""" 20 | 21 | 22 | class LoginEvent(Event): 23 | @staticmethod 24 | def meets_condition(event_data: dict): 25 | return ( 26 | event_data["before"]["session"] == 0 27 | and event_data["after"]["session"] == 1 28 | ) 29 | 30 | 31 | class LogoutEvent(Event): 32 | @staticmethod 33 | def meets_condition(event_data: dict): 34 | return ( 35 | event_data["before"]["session"] == 1 36 | and event_data["after"]["session"] == 0 37 | ) 38 | 39 | 40 | class SystemMonitor: 41 | """Identify events that occurred in the system 42 | 43 | >>> l1 = SystemMonitor({"before": {"session": 0}, "after": {"session": 1}}) 44 | >>> l1.identify_event().__class__.__name__ 45 | 'LoginEvent' 46 | 47 | >>> l2 = SystemMonitor({"before": {"session": 1}, "after": {"session": 0}}) 48 | >>> l2.identify_event().__class__.__name__ 49 | 'LogoutEvent' 50 | 51 | >>> l3 = SystemMonitor({"before": {"session": 1}, "after": {"session": 1}}) 52 | >>> l3.identify_event().__class__.__name__ 53 | 'UnknownEvent' 54 | 55 | """ 56 | 57 | def __init__(self, event_data): 58 | self.event_data = event_data 59 | 60 | def identify_event(self): 61 | for event_cls in Event.__subclasses__(): 62 | try: 63 | if event_cls.meets_condition(self.event_data): 64 | return event_cls(self.event_data) 65 | except KeyError: 66 | continue 67 | return UnknownEvent(self.event_data) 68 | -------------------------------------------------------------------------------- /Chapter04/openclosed_3.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 4 2 | 3 | The open/closed principle. 4 | 5 | Example with the corrected code, in order to comply with the principle. 6 | 7 | Extend the logic to prove the ``SystemMonitor`` class is actually closed with 8 | respect to the types of events. 9 | """ 10 | 11 | 12 | class Event: 13 | def __init__(self, raw_data): 14 | self.raw_data = raw_data 15 | 16 | @staticmethod 17 | def meets_condition(event_data: dict): 18 | return False 19 | 20 | 21 | class UnknownEvent(Event): 22 | """A type of event that cannot be identified from its data""" 23 | 24 | 25 | class LoginEvent(Event): 26 | @staticmethod 27 | def meets_condition(event_data: dict): 28 | return ( 29 | event_data["before"]["session"] == 0 30 | and event_data["after"]["session"] == 1 31 | ) 32 | 33 | 34 | class LogoutEvent(Event): 35 | @staticmethod 36 | def meets_condition(event_data: dict): 37 | return ( 38 | event_data["before"]["session"] == 1 39 | and event_data["after"]["session"] == 0 40 | ) 41 | 42 | 43 | class TransactionEvent(Event): 44 | """Represents a transaction that has just occurred on the system.""" 45 | 46 | @staticmethod 47 | def meets_condition(event_data: dict): 48 | return event_data["after"].get("transaction") is not None 49 | 50 | 51 | class SystemMonitor: 52 | """Identify events that occurred in the system 53 | 54 | >>> l1 = SystemMonitor({"before": {"session": 0}, "after": {"session": 1}}) 55 | >>> l1.identify_event().__class__.__name__ 56 | 'LoginEvent' 57 | 58 | >>> l2 = SystemMonitor({"before": {"session": 1}, "after": {"session": 0}}) 59 | >>> l2.identify_event().__class__.__name__ 60 | 'LogoutEvent' 61 | 62 | >>> l3 = SystemMonitor({"before": {"session": 1}, "after": {"session": 1}}) 63 | >>> l3.identify_event().__class__.__name__ 64 | 'UnknownEvent' 65 | 66 | >>> l4 = SystemMonitor({"after": {"transaction": "Tx001"}}) 67 | >>> l4.identify_event().__class__.__name__ 68 | 'TransactionEvent' 69 | 70 | """ 71 | 72 | def __init__(self, event_data): 73 | self.event_data = event_data 74 | 75 | def identify_event(self): 76 | for event_cls in Event.__subclasses__(): 77 | try: 78 | if event_cls.meets_condition(self.event_data): 79 | return event_cls(self.event_data) 80 | except KeyError: 81 | continue 82 | return UnknownEvent(self.event_data) 83 | -------------------------------------------------------------------------------- /Chapter04/srp_1.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 4, The SOLID Principles 2 | 3 | > SRP: Single Responsibility Principle 4 | """ 5 | 6 | 7 | class SystemMonitor: 8 | def load_activity(self): 9 | """Get the events from a source, to be processed.""" 10 | 11 | def identify_events(self): 12 | """Parse the source raw data into events (domain objects).""" 13 | 14 | def stream_events(self): 15 | """Send the parsed events to an external agent.""" 16 | -------------------------------------------------------------------------------- /Chapter05/Makefile: -------------------------------------------------------------------------------- 1 | PYTHON:=$(VIRTUAL_ENV)/bin/python 2 | 3 | clean: 4 | find . -name "*.swp" -o -name "__pycache__" | xargs rm -fr 5 | 6 | test: 7 | @$(PYTHON) -m doctest *.py 8 | @$(PYTHON) -m unittest *.py 9 | 10 | .PHONY: clean test 11 | -------------------------------------------------------------------------------- /Chapter05/README.rst: -------------------------------------------------------------------------------- 1 | Chapter 05 - Decorators 2 | ======================= 3 | 4 | Run the tests with:: 5 | 6 | make test 7 | 8 | Creating Decorators 9 | ^^^^^^^^^^^^^^^^^^^ 10 | 11 | 1. Function decorators 12 | 13 | 1.1 ``decorator_function_1.py``. 14 | 15 | 1.2 ``decorator_function_2.py`` 16 | 17 | 2. Class decorators 18 | 19 | 2.1 ``decorator_class_1.py`` 20 | 21 | 2.2 ``decorator_class_2.py`` 22 | 23 | 2.3 ``decorator_class_3.py`` 24 | 25 | 3. Other decorators (generators, coroutines, etc.). 26 | 27 | 4. Passing Arguments to Decorators 28 | 29 | 4.1 As a decorator function: ``decorator_parametrized_1.py`` 30 | 31 | 4.2 As a decorator object: ``decorator_parametrized_2.py`` 32 | 33 | 34 | Issues to avoid when creating decorators 35 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 36 | 37 | 1. Keep the properties of the original attributes (docstring, name, etc.), 38 | by using ``functools.wraps``. 39 | 40 | 1.1 ``decorator_wraps_1.py`` 41 | 42 | 2. Don't have side effects on the main body of the decorator. This will run 43 | at parsing time, and will most likely fail. 44 | 45 | 2.1 ``decorator_side_effects_1.py`` 46 | 47 | 2.2 ``decorator_side_effects_2.py`` 48 | 49 | 3. Make sure the decorated function is equivalent to the wrapped one, in 50 | terms of inspection, signature checking, etc. 51 | 52 | 3.1 Create decorators that work for functions, methods, static methods, class methods, etc. 53 | 54 | 3.2 Use the ``wrapt`` package to create effective decorators. 55 | 56 | 57 | Other Topics 58 | ^^^^^^^^^^^^ 59 | 60 | * The DRY Principle with Decorators (reusing code). 61 | * Separation of Concerns with Decorators. 62 | 63 | Listings: ``decorator_SoC_{1,2}.py`` 64 | -------------------------------------------------------------------------------- /Chapter05/decorator_SoC_1.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 5: Decorators 2 | 3 | > Separation of Concerns (SoC). 4 | Break a coupled decorator into smaller ones. 5 | """ 6 | import functools 7 | import time 8 | 9 | from log import logger 10 | 11 | 12 | def traced_function(function): 13 | @functools.wraps(function) 14 | def wrapped(*args, **kwargs): 15 | logger.info("started execution of %s", function.__qualname__) 16 | start_time = time.time() 17 | result = function(*args, **kwargs) 18 | logger.info( 19 | "function %s took %.2fs", 20 | function.__qualname__, 21 | time.time() - start_time, 22 | ) 23 | return result 24 | 25 | return wrapped 26 | 27 | 28 | @traced_function 29 | def operation1(): 30 | time.sleep(2) 31 | logger.info("running operation 1") 32 | return 2 33 | -------------------------------------------------------------------------------- /Chapter05/decorator_SoC_2.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 5 2 | 3 | Separation of Concerns (SoC). 4 | Break a coupled decorator into smaller ones. 5 | """ 6 | import time 7 | from functools import wraps 8 | 9 | from log import logger 10 | 11 | 12 | def log_execution(function): 13 | @wraps(function) 14 | def wrapped(*args, **kwargs): 15 | logger.info("started execution of %s", function.__qualname__) 16 | return function(*kwargs, **kwargs) 17 | 18 | return wrapped 19 | 20 | 21 | def measure_time(function): 22 | @wraps(function) 23 | def wrapped(*args, **kwargs): 24 | start_time = time.time() 25 | result = function(*args, **kwargs) 26 | 27 | logger.info( 28 | "function %s took %.2f", 29 | function.__qualname__, 30 | time.time() - start_time, 31 | ) 32 | return result 33 | 34 | return wrapped 35 | 36 | 37 | @measure_time 38 | @log_execution 39 | def operation(): 40 | time.sleep(3) 41 | logger.info("running operation...") 42 | return 33 43 | -------------------------------------------------------------------------------- /Chapter05/decorator_class_1.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 5: Decorators 2 | 3 | > Class decorators. 4 | """ 5 | import unittest 6 | from datetime import datetime 7 | 8 | 9 | class LoginEventSerializer: 10 | def __init__(self, event): 11 | self.event = event 12 | 13 | def serialize(self) -> dict: 14 | return { 15 | "username": self.event.username, 16 | "password": "**redacted**", 17 | "ip": self.event.ip, 18 | "timestamp": self.event.timestamp.strftime("%Y-%m-%d %H:%M"), 19 | } 20 | 21 | 22 | class LoginEvent: 23 | SERIALIZER = LoginEventSerializer 24 | 25 | def __init__(self, username, password, ip, timestamp): 26 | self.username = username 27 | self.password = password 28 | self.ip = ip 29 | self.timestamp = timestamp 30 | 31 | def serialize(self) -> dict: 32 | return self.SERIALIZER(self).serialize() 33 | 34 | 35 | class TestLoginEventSerialized(unittest.TestCase): 36 | def test_serializetion(self): 37 | event = LoginEvent( 38 | "username", "password", "127.0.0.1", datetime(2016, 7, 20, 15, 45) 39 | ) 40 | expected = { 41 | "username": "username", 42 | "password": "**redacted**", 43 | "ip": "127.0.0.1", 44 | "timestamp": "2016-07-20 15:45", 45 | } 46 | self.assertEqual(event.serialize(), expected) 47 | 48 | 49 | if __name__ == "__main__": 50 | unittest.main() 51 | -------------------------------------------------------------------------------- /Chapter05/decorator_class_3.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 5 2 | 3 | Class decorators. 4 | 5 | Reimplement the serialization of the events by applying a class decorator. 6 | Use the @dataclass decorator. 7 | 8 | This code only works in Python 3.7+ 9 | """ 10 | import sys 11 | import unittest 12 | from datetime import datetime 13 | 14 | from decorator_class_2 import ( 15 | Serialization, 16 | format_time, 17 | hide_field, 18 | show_original, 19 | ) 20 | 21 | try: 22 | from dataclasses import dataclass 23 | except ImportError: 24 | 25 | def dataclass(cls): 26 | return cls 27 | 28 | 29 | @Serialization( 30 | username=show_original, 31 | password=hide_field, 32 | ip=show_original, 33 | timestamp=format_time, 34 | ) 35 | @dataclass 36 | class LoginEvent: 37 | username: str 38 | password: str 39 | ip: str 40 | timestamp: datetime 41 | 42 | 43 | class TestLoginEventSerialized(unittest.TestCase): 44 | @unittest.skipIf( 45 | sys.version_info[:3] < (3, 7, 0), reason="Requires Python 3.7+ to run" 46 | ) 47 | def test_serializetion(self): 48 | event = LoginEvent( 49 | "username", "password", "127.0.0.1", datetime(2016, 7, 20, 15, 45) 50 | ) 51 | expected = { 52 | "username": "username", 53 | "password": "**redacted**", 54 | "ip": "127.0.0.1", 55 | "timestamp": "2016-07-20 15:45", 56 | } 57 | self.assertEqual(event.serialize(), expected) 58 | 59 | 60 | if __name__ == "__main__": 61 | unittest.main() 62 | -------------------------------------------------------------------------------- /Chapter05/decorator_function_1.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 5: Decorators 2 | 3 | Creating a decorator to be applied over a function. 4 | """ 5 | 6 | from functools import wraps 7 | from unittest import TestCase, main, mock 8 | 9 | from log import logger 10 | 11 | 12 | class ControlledException(Exception): 13 | """A generic exception on the program's domain.""" 14 | 15 | 16 | def retry(operation): 17 | @wraps(operation) 18 | def wrapped(*args, **kwargs): 19 | last_raised = None 20 | RETRIES_LIMIT = 3 21 | for _ in range(RETRIES_LIMIT): 22 | try: 23 | return operation(*args, **kwargs) 24 | except ControlledException as e: 25 | logger.info("retrying %s", operation.__qualname__) 26 | last_raised = e 27 | raise last_raised 28 | 29 | return wrapped 30 | 31 | 32 | class OperationObject: 33 | """A helper object to test the decorator.""" 34 | 35 | def __init__(self): 36 | self._times_called: int = 0 37 | 38 | def run(self) -> int: 39 | """Base operation for a particular action""" 40 | self._times_called += 1 41 | return self._times_called 42 | 43 | def __str__(self): 44 | return f"{self.__class__.__name__}()" 45 | 46 | __repr__ = __str__ 47 | 48 | 49 | class RunWithFailure: 50 | def __init__( 51 | self, 52 | task: "OperationObject", 53 | fail_n_times: int = 0, 54 | exception_cls=ControlledException, 55 | ): 56 | self._task = task 57 | self._fail_n_times = fail_n_times 58 | self._times_failed = 0 59 | self._exception_cls = exception_cls 60 | 61 | def run(self): 62 | called = self._task.run() 63 | if self._times_failed < self._fail_n_times: 64 | self._times_failed += 1 65 | raise self._exception_cls(f"{self._task!s} failed!") 66 | return called 67 | 68 | 69 | @retry 70 | def run_operation(task): 71 | """Run a particular task, simulating some failures on its execution.""" 72 | return task.run() 73 | 74 | 75 | class RetryDecoratorTest(TestCase): 76 | def setUp(self): 77 | self.info = mock.patch("log.logger.info").start() 78 | 79 | def tearDown(self): 80 | self.info.stop() 81 | 82 | def test_fail_less_than_retry_limit(self): 83 | """Retry = 3, fail = 2, should work""" 84 | task = OperationObject() 85 | failing_task = RunWithFailure(task, fail_n_times=2) 86 | times_run = run_operation(failing_task) 87 | 88 | self.assertEqual(times_run, 3) 89 | self.assertEqual(task._times_called, 3) 90 | 91 | def test_fail_equal_retry_limit(self): 92 | """Retry = fail = 3, will fail""" 93 | task = OperationObject() 94 | failing_task = RunWithFailure(task, fail_n_times=3) 95 | with self.assertRaises(ControlledException): 96 | run_operation(failing_task) 97 | 98 | def test_no_failures(self): 99 | task = OperationObject() 100 | failing_task = RunWithFailure(task, fail_n_times=0) 101 | times_run = run_operation(failing_task) 102 | 103 | self.assertEqual(times_run, 1) 104 | self.assertEqual(task._times_called, 1) 105 | 106 | 107 | if __name__ == "__main__": 108 | main() 109 | -------------------------------------------------------------------------------- /Chapter05/decorator_function_2.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 5: Decorators 2 | 3 | > Function decorators 4 | - Creating a decorator to be applied over a function. 5 | - Implement the decorator as an object. 6 | """ 7 | from functools import wraps 8 | from unittest import TestCase, main, mock 9 | 10 | from decorator_function_1 import (ControlledException, OperationObject, 11 | RunWithFailure) 12 | from log import logger 13 | 14 | 15 | class Retry: 16 | def __init__(self, operation): 17 | self.operation = operation 18 | wraps(operation)(self) 19 | 20 | def __call__(self, *args, **kwargs): 21 | last_raised = None 22 | RETRIES_LIMIT = 3 23 | for _ in range(RETRIES_LIMIT): 24 | try: 25 | return self.operation(*args, **kwargs) 26 | except ControlledException as e: 27 | logger.info("retrying %s", self.operation.__qualname__) 28 | last_raised = e 29 | raise last_raised 30 | 31 | 32 | @Retry 33 | def run_operation(task): 34 | """Run the operation in the task""" 35 | return task.run() 36 | 37 | 38 | class RetryDecoratorTest(TestCase): 39 | def setUp(self): 40 | self.info = mock.patch("log.logger.info").start() 41 | 42 | def tearDown(self): 43 | self.info.stop() 44 | 45 | def test_fail_less_than_retry_limit(self): 46 | """Retry = 3, fail = 2 --> OK""" 47 | task = OperationObject() 48 | failing_task = RunWithFailure(task, fail_n_times=2) 49 | times_run = run_operation(failing_task) 50 | 51 | self.assertEqual(times_run, 3) 52 | self.assertEqual(task._times_called, 3) 53 | 54 | def test_fail_equal_retry_limit(self): 55 | """Retry = fail = 3, will fail""" 56 | task = OperationObject() 57 | failing_task = RunWithFailure(task, fail_n_times=3) 58 | with self.assertRaises(ControlledException): 59 | run_operation(failing_task) 60 | 61 | def test_no_failures(self): 62 | task = OperationObject() 63 | failing_task = RunWithFailure(task, fail_n_times=0) 64 | times_run = run_operation(failing_task) 65 | 66 | self.assertEqual(times_run, 1) 67 | self.assertEqual(task._times_called, 1) 68 | 69 | def test_doc(self): 70 | self.assertEqual( 71 | run_operation.__doc__, "Run the operation in the task" 72 | ) 73 | 74 | 75 | if __name__ == "__main__": 76 | main() 77 | -------------------------------------------------------------------------------- /Chapter05/decorator_parametrized_1.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 5: Decorators 2 | 3 | Parametrized decorators using functions 4 | """ 5 | 6 | from functools import wraps 7 | 8 | from decorator_function_1 import ControlledException 9 | from log import logger 10 | 11 | 12 | RETRIES_LIMIT = 3 13 | 14 | 15 | def with_retry(retries_limit=RETRIES_LIMIT, allowed_exceptions=None): 16 | allowed_exceptions = allowed_exceptions or (ControlledException,) 17 | 18 | def retry(operation): 19 | @wraps(operation) 20 | def wrapped(*args, **kwargs): 21 | last_raised = None 22 | for _ in range(retries_limit): 23 | try: 24 | return operation(*args, **kwargs) 25 | except allowed_exceptions as e: 26 | logger.warning( 27 | "retrying %s due to %s", operation.__qualname__, e 28 | ) 29 | last_raised = e 30 | raise last_raised 31 | 32 | return wrapped 33 | 34 | return retry 35 | 36 | 37 | @with_retry() 38 | def run_operation(task): 39 | return task.run() 40 | 41 | 42 | @with_retry(retries_limit=5) 43 | def run_with_custom_retries_limit(task): 44 | return task.run() 45 | 46 | 47 | @with_retry(allowed_exceptions=(AttributeError,)) 48 | def run_with_custom_exceptions(task): 49 | return task.run() 50 | 51 | 52 | @with_retry( 53 | retries_limit=4, allowed_exceptions=(ZeroDivisionError, AttributeError) 54 | ) 55 | def run_with_custom_parameters(task): 56 | return task.run() 57 | -------------------------------------------------------------------------------- /Chapter05/decorator_parametrized_2.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 5: Decorators 2 | 3 | Parametrized decorators using callable objects. 4 | """ 5 | from functools import wraps 6 | 7 | from decorator_function_1 import ControlledException 8 | from log import logger 9 | 10 | RETRIES_LIMIT = 3 11 | 12 | 13 | class WithRetry: 14 | def __init__(self, retries_limit=RETRIES_LIMIT, allowed_exceptions=None): 15 | self.retries_limit = retries_limit 16 | self.allowed_exceptions = allowed_exceptions or (ControlledException,) 17 | 18 | def __call__(self, operation): 19 | @wraps(operation) 20 | def wrapped(*args, **kwargs): 21 | last_raised = None 22 | 23 | for _ in range(self.retries_limit): 24 | try: 25 | return operation(*args, **kwargs) 26 | except self.allowed_exceptions as e: 27 | logger.info( 28 | "retrying %s due to %s", operation.__qualname__, e 29 | ) 30 | last_raised = e 31 | raise last_raised 32 | 33 | return wrapped 34 | 35 | 36 | @WithRetry() 37 | def run_operation(task): 38 | return task.run() 39 | 40 | 41 | @WithRetry(retries_limit=5) 42 | def run_with_custom_retries_limit(task): 43 | return task.run() 44 | 45 | 46 | @WithRetry(allowed_exceptions=(AttributeError,)) 47 | def run_with_custom_exceptions(task): 48 | return task.run() 49 | 50 | 51 | @WithRetry( 52 | retries_limit=4, allowed_exceptions=(ZeroDivisionError, AttributeError) 53 | ) 54 | def run_with_custom_parameters(task): 55 | return task.run() 56 | -------------------------------------------------------------------------------- /Chapter05/decorator_side_effects_1.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 5: Decorators 2 | 3 | > Undesired side effects on decorators 4 | """ 5 | 6 | import time 7 | from functools import wraps 8 | 9 | from log import logger 10 | 11 | 12 | def traced_function_wrong(function): 13 | """An example of a badly defined decorator.""" 14 | logger.debug("started execution of %s", function) 15 | start_time = time.time() 16 | 17 | @wraps(function) 18 | def wrapped(*args, **kwargs): 19 | result = function(*args, **kwargs) 20 | logger.info( 21 | "function %s took %.2fs", function, time.time() - start_time 22 | ) 23 | return result 24 | 25 | return wrapped 26 | 27 | 28 | @traced_function_wrong 29 | def process_with_delay(callback, delay=0): 30 | logger.info("sleep(%d)", delay) 31 | return callback 32 | 33 | 34 | def traced_function(function): 35 | @wraps(function) 36 | def wrapped(*args, **kwargs): 37 | logger.info("started execution of %s", function) 38 | start_time = time.time() 39 | result = function(*args, **kwargs) 40 | logger.info( 41 | "function %s took %.2fs", function, time.time() - start_time 42 | ) 43 | return result 44 | 45 | return wrapped 46 | 47 | 48 | @traced_function 49 | def call_with_delay(callback, delay=0): 50 | logger.info("sleep(%d)", delay) 51 | return callback 52 | -------------------------------------------------------------------------------- /Chapter05/decorator_side_effects_2.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 5: Decorators 2 | 3 | > Example of desired side effects on decorators 4 | 5 | """ 6 | 7 | 8 | EVENTS_REGISTRY = {} 9 | 10 | 11 | def register_event(event_cls): 12 | """Place the class for the event into the registry to make it accessible in 13 | the module. 14 | """ 15 | EVENTS_REGISTRY[event_cls.__name__] = event_cls 16 | return event_cls 17 | 18 | 19 | class Event: 20 | """A base event object""" 21 | 22 | 23 | class UserEvent: 24 | TYPE = "user" 25 | 26 | 27 | @register_event 28 | class UserLoginEvent(UserEvent): 29 | """Represents the event of a user when it has just accessed the system.""" 30 | 31 | 32 | @register_event 33 | class UserLogoutEvent(UserEvent): 34 | """Event triggered right after a user abandoned the system.""" 35 | 36 | 37 | def test(): 38 | """ 39 | >>> sorted(EVENTS_REGISTRY.keys()) == sorted(('UserLoginEvent', 'UserLogoutEvent')) 40 | True 41 | """ 42 | -------------------------------------------------------------------------------- /Chapter05/decorator_universal_1.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 5: Decorators 2 | 3 | Universal Decorators: create decorators that can be applied to several 4 | different objects (e.g. functions, methods), and won't fail. 5 | 6 | """ 7 | from functools import wraps 8 | 9 | from log import logger 10 | 11 | 12 | class DBDriver: 13 | def __init__(self, dbstring): 14 | self.dbstring = dbstring 15 | 16 | def execute(self, query): 17 | return f"query {query} at {self.dbstring}" 18 | 19 | 20 | def inject_db_driver(function): 21 | """This decorator converts the parameter by creating a ``DBDriver`` 22 | instance from the database dsn string. 23 | """ 24 | 25 | @wraps(function) 26 | def wrapped(dbstring): 27 | return function(DBDriver(dbstring)) 28 | 29 | return wrapped 30 | 31 | 32 | @inject_db_driver 33 | def run_query(driver): 34 | return driver.execute("test_function") 35 | 36 | 37 | class DataHandler: 38 | """The decorator will not work for methods as it is defined.""" 39 | 40 | @inject_db_driver 41 | def run_query(self, driver): 42 | return driver.execute(self.__class__.__name__) 43 | -------------------------------------------------------------------------------- /Chapter05/decorator_universal_2.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 5: Decorators 2 | 3 | > Universal Decorators: create decorators that can be applied to several 4 | different objects (e.g. functions, methods), and won't fail. 5 | 6 | - Fix the failing decorator 7 | """ 8 | 9 | from functools import wraps 10 | from types import MethodType 11 | 12 | 13 | class DBDriver: 14 | def __init__(self, dbstring): 15 | self.dbstring = dbstring 16 | 17 | def execute(self, query): 18 | return f"query {query} at {self.dbstring}" 19 | 20 | 21 | class inject_db_driver: 22 | """Convert a string to a DBDriver instance and pass this to the wrapped 23 | function. 24 | """ 25 | 26 | def __init__(self, function): 27 | self.function = function 28 | wraps(self.function)(self) 29 | 30 | def __call__(self, dbstring): 31 | return self.function(DBDriver(dbstring)) 32 | 33 | def __get__(self, instance, owner): 34 | if instance is None: 35 | return self 36 | return self.__class__(MethodType(self.function, instance)) 37 | 38 | 39 | @inject_db_driver 40 | def run_query(driver): 41 | return driver.execute("test_function_2") 42 | 43 | 44 | class DataHandler: 45 | @inject_db_driver 46 | def run_query(self, driver): 47 | return driver.execute("test_method_2") 48 | -------------------------------------------------------------------------------- /Chapter05/decorator_wraps_1.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 5: Decorators 2 | 3 | > functools.wraps 4 | 5 | """ 6 | 7 | from log import logger 8 | 9 | 10 | def trace_decorator(function): 11 | def wrapped(*args, **kwargs): 12 | logger.info("running %s", function.__qualname__) 13 | return function(*args, **kwargs) 14 | 15 | return wrapped 16 | 17 | 18 | @trace_decorator 19 | def process_account(account_id): 20 | """Process an account by Id.""" 21 | logger.info("processing account %s", account_id) 22 | ... 23 | -------------------------------------------------------------------------------- /Chapter05/decorator_wraps_2.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 5: Decorators 2 | 3 | > functools.wraps 4 | """ 5 | 6 | from functools import wraps 7 | 8 | from log import logger 9 | 10 | 11 | def trace_decorator(function): 12 | """Log when a function is being called.""" 13 | 14 | @wraps(function) 15 | def wrapped(*args, **kwargs): 16 | logger.info("running %s", function.__qualname__) 17 | return function(*args, **kwargs) 18 | 19 | return wrapped 20 | 21 | 22 | @trace_decorator 23 | def process_account(account_id): 24 | """Process an account by Id.""" 25 | logger.info("processing account %s", account_id) 26 | ... 27 | 28 | 29 | def decorator(original_function): 30 | @wraps(original_function) 31 | def decorated_function(*args, **kwargs): 32 | # modifications done by the decorator ... 33 | return original_function(*args, **kwargs) 34 | 35 | return decorated_function 36 | -------------------------------------------------------------------------------- /Chapter05/log.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") 4 | logger = logging.getLogger(__name__) 5 | -------------------------------------------------------------------------------- /Chapter05/test_decorator_SoC.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 5: Decorators 2 | 3 | Test for separation of concerns with decorators. 4 | 5 | """ 6 | import unittest 7 | from unittest import mock 8 | 9 | from decorator_SoC_1 import operation1 10 | from decorator_SoC_2 import operation 11 | 12 | 13 | def mocked_time(): 14 | _delta_time = 0 15 | 16 | def time(): 17 | nonlocal _delta_time 18 | _delta_time += 1 19 | return _delta_time 20 | 21 | return time 22 | 23 | 24 | @mock.patch("time.sleep") 25 | @mock.patch("time.time", side_effect=mocked_time()) 26 | @mock.patch("decorator_SoC_1.logger") 27 | class TestSoC1(unittest.TestCase): 28 | def test_operation(self, logger, time, sleep): 29 | operation1() 30 | expected_calls = [ 31 | mock.call("started execution of %s", "operation1"), 32 | mock.call("running operation 1"), 33 | mock.call("function %s took %.2fs", "operation1", 1), 34 | ] 35 | logger.info.assert_has_calls(expected_calls) 36 | 37 | 38 | @mock.patch("time.sleep") 39 | @mock.patch("time.time", side_effect=mocked_time()) 40 | @mock.patch("decorator_SoC_2.logger") 41 | class TestSoC2(unittest.TestCase): 42 | def test_operation(self, logger, time, sleep): 43 | operation() 44 | expected_calls = [ 45 | mock.call("started execution of %s", "operation"), 46 | mock.call("running operation..."), 47 | mock.call("function %s took %.2f", "operation", 1), 48 | ] 49 | logger.info.assert_has_calls(expected_calls) 50 | 51 | 52 | if __name__ == "__main__": 53 | unittest.main() 54 | -------------------------------------------------------------------------------- /Chapter05/test_decorator_universal.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 5: Decorators 2 | 3 | Tests for decorator_universal_1 4 | 5 | """ 6 | 7 | from unittest import TestCase, main, mock 8 | 9 | from decorator_universal_1 import DataHandler, run_query 10 | from decorator_universal_2 import DataHandler as DataHandler_2 11 | from decorator_universal_2 import run_query as run_query_2 12 | 13 | 14 | class TestDecorator(TestCase): 15 | def setUp(self): 16 | self.logger = mock.patch("log.logger.info").start() 17 | 18 | def tearDown(self): 19 | self.logger.stop() 20 | 21 | def test_decorator_function_ok(self): 22 | self.assertEqual( 23 | run_query("function_ok"), "query test_function at function_ok" 24 | ) 25 | 26 | def test_decorator_method_fails(self): 27 | data_handler = DataHandler() 28 | self.assertRaisesRegex( 29 | TypeError, 30 | "\S+ takes \d+ positional argument but \d+ were given", 31 | data_handler.run_query, 32 | "method_fails", 33 | ) 34 | 35 | def test_decorator_function_2(self): 36 | self.assertEqual( 37 | run_query_2("second_works"), 38 | "query test_function_2 at second_works", 39 | ) 40 | 41 | def test_decorator_method_2(self): 42 | data_handler = DataHandler_2() 43 | self.assertEqual( 44 | data_handler.run_query("method_2"), 45 | "query test_method_2 at method_2", 46 | ) 47 | 48 | 49 | if __name__ == "__main__": 50 | main() 51 | -------------------------------------------------------------------------------- /Chapter05/test_wraps.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 5: Decorators 2 | 3 | Tests for examples with ``functools.wraps`` 4 | """ 5 | 6 | import unittest 7 | 8 | from decorator_wraps_1 import process_account as process_account_1 9 | from decorator_wraps_2 import process_account as process_account_2 10 | 11 | 12 | class TestWraps1(unittest.TestCase): 13 | def test_name_incorrect(self): 14 | self.assertEqual( 15 | process_account_1.__qualname__, "trace_decorator..wrapped" 16 | ) 17 | 18 | def test_no_docstring(self): 19 | self.assertIsNone(process_account_1.__doc__) 20 | 21 | 22 | class TestWraps2(unittest.TestCase): 23 | def test_name_solved(self): 24 | self.assertEqual(process_account_2.__qualname__, "process_account") 25 | 26 | def test_docsting_preserved(self): 27 | self.assertTrue(process_account_2.__doc__.startswith("Process")) 28 | 29 | 30 | if __name__ == "__main__": 31 | unittest.main() 32 | -------------------------------------------------------------------------------- /Chapter06/Makefile: -------------------------------------------------------------------------------- 1 | PYTHON:=$(VIRTUAL_ENV)/bin/python 2 | 3 | clean: 4 | find . -name "*.swp" -o -name "__pycache__" | xargs rm -fr 5 | 6 | test: 7 | @$(PYTHON) -m doctest *.py 8 | @$(PYTHON) -m unittest *.py 9 | 10 | typehint: 11 | @mypy *.py 12 | 13 | .PHONY: clean test 14 | -------------------------------------------------------------------------------- /Chapter06/README.rst: -------------------------------------------------------------------------------- 1 | Getting More out of our Objects With Descriptors 2 | ================================================ 3 | 4 | Run tests:: 5 | 6 | make test 7 | 8 | A First Look at Descriptors 9 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ 10 | 11 | * ``descriptor_1.py``: First high-level example illustrating the descriptor 12 | protocol. 13 | 14 | 15 | Methods 16 | ------- 17 | 18 | Files for these examples are ``descriptors_methods_{1,2,3,4}.py``, respectively 19 | for: 20 | 21 | * ``__get__`` 22 | * ``__set__`` 23 | * ``__delete__`` 24 | * ``__set_name__`` 25 | 26 | Descriptors in Action 27 | --------------------- 28 | 29 | * An application of descriptors: ``descriptors_pythonic_{1,2}.py`` 30 | * Different forms of implementing descriptors (``__dict__`` vs. ``weakref``): 31 | ``descriptors_implementation_{1,2}.py`` 32 | 33 | 34 | Uses of Descriptors in CPython 35 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 36 | 37 | * Internals of descriptors: ``descriptors_cpython_{1..3}.py``. 38 | * Functions and methods: ``descriptors_methods_{1..4}.py``. 39 | * ``__slots__`` 40 | * ``@property``, ``@classmethod``, and ``@staticmethod``. 41 | 42 | 43 | Uses of descriptors 44 | ^^^^^^^^^^^^^^^^^^^ 45 | 46 | * Reuse code 47 | * Avoid class decorators: ``descriptors_uses_{1,2}.py`` 48 | -------------------------------------------------------------------------------- /Chapter06/descriptors_1.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter6: Getting more out of our objects with 2 | descriptors 3 | 4 | > Illustrate the basic workings of the descriptor protocol. 5 | """ 6 | import logging 7 | 8 | logging.basicConfig(level=logging.INFO) 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class DescriptorClass: 13 | def __get__(self, instance, owner): 14 | if instance is None: 15 | return self 16 | logger.info( 17 | "Call: %s.__get__(%r, %r)", 18 | self.__class__.__name__, 19 | instance, 20 | owner, 21 | ) 22 | return instance 23 | 24 | 25 | class ClientClass: 26 | descriptor = DescriptorClass() 27 | -------------------------------------------------------------------------------- /Chapter06/descriptors_cpython_1.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 6: Descriptors 2 | 3 | > How Python uses descriptors internally. 4 | 5 | """ 6 | from types import MethodType 7 | 8 | 9 | class Method: 10 | def __init__(self, name): 11 | self.name = name 12 | 13 | def __call__(self, instance, arg1, arg2): 14 | print(f"{self.name}: {instance} called with {arg1} and {arg2}") 15 | 16 | 17 | class MyClass1: 18 | method = Method("Internal call") 19 | 20 | 21 | class NewMethod: 22 | def __init__(self, name): 23 | self.name = name 24 | 25 | def __call__(self, instance, arg1, arg2): 26 | print(f"{self.name}: {instance} called with {arg1} and {arg2}") 27 | 28 | def __get__(self, instance, owner): 29 | if instance is None: 30 | return self 31 | return MethodType(self, instance) 32 | 33 | 34 | class MyClass2: 35 | method = NewMethod("Internal call") 36 | -------------------------------------------------------------------------------- /Chapter06/descriptors_cpython_2.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 6: Descriptors 2 | 3 | > How Python uses descriptors internally: __slots__ 4 | """ 5 | 6 | class Coordinate2D: 7 | """ 8 | >>> coord = Coordinate2D(1, 2) 9 | >>> repr(coord) 10 | 'Coordinate2D(1, 2)' 11 | """ 12 | __slots__ = ("lat", "long") 13 | 14 | def __init__(self, lat, long): 15 | self.lat = lat 16 | self.long = long 17 | 18 | def __repr__(self): 19 | return f"{self.__class__.__name__}({self.lat}, {self.long})" 20 | -------------------------------------------------------------------------------- /Chapter06/descriptors_cpython_3.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 6: Descriptors 2 | 3 | > How Python uses descriptors internally: @clasmethod 4 | 5 | """ 6 | from types import MethodType 7 | 8 | 9 | class ClassMethod: 10 | def __init__(self, method): 11 | self.method = method 12 | 13 | def __call__(self, *args, **kwargs): 14 | return self.method(*args, **kwargs) 15 | 16 | def __get__(self, instance, owner): 17 | return MethodType(self.method, owner) 18 | 19 | 20 | class MyClass: 21 | """ 22 | >>> MyClass().class_method("first", "second") 23 | 'MyClass called with arguments: first, and second' 24 | 25 | >>> MyClass.class_method("one", "two") 26 | 'MyClass called with arguments: one, and two' 27 | 28 | >>> MyClass().method() # doctest: +ELLIPSIS 29 | 'MyClass called with arguments: self, and from method' 30 | """ 31 | 32 | @ClassMethod 33 | def class_method(cls, arg1, arg2) -> str: 34 | return f"{cls.__name__} called with arguments: {arg1}, and {arg2}" 35 | 36 | def method(self): 37 | return self.class_method("self", "from method") 38 | 39 | 40 | class classproperty: 41 | def __init__(self, fget): 42 | self.fget = fget 43 | 44 | def __get__(self, instance, owner): 45 | return self.fget(owner) 46 | 47 | 48 | def read_prefix_from_config(): 49 | return "" 50 | 51 | 52 | class TableEvent: 53 | """ 54 | >>> TableEvent.topic 55 | 'public.user' 56 | 57 | >>> TableEvent().topic 58 | 'public.user' 59 | """ 60 | 61 | schema = "public" 62 | table = "user" 63 | 64 | @classproperty 65 | def topic(cls): 66 | prefix = read_prefix_from_config() 67 | return f"{prefix}{cls.schema}.{cls.table}" 68 | -------------------------------------------------------------------------------- /Chapter06/descriptors_implementation_1.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter6: Getting more out of our objects with 2 | descriptors 3 | 4 | > Different forms of implementing descriptors (``__dict__`` vs. ``weakref``) 5 | 6 | - The global state problem 7 | """ 8 | 9 | 10 | class SharedDataDescriptor: 11 | def __init__(self, initial_value): 12 | self.value = initial_value 13 | 14 | def __get__(self, instance, owner): 15 | if instance is None: 16 | return self 17 | return self.value 18 | 19 | def __set__(self, instance, value): 20 | self.value = value 21 | 22 | 23 | class ClientClass: 24 | """ 25 | >>> client1 = ClientClass() 26 | >>> client1.descriptor 27 | 'first value' 28 | 29 | >>> client2 = ClientClass() 30 | >>> client2.descriptor 31 | 'first value' 32 | 33 | >>> client2.descriptor = "value for client 2" 34 | >>> client2.descriptor 35 | 'value for client 2' 36 | 37 | >>> client1.descriptor 38 | 'value for client 2' 39 | """ 40 | 41 | descriptor = SharedDataDescriptor("first value") 42 | -------------------------------------------------------------------------------- /Chapter06/descriptors_implementation_2.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 6: Descriptors 2 | 3 | > Different forms of implementing descriptors (``__dict__`` vs. ``weakref``) 4 | 5 | - Implementation with weakref 6 | """ 7 | 8 | from weakref import WeakKeyDictionary 9 | 10 | 11 | class DescriptorClass: 12 | def __init__(self, initial_value): 13 | self.value = initial_value 14 | self.mapping = WeakKeyDictionary() 15 | 16 | def __get__(self, instance, owner): 17 | if instance is None: 18 | return self 19 | return self.mapping.get(instance, self.value) 20 | 21 | def __set__(self, instance, value): 22 | self.mapping[instance] = value 23 | 24 | 25 | class ClientClass: 26 | """ 27 | >>> client1 = ClientClass() 28 | >>> client2 = ClientClass() 29 | 30 | >>> client1.descriptor = "new value" 31 | 32 | client1 must have the new value, whilst client2 has to still be with the 33 | default one: 34 | 35 | >>> client1.descriptor 36 | 'new value' 37 | >>> client2.descriptor 38 | 'default value' 39 | 40 | Changing the value for client2 doesn't affect client1 41 | 42 | >>> client2.descriptor = "value for client2" 43 | >>> client2.descriptor 44 | 'value for client2' 45 | >>> client2.descriptor != client1.descriptor 46 | True 47 | """ 48 | 49 | descriptor = DescriptorClass("default value") 50 | -------------------------------------------------------------------------------- /Chapter06/descriptors_methods_1.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 6: Descriptors 2 | 3 | > Methods of the descriptor interface: __get__ 4 | """ 5 | 6 | 7 | class DescriptorClass: 8 | def __get__(self, instance, owner): 9 | if instance is None: 10 | return f"{self.__class__.__name__}.{owner.__name__}" 11 | return f"value for {instance}" 12 | 13 | 14 | class ClientClass: 15 | """ 16 | >>> ClientClass.descriptor 17 | 'DescriptorClass.ClientClass' 18 | 19 | >>> ClientClass().descriptor # doctest: +ELLIPSIS 20 | 'value for ' 21 | """ 22 | 23 | descriptor = DescriptorClass() 24 | -------------------------------------------------------------------------------- /Chapter06/descriptors_methods_2.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 6: Descriptors 2 | 3 | > Methods of the descriptor interface: __set__ 4 | 5 | """ 6 | from typing import Callable, Any 7 | 8 | 9 | class Validation: 10 | """A configurable validation callable.""" 11 | 12 | def __init__( 13 | self, validation_function: Callable[[Any], bool], error_msg: str 14 | ) -> None: 15 | self.validation_function = validation_function 16 | self.error_msg = error_msg 17 | 18 | def __call__(self, value): 19 | if not self.validation_function(value): 20 | raise ValueError(f"{value!r} {self.error_msg}") 21 | 22 | 23 | class Field: 24 | """A class attribute with validation functions configured over it.""" 25 | 26 | def __init__(self, *validations): 27 | self._name = None 28 | self.validations = validations 29 | 30 | def __set_name__(self, owner, name): 31 | self._name = name 32 | 33 | def __get__(self, instance, owner): 34 | if instance is None: 35 | return self 36 | return instance.__dict__[self._name] 37 | 38 | def validate(self, value): 39 | for validation in self.validations: 40 | validation(value) 41 | 42 | def __set__(self, instance, value): 43 | self.validate(value) 44 | instance.__dict__[self._name] = value 45 | 46 | 47 | class ClientClass: 48 | descriptor = Field( 49 | Validation(lambda x: isinstance(x, (int, float)), "is not a number"), 50 | Validation(lambda x: x >= 0, "is not >= 0"), 51 | ) 52 | -------------------------------------------------------------------------------- /Chapter06/descriptors_methods_3.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 6: Descriptors 2 | 3 | > Methods of the descriptor interface: __delete__ 4 | 5 | """ 6 | 7 | 8 | class ProtectedAttribute: 9 | def __init__(self, requires_role=None) -> None: 10 | self.permission_required = requires_role 11 | self._name = None 12 | 13 | def __set_name__(self, owner, name): 14 | self._name = name 15 | 16 | def __set__(self, user, value): 17 | if value is None: 18 | raise ValueError(f"{self._name} can't be set to None") 19 | user.__dict__[self._name] = value 20 | 21 | def __delete__(self, user): 22 | if self.permission_required in user.permissions: 23 | user.__dict__[self._name] = None 24 | else: 25 | raise ValueError( 26 | f"User {user!s} doesn't have {self.permission_required} " 27 | "permission" 28 | ) 29 | 30 | 31 | class User: 32 | """Only users with "admin" privileges can remove their email address.""" 33 | 34 | email = ProtectedAttribute(requires_role="admin") 35 | 36 | def __init__( 37 | self, username: str, email: str, permission_list: list = None 38 | ) -> None: 39 | self.username = username 40 | self.email = email 41 | self.permissions = permission_list or [] 42 | 43 | def __str__(self): 44 | return self.username 45 | -------------------------------------------------------------------------------- /Chapter06/descriptors_methods_4.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 6: Descriptors 2 | 3 | > Methods of the descriptor interface: __set_name__ 4 | 5 | """ 6 | from log import logger 7 | 8 | 9 | class DescriptorWithName: 10 | """This descriptor requires the name to be explicitly set.""" 11 | 12 | def __init__(self, name): 13 | self.name = name 14 | 15 | def __get__(self, instance, value): 16 | if instance is None: 17 | return self 18 | logger.debug("getting %r attribute from %r", self.name, instance) 19 | return instance.__dict__[self.name] 20 | 21 | def __set__(self, instance, value): 22 | instance.__dict__[self.name] = value 23 | 24 | 25 | class ClientClass: 26 | """ 27 | >>> client = ClientClass() 28 | >>> client.descriptor = "value" 29 | >>> client.descriptor 30 | 'value' 31 | 32 | >>> ClientClass.descriptor_2.name 33 | "a name that doesn't match the attribute" 34 | """ 35 | 36 | descriptor = DescriptorWithName("descriptor") 37 | descriptor_2 = DescriptorWithName("a name that doesn't match the attribute") 38 | 39 | 40 | class DescriptorWithAutomaticName(DescriptorWithName): 41 | """This descriptor can infer the name of the attribute, if not provided. 42 | It also supports setting a different name explicitly. 43 | """ 44 | 45 | def __init__(self, name: str = None) -> None: 46 | self.name = name 47 | 48 | def __set_name__(self, owner, name): 49 | self.name = self.name or name 50 | 51 | 52 | class NewClientClass: 53 | """ 54 | >>> NewClientClass.descriptor_with_default_name.name 55 | 'descriptor_with_default_name' 56 | 57 | >>> NewClientClass.named_descriptor.name 58 | 'named_descriptor' 59 | 60 | >>> NewClientClass.descriptor_named_differently.name 61 | 'a_different_name' 62 | 63 | >>> client = NewClientClass() 64 | >>> client.descriptor_named_differently = "foo" 65 | >>> client.__dict__["a_different_name"] 66 | 'foo' 67 | 68 | >>> client.descriptor_named_differently 69 | 'foo' 70 | 71 | >>> client.a_different_name 72 | 'foo' 73 | """ 74 | 75 | descriptor_with_default_name = DescriptorWithAutomaticName() 76 | named_descriptor = DescriptorWithAutomaticName("named_descriptor") 77 | descriptor_named_differently = DescriptorWithAutomaticName( 78 | "a_different_name" 79 | ) 80 | -------------------------------------------------------------------------------- /Chapter06/descriptors_pythonic_1.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 6: Descriptors 2 | 3 | > A Pythonic Implementation 4 | 5 | 6 | """ 7 | import time 8 | 9 | 10 | class Traveller: 11 | """A person visiting several cities. 12 | 13 | We wish to track the path of the traveller, as he or she is visiting each 14 | new city. 15 | 16 | >>> alice = Traveller("Alice", "Barcelona") 17 | >>> alice.current_city = "Paris" 18 | >>> alice.current_city = "Brussels" 19 | >>> alice.current_city = "Amsterdam" 20 | 21 | >>> alice.cities_visited 22 | ['Barcelona', 'Paris', 'Brussels', 'Amsterdam'] 23 | 24 | >>> alice.current_city 25 | 'Amsterdam' 26 | 27 | >>> alice.current_city = "Amsterdam" 28 | >>> alice.cities_visited 29 | ['Barcelona', 'Paris', 'Brussels', 'Amsterdam'] 30 | 31 | >>> bob = Traveller("Bob", "Rotterdam") 32 | >>> bob.current_city = "Amsterdam" 33 | >>> bob.current_city 34 | 'Amsterdam' 35 | >>> bob.cities_visited 36 | ['Rotterdam', 'Amsterdam'] 37 | 38 | """ 39 | def __init__(self, name, current_city): 40 | self.name = name 41 | self._current_city = current_city 42 | self._cities_visited = [current_city] 43 | 44 | @property 45 | def current_city(self): 46 | return self._current_city 47 | 48 | @current_city.setter 49 | def current_city(self, new_city): 50 | if new_city != self._current_city: 51 | self._cities_visited.append(new_city) 52 | self._current_city = new_city 53 | 54 | @property 55 | def cities_visited(self): 56 | return self._cities_visited 57 | -------------------------------------------------------------------------------- /Chapter06/descriptors_pythonic_2.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 6: Descriptors 2 | 3 | > A Pythonic Implementation 4 | 5 | """ 6 | 7 | 8 | class HistoryTracedAttribute: 9 | """Trace the values of this attribute into another one given by the name at 10 | ``trace_attribute_name``. 11 | """ 12 | 13 | def __init__(self, trace_attribute_name: str) -> None: 14 | self.trace_attribute_name = trace_attribute_name 15 | self._name = None 16 | 17 | def __set_name__(self, owner, name): 18 | self._name = name 19 | 20 | def __get__(self, instance, owner): 21 | if instance is None: 22 | return self 23 | return instance.__dict__[self._name] 24 | 25 | def __set__(self, instance, value): 26 | self._track_change_in_value_for_instance(instance, value) 27 | instance.__dict__[self._name] = value 28 | 29 | def _track_change_in_value_for_instance(self, instance, value): 30 | self._set_default(instance) 31 | if self._needs_to_track_change(instance, value): 32 | instance.__dict__[self.trace_attribute_name].append(value) 33 | 34 | def _needs_to_track_change(self, instance, value) -> bool: 35 | """Determine if the value change needs to be traced or not. 36 | 37 | Rules for adding a value to the trace: 38 | * If the value is not previously set (it's the first one). 39 | * If the new value is != than the current one. 40 | """ 41 | try: 42 | current_value = instance.__dict__[self._name] 43 | except KeyError: 44 | return True 45 | return value != current_value 46 | 47 | def _set_default(self, instance): 48 | instance.__dict__.setdefault(self.trace_attribute_name, []) 49 | 50 | 51 | class Traveller: 52 | """A person visiting several cities. 53 | 54 | We wish to track the path of the traveller, as he or she is visiting each 55 | new city. 56 | 57 | >>> alice = Traveller("Alice", "Barcelona") 58 | >>> alice.current_city = "Paris" 59 | >>> alice.current_city = "Brussels" 60 | >>> alice.current_city = "Amsterdam" 61 | 62 | >>> alice.cities_visited 63 | ['Barcelona', 'Paris', 'Brussels', 'Amsterdam'] 64 | 65 | >>> alice.current_city 66 | 'Amsterdam' 67 | 68 | >>> alice.current_city = "Amsterdam" 69 | >>> alice.cities_visited 70 | ['Barcelona', 'Paris', 'Brussels', 'Amsterdam'] 71 | 72 | >>> bob = Traveller("Bob", "Rotterdam") 73 | >>> bob.current_city = "Amsterdam" 74 | >>> bob.current_city 75 | 'Amsterdam' 76 | >>> bob.cities_visited 77 | ['Rotterdam', 'Amsterdam'] 78 | 79 | """ 80 | 81 | current_city = HistoryTracedAttribute("cities_visited") 82 | 83 | def __init__(self, name, current_city): 84 | self.name = name 85 | self.current_city = current_city 86 | -------------------------------------------------------------------------------- /Chapter06/descriptors_types_1.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 6: Descriptors 2 | 3 | > Types of Descriptors: 4 | 5 | 1. Non-Data Descriptors (non-overriding) 6 | 2. Data Descriptors (overriding) 7 | 8 | Code examples to illustrate [1]. 9 | """ 10 | 11 | class NonDataDescriptor: 12 | """A descriptor that doesn't implement __set__.""" 13 | def __get__(self, instance, owner): 14 | if instance is None: 15 | return self 16 | return 42 17 | 18 | 19 | class ClientClass: 20 | """Test NonDataDescriptor. 21 | 22 | >>> client = ClientClass() 23 | >>> client.descriptor 24 | 42 25 | 26 | >>> client.descriptor = 43 27 | >>> client.descriptor 28 | 43 29 | 30 | >>> del client.descriptor 31 | >>> client.descriptor 32 | 42 33 | 34 | >>> vars(client) 35 | {} 36 | 37 | >>> client.descriptor 38 | 42 39 | 40 | >>> client.descriptor = 99 41 | >>> vars(client) 42 | {'descriptor': 99} 43 | 44 | >>> del client.descriptor 45 | >>> vars(client) 46 | {} 47 | >>> client.descriptor 48 | 42 49 | 50 | """ 51 | descriptor = NonDataDescriptor() 52 | -------------------------------------------------------------------------------- /Chapter06/descriptors_types_2.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 6: Descriptors 2 | 3 | > Types of Descriptors: 4 | 5 | 1. Non-Data Descriptors (non-overriding) 6 | 2. Data Descriptors (overriding) 7 | 8 | Code examples to illustrate [2]. 9 | """ 10 | from log import logger 11 | 12 | 13 | class DataDescriptor: 14 | """A descriptor that implements __get__ & __set__.""" 15 | 16 | def __get__(self, instance, owner): 17 | if instance is None: 18 | return self 19 | return 42 20 | 21 | def __set__(self, instance, value): 22 | logger.debug("setting %s.descriptor to %s", instance, value) 23 | instance.__dict__["descriptor"] = value 24 | 25 | 26 | class ClientClass: 27 | """Test DataDescriptor 28 | 29 | Let's see what the value of the descriptor returns:: 30 | 31 | >>> client = ClientClass() 32 | >>> client.descriptor 33 | 42 34 | 35 | >>> client.descriptor = 99 36 | >>> client.descriptor 37 | 42 38 | 39 | >>> vars(client) 40 | {'descriptor': 99} 41 | 42 | >>> client.__dict__["descriptor"] 43 | 99 44 | 45 | >>> del client.descriptor 46 | Traceback (most recent call last): 47 | File "", line 1, in 48 | AttributeError: __delete__ 49 | 50 | """ 51 | descriptor = DataDescriptor() 52 | -------------------------------------------------------------------------------- /Chapter06/descriptors_uses_1.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 6: Descriptors 2 | 3 | > Using descriptors instead of class decorators 4 | 5 | """ 6 | from datetime import datetime 7 | from functools import partial 8 | from typing import Any, Callable 9 | 10 | 11 | class BaseFieldTransformation: 12 | """Base class to define descriptors that convert values.""" 13 | 14 | def __init__(self, transformation: Callable[[Any, str], str]) -> None: 15 | self._name = None 16 | self.transformation = transformation 17 | 18 | def __get__(self, instance, owner): 19 | if instance is None: 20 | return self 21 | raw_value = instance.__dict__[self._name] 22 | return self.transformation(raw_value) 23 | 24 | def __set_name__(self, owner, name): 25 | self._name = name 26 | 27 | def __set__(self, instance, value): 28 | instance.__dict__[self._name] = value 29 | 30 | 31 | ShowOriginal = partial(BaseFieldTransformation, transformation=lambda x: x) 32 | HideField = partial( 33 | BaseFieldTransformation, transformation=lambda x: "**redacted**" 34 | ) 35 | FormatTime = partial( 36 | BaseFieldTransformation, 37 | transformation=lambda ft: ft.strftime("%Y-%m-%d %H:%M"), 38 | ) 39 | 40 | 41 | class LoginEvent: 42 | """ 43 | >>> le = LoginEvent( 44 | ... "usr", "secret password", "127.0.0.1", datetime(2016, 7, 20, 15, 45) 45 | ... ) 46 | >>> vars(le) 47 | {'username': 'usr', 'password': 'secret password', 'ip': '127.0.0.1', 'timestamp': datetime.datetime(2016, 7, 20, 15, 45)} 48 | 49 | >>> le.serialize() 50 | {'username': 'usr', 'password': '**redacted**', 'ip': '127.0.0.1', 'timestamp': '2016-07-20 15:45'} 51 | 52 | >>> le.password 53 | '**redacted**' 54 | 55 | """ 56 | 57 | username = ShowOriginal() 58 | password = HideField() 59 | ip = ShowOriginal() 60 | timestamp = FormatTime() 61 | 62 | def __init__(self, username, password, ip, timestamp): 63 | self.username = username 64 | self.password = password 65 | self.ip = ip 66 | self.timestamp = timestamp 67 | 68 | def serialize(self): 69 | return { 70 | "username": self.username, 71 | "password": self.password, 72 | "ip": self.ip, 73 | "timestamp": self.timestamp, 74 | } 75 | 76 | 77 | class BaseEvent: 78 | """Abstract the serialization and the __init__""" 79 | 80 | def __init__(self, **kwargs): 81 | self.__dict__.update(kwargs) 82 | 83 | def serialize(self): 84 | return { 85 | attr: getattr(self, attr) for attr in self._fields_to_serialize() 86 | } 87 | 88 | def _fields_to_serialize(self): 89 | for attr_name, value in vars(self.__class__).items(): 90 | if isinstance(value, BaseFieldTransformation): 91 | yield attr_name 92 | 93 | 94 | class NewLoginEvent(BaseEvent): 95 | """A class that takes advantage of the base to only define the fields.""" 96 | 97 | username = ShowOriginal() 98 | password = HideField() 99 | ip = ShowOriginal() 100 | timestamp = FormatTime() 101 | -------------------------------------------------------------------------------- /Chapter06/log.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | logging.basicConfig(level=logging.INFO, format="%(message)s") 4 | logger = logging.getLogger(__name__) 5 | -------------------------------------------------------------------------------- /Chapter06/test_descriptors_cpython.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 6: Descriptors 2 | 3 | > How Python uses descriptors internally. 4 | 5 | """ 6 | 7 | import io 8 | import re 9 | from contextlib import redirect_stdout 10 | from unittest import TestCase, main 11 | 12 | from descriptors_cpython_1 import Method, MyClass1, MyClass2, NewMethod 13 | 14 | 15 | class TestDescriptorsCPython1(TestCase): 16 | def setUp(self): 17 | self.pattern = re.compile( 18 | r"(External|Internal) call: .* called with \S+ and \S+", 19 | re.DOTALL | re.MULTILINE, 20 | ) 21 | 22 | def test_method_unbound_fails(self): 23 | instance = MyClass1() 24 | 25 | capture = io.StringIO() 26 | with redirect_stdout(capture): 27 | Method("External call")(instance, "first", "second") 28 | 29 | result = capture.getvalue() 30 | 31 | self.assertIsNotNone(self.pattern.match(result), repr(result)) 32 | 33 | with self.assertRaises(TypeError): 34 | instance.method("first", "second") 35 | 36 | def test_working_example(self): 37 | instance = MyClass2() 38 | capture = io.StringIO() 39 | 40 | with redirect_stdout(capture): 41 | NewMethod("External call")(instance, "first", "second") 42 | 43 | external = capture.getvalue() 44 | self.assertIsNotNone(self.pattern.match(external), repr(external)) 45 | 46 | capture = io.StringIO() 47 | with redirect_stdout(capture): 48 | instance.method("first", "second") 49 | 50 | internal = capture.getvalue() 51 | self.assertIsNotNone(self.pattern.match(internal), repr(internal)) 52 | 53 | 54 | if __name__ == "__main__": 55 | main() 56 | -------------------------------------------------------------------------------- /Chapter06/test_descriptors_methods.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 6: Descriptors 2 | 3 | Test for the examples of descriptors methods. 4 | """ 5 | 6 | from unittest import TestCase, main 7 | 8 | from descriptors_methods_2 import ClientClass 9 | from descriptors_methods_3 import User 10 | 11 | 12 | class TestSet(TestCase): 13 | def setUp(self): 14 | self.client = ClientClass() 15 | 16 | def test_name(self): 17 | self.assertEqual(ClientClass.descriptor._name, "descriptor") 18 | 19 | def test_invalid_parameters_not_assigned(self): 20 | with self.assertRaisesRegex(ValueError, "-1 is not >= 0"): 21 | self.client.descriptor = -1 22 | 23 | with self.assertRaisesRegex(ValueError, "'something' is not a number"): 24 | self.client.descriptor = "something" 25 | 26 | def test_assign_valid_data(self): 27 | for value in (1, 2.71, 0.5): 28 | with self.subTest(value=value): 29 | self.client.descriptor = value 30 | self.assertEqual(self.client.descriptor, value) 31 | 32 | def test_assing_valie_then_invalid(self): 33 | self.client.descriptor = 3.14 34 | with self.assertRaisesRegex(ValueError, "-4 is not >= 0"): 35 | self.client.descriptor = -4 36 | self.assertAlmostEqual(self.client.descriptor, 3.14) 37 | 38 | 39 | class TestDelete(TestCase): 40 | def setUp(self): 41 | self.admin = User("root", "root@d.com", ["admin"]) 42 | self.user = User("user", "user1@d.com", ["email", "helpdesk"]) 43 | 44 | def test_delete_email(self): 45 | self.assertEqual(self.admin.email, "root@d.com") 46 | del self.admin.email 47 | self.assertIsNone(self.admin.email) 48 | 49 | def test_no_set_none(self): 50 | with self.assertRaisesRegex(ValueError, "email can't be set to None"): 51 | self.admin.email = None 52 | with self.assertRaisesRegex(ValueError, "email can't be set to None"): 53 | self.user.email = None 54 | 55 | def test_cannot_delete(self): 56 | with self.assertRaisesRegex( 57 | ValueError, "User \S+ doesn't have \S+ permission" 58 | ): 59 | del self.user.email 60 | 61 | 62 | if __name__ == "__main__": 63 | main() 64 | -------------------------------------------------------------------------------- /Chapter06/test_descriptors_uses_1.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 6: Descriptors 2 | 3 | > Tests for descriptors_uses_1.py 4 | 5 | """ 6 | from datetime import datetime 7 | from unittest import TestCase, main 8 | 9 | from descriptors_uses_1 import LoginEvent, NewLoginEvent 10 | 11 | DATE_TIME = datetime(2016, 7, 20, 15, 45) 12 | 13 | 14 | class BaseTestLoginEvent: 15 | def test_serialization(self): 16 | event = self.login_event_cls( 17 | username="username", 18 | password="password", 19 | ip="127.0.0.1", 20 | timestamp=DATE_TIME, 21 | ) 22 | expected = { 23 | "username": "username", 24 | "password": "**redacted**", 25 | "ip": "127.0.0.1", 26 | "timestamp": "2016-07-20 15:45", 27 | } 28 | self.assertEqual(event.serialize(), expected) 29 | 30 | def test_retrieve_transformed_value(self): 31 | event = self.login_event_cls( 32 | username="username", 33 | password="password", 34 | ip="127.0.0.1", 35 | timestamp=DATE_TIME, 36 | ) 37 | self.assertEqual(event.password, "**redacted**") 38 | self.assertEqual(event.timestamp, "2016-07-20 15:45") 39 | self.assertEqual(event.username, "username") 40 | self.assertEqual(event.ip, "127.0.0.1") 41 | 42 | def test_object_keeps_original_values(self): 43 | event = self.login_event_cls( 44 | username="username", 45 | password="password", 46 | ip="127.0.0.1", 47 | timestamp=DATE_TIME, 48 | ) 49 | self.assertEqual(event.__dict__["password"], "password") 50 | 51 | 52 | class TestLoginEvent(BaseTestLoginEvent, TestCase): 53 | def setUp(self): 54 | self.login_event_cls = LoginEvent 55 | 56 | 57 | class TestNewLoginEvent(BaseTestLoginEvent, TestCase): 58 | def setUp(self): 59 | self.login_event_cls = NewLoginEvent 60 | 61 | 62 | if __name__ == "__main__": 63 | main() 64 | -------------------------------------------------------------------------------- /Chapter07/.gitignore: -------------------------------------------------------------------------------- 1 | *.trace.txt 2 | -------------------------------------------------------------------------------- /Chapter07/Makefile: -------------------------------------------------------------------------------- 1 | PYTHON:=$(VIRTUAL_ENV)/bin/python 2 | 3 | clean: 4 | find . -name "*.swp" -o -name "__pycache__" | xargs rm -fr 5 | 6 | test: 7 | @$(PYTHON) -m doctest *.py 8 | @$(PYTHON) -m unittest *.py 9 | 10 | typehint: 11 | @mypy *.py 12 | 13 | .PHONY: clean test 14 | -------------------------------------------------------------------------------- /Chapter07/README.rst: -------------------------------------------------------------------------------- 1 | Clean Code in Python - Chapter 7: Using Generators 2 | ================================================== 3 | 4 | Run the tests:: 5 | 6 | make test 7 | 8 | 9 | Working With Generators 10 | ----------------------- 11 | 1. A First Glimpse at Generators: ``generators_1.py`` 12 | 13 | 2. The States of a Generator: ``generators_2.py`` 14 | 15 | 16 | Idiomatic Iteration 17 | ------------------- 18 | 1. Idioms for Iteration in Python: ``generators_pythonic_1.py`` 19 | 20 | 2. The Iterator Design Pattern, the Python way 21 | 22 | 2.1. A first approach of iteration with the iteration pattern: ``generators_pythonic_2.py`` 23 | 24 | 2.2. The idiomatic way: using an Iterator: ``generators_pythonic_3.py`` 25 | 26 | 27 | Coroutines 28 | ---------- 29 | 1. The Methods of the Generator Interface: ``generators_coroutines_1.py`` 30 | 31 | 2. Working with Coroutines: ``generators_coroutines_2.py`` 32 | 33 | 3. Delegating Coroutines: ``generators_yieldfrom_{2..3}.py`` 34 | -------------------------------------------------------------------------------- /Chapter07/_generate_data.py: -------------------------------------------------------------------------------- 1 | """Helper to generate test data.""" 2 | import os 3 | from tempfile import gettempdir 4 | 5 | PURCHASES_FILE = os.path.join(gettempdir(), "purchases.csv") 6 | 7 | 8 | def create_purchases_file(filename, entries=1_000_000): 9 | if os.path.exists(PURCHASES_FILE): 10 | return 11 | 12 | with open(filename, "w+") as f: 13 | for i in range(entries): 14 | line = f"2018-01-01,{i}\n" 15 | f.write(line) 16 | 17 | 18 | if __name__ == "__main__": 19 | create_purchases_file(PURCHASES_FILE) 20 | -------------------------------------------------------------------------------- /Chapter07/generators_1.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 7: Using Generators 2 | 3 | > Introduction to generators 4 | 5 | """ 6 | from _generate_data import PURCHASES_FILE, create_purchases_file 7 | from log import logger 8 | 9 | 10 | class PurchasesStats: 11 | def __init__(self, purchases): 12 | self.purchases = iter(purchases) 13 | self.min_price: float = None 14 | self.max_price: float = None 15 | self._total_purchases_price: float = 0.0 16 | self._total_purchases = 0 17 | self._initialize() 18 | 19 | def _initialize(self): 20 | try: 21 | first_value = next(self.purchases) 22 | except StopIteration: 23 | raise ValueError("no values provided") 24 | 25 | self.min_price = self.max_price = first_value 26 | self._update_avg(first_value) 27 | 28 | def process(self): 29 | for purchase_value in self.purchases: 30 | self._update_min(purchase_value) 31 | self._update_max(purchase_value) 32 | self._update_avg(purchase_value) 33 | return self 34 | 35 | def _update_min(self, new_value: float): 36 | if new_value < self.min_price: 37 | self.min_price = new_value 38 | 39 | def _update_max(self, new_value: float): 40 | if new_value > self.max_price: 41 | self.max_price = new_value 42 | 43 | @property 44 | def avg_price(self): 45 | return self._total_purchases_price / self._total_purchases 46 | 47 | def _update_avg(self, new_value: float): 48 | self._total_purchases_price += new_value 49 | self._total_purchases += 1 50 | 51 | def __str__(self): 52 | return ( 53 | f"{self.__class__.__name__}({self.min_price}, " 54 | f"{self.max_price}, {self.avg_price})" 55 | ) 56 | 57 | 58 | def _load_purchases(filename): 59 | purchases = [] 60 | with open(filename) as f: 61 | for line in f: 62 | *_, price_raw = line.partition(",") 63 | purchases.append(float(price_raw)) 64 | 65 | return purchases 66 | 67 | 68 | def load_purchases(filename): 69 | with open(filename) as f: 70 | for line in f: 71 | *_, price_raw = line.partition(",") 72 | yield float(price_raw) 73 | 74 | 75 | def main(): 76 | create_purchases_file(PURCHASES_FILE) 77 | purchases = load_purchases(PURCHASES_FILE) 78 | stats = PurchasesStats(purchases).process() 79 | logger.info("Results: %s", stats) 80 | 81 | 82 | if __name__ == "__main__": 83 | main() 84 | -------------------------------------------------------------------------------- /Chapter07/generators_2.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 7: Using Generators 2 | 3 | > Introduction to generators 4 | """ 5 | 6 | 7 | def sequence(start=0): 8 | """ 9 | >>> import inspect 10 | >>> seq = sequence() 11 | >>> inspect.getgeneratorstate(seq) 12 | 'GEN_CREATED' 13 | 14 | >>> seq = sequence() 15 | >>> next(seq) 16 | 0 17 | >>> inspect.getgeneratorstate(seq) 18 | 'GEN_SUSPENDED' 19 | 20 | >>> seq = sequence() 21 | >>> next(seq) 22 | 0 23 | >>> seq.close() 24 | >>> inspect.getgeneratorstate(seq) 25 | 'GEN_CLOSED' 26 | >>> next(seq) # doctest: +ELLIPSIS 27 | Traceback (most recent call last): 28 | ... 29 | StopIteration 30 | 31 | """ 32 | while True: 33 | yield start 34 | start += 1 35 | -------------------------------------------------------------------------------- /Chapter07/generators_coroutines_1.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 7: Using Generators 2 | 3 | > Methods of the Generators Interface. 4 | 5 | """ 6 | import time 7 | 8 | from log import logger 9 | 10 | 11 | class DBHandler: 12 | """Simulate reading from the database by pages.""" 13 | 14 | def __init__(self, db): 15 | self.db = db 16 | self.is_closed = False 17 | 18 | def read_n_records(self, limit): 19 | return [(i, f"row {i}") for i in range(limit)] 20 | 21 | def close(self): 22 | logger.debug("closing connection to database %r", self.db) 23 | self.is_closed = True 24 | 25 | 26 | def stream_db_records(db_handler): 27 | """Example of .close() 28 | 29 | >>> streamer = stream_db_records(DBHandler("testdb")) # doctest: +ELLIPSIS 30 | >>> len(next(streamer)) 31 | 10 32 | 33 | >>> len(next(streamer)) 34 | 10 35 | """ 36 | try: 37 | while True: 38 | yield db_handler.read_n_records(10) 39 | time.sleep(.1) 40 | except GeneratorExit: 41 | db_handler.close() 42 | 43 | 44 | class CustomException(Exception): 45 | """An exception of the domain model.""" 46 | 47 | 48 | def stream_data(db_handler): 49 | """Test the ``.throw()`` method. 50 | 51 | >>> streamer = stream_data(DBHandler("testdb")) 52 | >>> len(next(streamer)) 53 | 10 54 | """ 55 | while True: 56 | try: 57 | yield db_handler.read_n_records(10) 58 | except CustomException as e: 59 | logger.info("controlled error %r, continuing", e) 60 | except Exception as e: 61 | logger.info("unhandled error %r, stopping", e) 62 | db_handler.close() 63 | break 64 | -------------------------------------------------------------------------------- /Chapter07/generators_coroutines_2.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 7: Using Generators 2 | 3 | > Using Coroutines. 4 | 5 | """ 6 | 7 | 8 | def _stream_db_records(db_handler): 9 | retrieved_data = None 10 | previous_page_size = 10 11 | try: 12 | while True: 13 | page_size = yield retrieved_data 14 | if page_size is None: 15 | page_size = previous_page_size 16 | 17 | previous_page_size = page_size 18 | 19 | retrieved_data = db_handler.read_n_records(page_size) 20 | except GeneratorExit: 21 | db_handler.close() 22 | 23 | 24 | def stream_db_records(db_handler): 25 | retrieved_data = None 26 | page_size = 10 27 | try: 28 | while True: 29 | page_size = (yield retrieved_data) or page_size 30 | retrieved_data = db_handler.read_n_records(page_size) 31 | except GeneratorExit: 32 | db_handler.close() 33 | 34 | 35 | def prepare_coroutine(coroutine): 36 | def wrapped(*args, **kwargs): 37 | advanced_coroutine = coroutine(*args, **kwargs) 38 | next(advanced_coroutine) 39 | return advanced_coroutine 40 | 41 | return wrapped 42 | 43 | 44 | @prepare_coroutine 45 | def auto_stream_db_records(db_handler): 46 | """This coroutine is automatically advanced so it doesn't need the first 47 | next() call. 48 | """ 49 | retrieved_data = None 50 | page_size = 10 51 | try: 52 | while True: 53 | page_size = (yield retrieved_data) or page_size 54 | retrieved_data = db_handler.read_n_records(page_size) 55 | except GeneratorExit: 56 | db_handler.close() 57 | -------------------------------------------------------------------------------- /Chapter07/generators_iteration_1.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 7: Using Generators 2 | 3 | > The Interface for Iteration 4 | 5 | * Distinguish between iterable objects and iterators 6 | * Create iterators 7 | """ 8 | 9 | 10 | class SequenceIterator: 11 | """ 12 | >>> si = SequenceIterator(1, 2) 13 | >>> next(si) 14 | 1 15 | >>> next(si) 16 | 3 17 | >>> next(si) 18 | 5 19 | """ 20 | def __init__(self, start=0, step=1): 21 | self.current = start 22 | self.step = step 23 | 24 | def __next__(self): 25 | value = self.current 26 | self.current += self.step 27 | return value 28 | -------------------------------------------------------------------------------- /Chapter07/generators_iteration_2.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 7: Using Generators 2 | 3 | > The Interface for Iteration: sequences 4 | 5 | """ 6 | 7 | import logging 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class SequenceWrapper: 13 | def __init__(self, original_sequence): 14 | self.seq = original_sequence 15 | 16 | def __getitem__(self, item): 17 | value = self.seq[item] 18 | logger.debug("%s getting %s", self.__class__.__name__, item) 19 | return value 20 | 21 | def __len__(self): 22 | return len(self.seq) 23 | 24 | 25 | class MappedRange: 26 | """Apply a transformation to a range of numbers.""" 27 | 28 | def __init__(self, transformation, start, end): 29 | self._transformation = transformation 30 | self._wrapped = range(start, end) 31 | 32 | def __getitem__(self, index): 33 | value = self._wrapped.__getitem__(index) 34 | result = self._transformation(value) 35 | logger.debug("Index %d: %s", index, result) 36 | return result 37 | 38 | def __len__(self): 39 | return len(self._wrapped) 40 | -------------------------------------------------------------------------------- /Chapter07/generators_pythonic_1.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 7: Using Generators 2 | 3 | > Idiomatic Iteration 4 | 5 | """ 6 | 7 | 8 | class NumberSequence: 9 | """ 10 | >>> seq = NumberSequence() 11 | >>> seq.next() 12 | 0 13 | >>> seq.next() 14 | 1 15 | 16 | >>> seq2 = NumberSequence(10) 17 | >>> seq2.next() 18 | 10 19 | >>> seq2.next() 20 | 11 21 | 22 | """ 23 | 24 | def __init__(self, start=0): 25 | self.current = start 26 | 27 | def next(self): 28 | current = self.current 29 | self.current += 1 30 | return current 31 | 32 | 33 | class SequenceOfNumbers: 34 | """ 35 | >>> list(zip(SequenceOfNumbers(), "abcdef")) 36 | [(0, 'a'), (1, 'b'), (2, 'c'), (3, 'd'), (4, 'e'), (5, 'f')] 37 | 38 | >>> seq = SequenceOfNumbers(100) 39 | >>> next(seq) 40 | 100 41 | >>> next(seq) 42 | 101 43 | 44 | """ 45 | 46 | def __init__(self, start=0): 47 | self.current = start 48 | 49 | def __next__(self): 50 | current = self.current 51 | self.current += 1 52 | return current 53 | 54 | def __iter__(self): 55 | return self 56 | 57 | 58 | def sequence(start=0): 59 | """ 60 | >>> seq = sequence(10) 61 | >>> next(seq) 62 | 10 63 | >>> next(seq) 64 | 11 65 | 66 | >>> list(zip(sequence(), "abcdef")) 67 | [(0, 'a'), (1, 'b'), (2, 'c'), (3, 'd'), (4, 'e'), (5, 'f')] 68 | """ 69 | while True: 70 | yield start 71 | start += 1 72 | -------------------------------------------------------------------------------- /Chapter07/generators_pythonic_2.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 7: Using Generators 2 | 3 | > Idiomatic Iteration with itertools 4 | 5 | """ 6 | import logging 7 | from itertools import filterfalse, tee 8 | 9 | logging.basicConfig(level=logging.INFO) 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class IteratorWrapper: 14 | def __init__(self, iterable): 15 | self.iterable = iter(iterable) 16 | 17 | def __next__(self): 18 | value = next(self.iterable) 19 | logger.debug( 20 | "%s Producing next value: %r", self.__class__.__name__, value 21 | ) 22 | return value 23 | 24 | def __iter__(self): 25 | return self 26 | 27 | 28 | def partition(condition, iterable): 29 | """Return 2 new iterables 30 | 31 | true_cond, false_cond = partition(condition, iterable) 32 | 33 | * in true_cond, condition is true over all elements of iterable 34 | * in false_cond, condition is false over all elements of iterable 35 | """ 36 | for_true, for_false = tee(iterable) 37 | return filter(condition, for_true), filterfalse(condition, for_false) 38 | 39 | 40 | iterable = IteratorWrapper( 41 | {"name": f"element_{i}", "index": i} for i in range(20) 42 | ) 43 | 44 | 45 | def is_even(record): 46 | return record["index"] % 2 == 0 47 | 48 | 49 | def show(records): 50 | return ", ".join(e["name"] for e in records) 51 | 52 | 53 | if __name__ == "__main__": 54 | even, odd = partition(is_even, iterable) 55 | 56 | logger.info( 57 | "\n\tEven records: %s\n\t Odd records: %s", show(even), show(odd) 58 | ) 59 | -------------------------------------------------------------------------------- /Chapter07/generators_pythonic_3.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 7: Using Generators 2 | 3 | > Idiomatic Iteration 4 | 5 | """ 6 | import logging 7 | from itertools import tee 8 | from statistics import median 9 | 10 | logging.basicConfig(level=logging.INFO) 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | def produce_values(how_many): 15 | for i in range(1, how_many + 1): 16 | logger.debug("producing purchase %i", i) 17 | yield i 18 | 19 | 20 | def process_purchases(purchases): 21 | min_, max_, avg = tee(purchases, 3) 22 | return min(min_), max(max_), median(avg) 23 | 24 | 25 | def main(): 26 | data = produce_values(7) 27 | obtained = process_purchases(data) 28 | logger.info("Obtained: %s", obtained) 29 | assert obtained == (1, 7, 4) 30 | 31 | 32 | if __name__ == "__main__": 33 | main() 34 | -------------------------------------------------------------------------------- /Chapter07/generators_pythonic_4.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 7: Using Generators 2 | 3 | > Idiomatic Iteration: simplifying loops 4 | 5 | """ 6 | import unittest 7 | 8 | from log import logger 9 | 10 | 11 | def search_nested_bad(array, desired_value): 12 | """Example of an iteration in a nested loop.""" 13 | coords = None 14 | for i, row in enumerate(array): 15 | for j, cell in enumerate(row): 16 | if cell == desired_value: 17 | coords = (i, j) 18 | break 19 | 20 | if coords is not None: 21 | break 22 | 23 | if coords is None: 24 | raise ValueError(f"{desired_value} not found") 25 | 26 | logger.info("value %r found at [%i, %i]", desired_value, *coords) 27 | return coords 28 | 29 | 30 | def _iterate_array2d(array2d): 31 | for i, row in enumerate(array2d): 32 | for j, cell in enumerate(row): 33 | yield (i, j), cell 34 | 35 | 36 | def search_nested(array, desired_value): 37 | """"Searching in multiple dimensions with a single loop.""" 38 | try: 39 | coord = next( 40 | coord 41 | for (coord, cell) in _iterate_array2d(array) 42 | if cell == desired_value 43 | ) 44 | except StopIteration: 45 | raise ValueError("{desired_value} not found") 46 | 47 | logger.debug("value %r found at [%i, %i]", desired_value, *coord) 48 | return coord 49 | 50 | 51 | if __name__ == "__main__": 52 | unittest.main() 53 | -------------------------------------------------------------------------------- /Chapter07/generators_yieldfrom_1.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 7: Using Generators 2 | 3 | > The ``yield from`` syntax: chain generators. 4 | 5 | """ 6 | 7 | 8 | def chain(*iterables): 9 | """ 10 | >>> list(chain("hello", ["world"], ("tuple", " of ", "values."))) 11 | ['h', 'e', 'l', 'l', 'o', 'world', 'tuple', ' of ', 'values.'] 12 | """ 13 | for it in iterables: 14 | yield from it 15 | 16 | 17 | def _chain(*iterables): 18 | for it in iterables: 19 | for value in it: 20 | yield value 21 | 22 | 23 | def all_powers(n, power): 24 | yield from (n ** i for i in range(power + 1)) 25 | -------------------------------------------------------------------------------- /Chapter07/generators_yieldfrom_2.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 7: Using Generators 2 | 3 | > yield from: Capture the return value. 4 | """ 5 | 6 | from log import logger 7 | 8 | 9 | def sequence(name, start, end): 10 | logger.info("%s started at %i", name, start) 11 | yield from range(start, end) 12 | logger.info("%s finished at %i", name, end) 13 | return end 14 | 15 | 16 | def main(): 17 | step1 = yield from sequence("first", 0, 5) 18 | step2 = yield from sequence("second", step1, 10) 19 | return step1 + step2 20 | -------------------------------------------------------------------------------- /Chapter07/generators_yieldfrom_3.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 7: Using Generators 2 | 3 | > yield from: send values & throw exceptions 4 | 5 | """ 6 | from log import logger 7 | 8 | 9 | class CustomException(Exception): 10 | """A type of exception that is under control.""" 11 | 12 | 13 | def sequence(name, start, end): 14 | value = start 15 | logger.info("%s started at %i", name, value) 16 | while value < end: 17 | try: 18 | received = yield value 19 | logger.info("%s received %r", name, received) 20 | value += 1 21 | except CustomException as e: 22 | logger.info("%s is handling %s", name, e) 23 | received = yield "OK" 24 | return end 25 | 26 | 27 | def main(): 28 | step1 = yield from sequence("first", 0, 5) 29 | step2 = yield from sequence("second", step1, 10) 30 | return step1 + step2 31 | -------------------------------------------------------------------------------- /Chapter07/log.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | logging.basicConfig(level=logging.INFO, format="%(message)s") 4 | logger = logging.getLogger(__name__) 5 | -------------------------------------------------------------------------------- /Chapter07/test_generators.py: -------------------------------------------------------------------------------- 1 | """Clean code in Python - Chapter 07: Using generators 2 | 3 | > Tests for: generators_1.py 4 | """ 5 | from unittest import TestCase, main 6 | 7 | from generators_1 import PurchasesStats 8 | 9 | 10 | class TestPurchaseStats(TestCase): 11 | def test_calculations(self): 12 | stats = PurchasesStats(range(1, 11 + 1)).process() 13 | 14 | self.assertEqual(stats.min_price, 1) 15 | self.assertEqual(stats.max_price, 11) 16 | self.assertEqual(stats.avg_price, 6) 17 | 18 | def test_empty(self): 19 | self.assertRaises(ValueError, PurchasesStats, []) 20 | 21 | 22 | if __name__ == "__main__": 23 | main() 24 | -------------------------------------------------------------------------------- /Chapter07/test_generators_iteration.py: -------------------------------------------------------------------------------- 1 | """Tests for generators_iteration_*.py""" 2 | from unittest import TestCase, main 3 | 4 | from generators_iteration_2 import MappedRange, SequenceWrapper 5 | 6 | 7 | class TestSequenceWrapper(TestCase): 8 | def test_sequence(self): 9 | sequence = SequenceWrapper(list(range(10))) 10 | for i in sequence: 11 | self.assertEqual(i, sequence[i]) 12 | 13 | 14 | class TestMappedRange(TestCase): 15 | def test_limits(self): 16 | self.assertEqual(len(MappedRange(None, 1, 10)), 9) 17 | 18 | seq = MappedRange(abs, -5, 5) 19 | self.assertEqual(seq[-5], 0) 20 | self.assertEqual(seq[-1], 4) 21 | self.assertEqual(seq[4], abs(-1)) 22 | self.assertRaises(IndexError, seq.__getitem__, -16) 23 | self.assertRaises(IndexError, seq.__getitem__, 10) 24 | 25 | def test_getitem(self): 26 | seq = MappedRange(lambda x: x ** 2, 1, 10) 27 | self.assertEqual(seq[5], 36) 28 | 29 | def test_iterate(self): 30 | test_data = ( # start, end, expected 31 | (0, 5, [1, 2, 3, 4, 5]), 32 | (100, 106, [101, 102, 103, 104, 105, 106]), 33 | ) 34 | for start, end, expected in test_data: 35 | with self.subTest(start=start, end=end, expected=expected): 36 | seq = MappedRange(lambda x: x + 1, start, end) 37 | self.assertEqual(list(seq), expected) 38 | 39 | 40 | if __name__ == "__main__": 41 | main() 42 | -------------------------------------------------------------------------------- /Chapter07/test_generators_pythonic.py: -------------------------------------------------------------------------------- 1 | from itertools import chain 2 | from random import choice, randint 3 | from unittest import TestCase, main 4 | 5 | from generators_pythonic_3 import process_purchases, produce_values 6 | from generators_pythonic_4 import search_nested, search_nested_bad 7 | 8 | 9 | class TestPurchaseStats(TestCase): 10 | """Tests for generators_pythonic_3.py""" 11 | 12 | def test_calculations(self): 13 | min_price, max_price, avg_price = process_purchases(produce_values(11)) 14 | 15 | self.assertEqual(min_price, 1) 16 | self.assertEqual(max_price, 11) 17 | self.assertEqual(avg_price, 6) 18 | 19 | def test_empty(self): 20 | self.assertRaises(ValueError, process_purchases, []) 21 | 22 | 23 | class TestSimplifiedIteration(TestCase): 24 | """Tests for generators_pythonic_4.py""" 25 | 26 | def test_found(self): 27 | test_matrix = [[randint(1, 100) for _ in range(10)] for _ in range(10)] 28 | to_search_for = choice(list(chain.from_iterable(test_matrix))) 29 | for finding_function in (search_nested_bad, search_nested): 30 | row, column = finding_function(test_matrix, to_search_for) 31 | self.assertEqual(test_matrix[row][column], to_search_for) 32 | 33 | def test_not_found(self): 34 | matrix = [[i for i in range(10)] for _ in range(2)] 35 | for ffunc in search_nested_bad, search_nested: 36 | self.assertRaises(ValueError, ffunc, matrix, -1) 37 | 38 | 39 | if __name__ == "__main__": 40 | main() 41 | -------------------------------------------------------------------------------- /Chapter08/.gitignore: -------------------------------------------------------------------------------- 1 | .coverage 2 | .hypothesis/ 3 | .pytest_cache/ 4 | -------------------------------------------------------------------------------- /Chapter08/Makefile: -------------------------------------------------------------------------------- 1 | CASE:=1 2 | PYTHON:=$(VIRTUAL_ENV)/bin/python 3 | 4 | setup: 5 | $(VIRTUAL_ENV)/bin/pip install -r requirements.txt 6 | 7 | test: 8 | @$(PYTHON) -m doctest *.py 9 | @$(VIRTUAL_ENV)/bin/pytest . 10 | 11 | clean: 12 | find . -type d -name __pycache__ | xargs rm -fr {} 13 | rm -fr .coverage .pytest_cache/ .hypothesis/ 14 | 15 | coverage: 16 | bash run-coverage.sh $(CASE) 17 | 18 | mutation: 19 | bash mutation-testing.sh $(CASE) 20 | 21 | 22 | .PHONY: test clean mutation coverage setup 23 | -------------------------------------------------------------------------------- /Chapter08/README.rst: -------------------------------------------------------------------------------- 1 | Clean code in Python - Chapter 8: Unit testing and refactoring 2 | ============================================================== 3 | 4 | Install the dependencies:: 5 | 6 | make setup 7 | 8 | 9 | Run the tests:: 10 | 11 | make test 12 | 13 | 14 | Running extra test cases 15 | ^^^^^^^^^^^^^^^^^^^^^^^^ 16 | For example for to try the mutation testing, or coverage, you can use the 17 | following command:: 18 | 19 | make coverage 20 | make mutation 21 | 22 | There are two test cases for each one (1 & 2), which can be specified in the 23 | command. For example:: 24 | 25 | make coverage CASE=1 26 | make mutation CASE=1 27 | 28 | As usual, if you don't have the ``make`` command available, you can always run 29 | the code manually with ``python3 .py``. 30 | -------------------------------------------------------------------------------- /Chapter08/constants.py: -------------------------------------------------------------------------------- 1 | """Definitions""" 2 | 3 | STATUS_ENDPOINT = "http://localhost:8080/mrstatus" 4 | -------------------------------------------------------------------------------- /Chapter08/coverage_1.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 8: Unit Testing & Refactoring 2 | 3 | > Coverage 4 | """ 5 | 6 | from enum import Enum 7 | 8 | 9 | class MergeRequestStatus(Enum): 10 | APPROVED = "approved" 11 | REJECTED = "rejected" 12 | PENDING = "pending" 13 | OPEN = "open" 14 | CLOSED = "closed" 15 | 16 | 17 | class MergeRequestException(Exception): 18 | """Something went wrong with the merge request""" 19 | 20 | 21 | class AcceptanceThreshold: 22 | def __init__(self, merge_request_context: dict) -> None: 23 | self._context = merge_request_context 24 | 25 | def status(self): 26 | if self._context["downvotes"]: 27 | return MergeRequestStatus.REJECTED 28 | elif len(self._context["upvotes"]) >= 2: 29 | return MergeRequestStatus.APPROVED 30 | return MergeRequestStatus.PENDING 31 | 32 | 33 | class MergeRequest: 34 | def __init__(self): 35 | self._context = {"upvotes": set(), "downvotes": set()} 36 | self._status = MergeRequestStatus.OPEN 37 | 38 | def close(self): 39 | self._status = MergeRequestStatus.CLOSED 40 | 41 | @property 42 | def status(self): 43 | if self._status == MergeRequestStatus.CLOSED: 44 | return self._status 45 | 46 | return AcceptanceThreshold(self._context).status() 47 | 48 | def _cannot_vote_if_closed(self): 49 | if self._status == MergeRequestStatus.CLOSED: 50 | raise MergeRequestException("can't vote on a closed merge request") 51 | 52 | def upvote(self, by_user): 53 | self._cannot_vote_if_closed() 54 | 55 | self._context["downvotes"].discard(by_user) 56 | self._context["upvotes"].add(by_user) 57 | 58 | def downvote(self, by_user): 59 | self._cannot_vote_if_closed() 60 | 61 | self._context["upvotes"].discard(by_user) 62 | self._context["downvotes"].add(by_user) 63 | -------------------------------------------------------------------------------- /Chapter08/doctest_module.py: -------------------------------------------------------------------------------- 1 | def convert_num(num_str: str): 2 | """ 3 | >>> convert_num("12345") 4 | 12345 5 | 6 | >>> convert_num("-12345") 7 | -12345 8 | 9 | >>> convert_num("12345-") 10 | -12345 11 | 12 | >>> convert_num("-12345-") 13 | 12345 14 | """ 15 | num, sign = num_str[:-1], num_str[-1] 16 | if sign == "-": 17 | return -int(num) 18 | return int(num_str) 19 | -------------------------------------------------------------------------------- /Chapter08/doctest_module_test.py: -------------------------------------------------------------------------------- 1 | import doctest 2 | import unittest 3 | import doctest_module 4 | 5 | 6 | def load_tests(loader, tests, ignore): 7 | tests.addTests(doctest.DocTestSuite(doctest_module)) 8 | return tests 9 | 10 | 11 | if __name__ == "__main__": 12 | unittest.main() 13 | -------------------------------------------------------------------------------- /Chapter08/mock_1.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 8: Unit Testing 2 | 3 | > Mock Objects 4 | """ 5 | 6 | from typing import Dict, List 7 | 8 | 9 | class GitBranch: 10 | def __init__(self, commits: List[Dict]): 11 | self._commits = {c["id"]: c for c in commits} 12 | 13 | def __getitem__(self, commit_id): 14 | return self._commits[commit_id] 15 | 16 | def __len__(self): 17 | return len(self._commits) 18 | 19 | 20 | def author_by_id(commit_id, branch): 21 | return branch[commit_id]["author"] 22 | -------------------------------------------------------------------------------- /Chapter08/mock_2.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 8: Unit Testing 2 | 3 | > Mock Objects 4 | 5 | """ 6 | from datetime import datetime 7 | 8 | import requests 9 | from constants import STATUS_ENDPOINT 10 | 11 | 12 | class BuildStatus: 13 | """The CI status of a pull request.""" 14 | 15 | @staticmethod 16 | def build_date() -> str: 17 | return datetime.utcnow().isoformat() 18 | 19 | @classmethod 20 | def notify(cls, merge_request_id, status): 21 | build_status = { 22 | "id": merge_request_id, 23 | "status": status, 24 | "built_at": cls.build_date(), 25 | } 26 | response = requests.post(STATUS_ENDPOINT, json=build_status) 27 | response.raise_for_status() 28 | return response 29 | -------------------------------------------------------------------------------- /Chapter08/mrstatus.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class MergeRequestStatus(Enum): 5 | APPROVED = "approved" 6 | REJECTED = "rejected" 7 | PENDING = "pending" 8 | 9 | 10 | class MergeRequestExtendedStatus(Enum): 11 | APPROVED = "approved" 12 | REJECTED = "rejected" 13 | PENDING = "pending" 14 | OPEN = "open" 15 | CLOSED = "closed" 16 | 17 | 18 | class MergeRequestException(Exception): 19 | """Something went wrong with the merge request.""" 20 | -------------------------------------------------------------------------------- /Chapter08/mutation-testing.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | N=$! 4 | if [[ "$N" == "" ]]; then 5 | N=1; 6 | fi 7 | 8 | 9 | mut.py \ 10 | --target mutation_testing_$N \ 11 | --unit-test test_mutation_testing_$N \ 12 | --operator AOD `# delete arithmetic operator` \ 13 | --operator AOR `# replace arithmetic operator` \ 14 | --operator COD `# delete conditional operator` \ 15 | --operator COI `# insert conditional operator` \ 16 | --operator CRP `# replace constant` \ 17 | --operator ROR `# replace relational operator` \ 18 | --show-mutants 19 | -------------------------------------------------------------------------------- /Chapter08/mutation_testing_1.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 8: Unit Testing & Refactoring 2 | 3 | > Mutation Testing 4 | """ 5 | from mrstatus import MergeRequestStatus as Status 6 | 7 | 8 | def evaluate_merge_request(upvote_count, downvotes_count): 9 | if downvotes_count > 0: 10 | return Status.REJECTED 11 | if upvote_count >= 2: 12 | return Status.APPROVED 13 | return Status.PENDING 14 | -------------------------------------------------------------------------------- /Chapter08/mutation_testing_2.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 8: Unit Testing & Refactoring 2 | 3 | > Mutation Testing 2 4 | """ 5 | 6 | from mrstatus import MergeRequestStatus 7 | 8 | 9 | def evaluate_merge_request(upvote_counts, downvotes_count): 10 | if downvotes_count > 0: 11 | return MergeRequestStatus.REJECTED 12 | if upvote_counts >= 2: 13 | return MergeRequestStatus.APPROVED 14 | return MergeRequestStatus.PENDING 15 | -------------------------------------------------------------------------------- /Chapter08/refactoring_1.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 8: Unit Testing 2 | 3 | > Refactoring Code 4 | """ 5 | 6 | from datetime import datetime 7 | 8 | from constants import STATUS_ENDPOINT 9 | 10 | 11 | class BuildStatus: 12 | 13 | endpoint = STATUS_ENDPOINT 14 | 15 | def __init__(self, transport): 16 | self.transport = transport 17 | 18 | @staticmethod 19 | def build_date() -> str: 20 | return datetime.utcnow().isoformat() 21 | 22 | def compose_payload(self, merge_request_id, status) -> dict: 23 | return { 24 | "id": merge_request_id, 25 | "status": status, 26 | "built_at": self.build_date(), 27 | } 28 | 29 | def deliver(self, payload): 30 | response = self.transport.post(self.endpoint, json=payload) 31 | response.raise_for_status() 32 | return response 33 | 34 | def notify(self, merge_request_id, status): 35 | return self.deliver(self.compose_payload(merge_request_id, status)) 36 | -------------------------------------------------------------------------------- /Chapter08/refactoring_2.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 8: Unit Testing and refactoring 2 | 3 | > Refactoring Code 4 | 5 | """ 6 | from mrstatus import MergeRequestExtendedStatus, MergeRequestException 7 | 8 | 9 | class AcceptanceThreshold: 10 | def __init__(self, merge_request_context: dict) -> None: 11 | self._context = merge_request_context 12 | 13 | def status(self): 14 | if self._context["downvotes"]: 15 | return MergeRequestExtendedStatus.REJECTED 16 | elif len(self._context["upvotes"]) >= 2: 17 | return MergeRequestExtendedStatus.APPROVED 18 | return MergeRequestExtendedStatus.PENDING 19 | 20 | 21 | class MergeRequest: 22 | def __init__(self): 23 | self._context = {"upvotes": set(), "downvotes": set()} 24 | self._status = MergeRequestExtendedStatus.OPEN 25 | 26 | def close(self): 27 | self._status = MergeRequestExtendedStatus.CLOSED 28 | 29 | @property 30 | def status(self): 31 | if self._status == MergeRequestExtendedStatus.CLOSED: 32 | return self._status 33 | 34 | return AcceptanceThreshold(self._context).status() 35 | 36 | def _cannot_vote_if_closed(self): 37 | if self._status == MergeRequestExtendedStatus.CLOSED: 38 | raise MergeRequestException("can't vote on a closed merge request") 39 | 40 | def upvote(self, by_user): 41 | self._cannot_vote_if_closed() 42 | 43 | self._context["downvotes"].discard(by_user) 44 | self._context["upvotes"].add(by_user) 45 | 46 | def downvote(self, by_user): 47 | self._cannot_vote_if_closed() 48 | 49 | self._context["upvotes"].discard(by_user) 50 | self._context["downvotes"].add(by_user) 51 | -------------------------------------------------------------------------------- /Chapter08/requirements.txt: -------------------------------------------------------------------------------- 1 | -r ../requirements.txt 2 | coverage==4.5.1 3 | hypothesis==3.66.4 4 | MutPy==0.5.1 5 | pytest-cov==2.5.1 6 | pytest==3.6.3 7 | requests==2.31.0 8 | -------------------------------------------------------------------------------- /Chapter08/run-coverage.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | N=$1 4 | if [[ "$N" == "" ]]; then 5 | N=1; 6 | fi 7 | 8 | pytest \ 9 | --cov-report term-missing \ 10 | --cov=coverage_$N \ 11 | test_coverage_$N.py 12 | -------------------------------------------------------------------------------- /Chapter08/test_coverage_1.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 8: Unit Testing & Refactoring 2 | 3 | > Coverage 4 | - File under test: coverage_1.py 5 | """ 6 | import pytest 7 | 8 | from coverage_1 import (AcceptanceThreshold, MergeRequest, 9 | MergeRequestException, MergeRequestStatus) 10 | 11 | 12 | @pytest.fixture 13 | def rejected_mr(): 14 | merge_request = MergeRequest() 15 | merge_request.downvote("dev1") 16 | return merge_request 17 | 18 | 19 | def test_simple_rejected(rejected_mr): 20 | assert rejected_mr.status == MergeRequestStatus.REJECTED 21 | 22 | 23 | def test_rejected_with_approvals(rejected_mr): 24 | rejected_mr.upvote("dev2") 25 | rejected_mr.upvote("dev3") 26 | assert rejected_mr.status == MergeRequestStatus.REJECTED 27 | 28 | 29 | def test_rejected_to_pending(rejected_mr): 30 | rejected_mr.upvote("dev1") 31 | assert rejected_mr.status == MergeRequestStatus.PENDING 32 | 33 | 34 | def test_rejected_to_approved(rejected_mr): 35 | rejected_mr.upvote("dev1") 36 | rejected_mr.upvote("dev2") 37 | assert rejected_mr.status == MergeRequestStatus.APPROVED 38 | 39 | 40 | def test_just_created_is_pending(): 41 | assert MergeRequest().status == MergeRequestStatus.PENDING 42 | 43 | 44 | def test_pending_awaiting_review(): 45 | merge_request = MergeRequest() 46 | merge_request.upvote("core-dev") 47 | assert merge_request.status == MergeRequestStatus.PENDING 48 | 49 | 50 | def test_approved(): 51 | merge_request = MergeRequest() 52 | merge_request.upvote("dev1") 53 | merge_request.upvote("dev2") 54 | 55 | assert merge_request.status == MergeRequestStatus.APPROVED 56 | 57 | 58 | def test_no_double_approve(): 59 | merge_request = MergeRequest() 60 | merge_request.upvote("dev1") 61 | merge_request.upvote("dev1") 62 | 63 | assert merge_request.status == MergeRequestStatus.PENDING 64 | 65 | 66 | def test_upvote_changes_to_downvote(): 67 | merge_request = MergeRequest() 68 | merge_request.upvote("dev1") 69 | merge_request.upvote("dev2") 70 | merge_request.downvote("dev1") 71 | 72 | assert merge_request.status == MergeRequestStatus.REJECTED 73 | 74 | 75 | def test_downvote_to_upvote(): 76 | merge_request = MergeRequest() 77 | merge_request.upvote("dev1") 78 | merge_request.downvote("dev2") 79 | merge_request.upvote("dev2") 80 | 81 | assert merge_request.status == MergeRequestStatus.APPROVED 82 | 83 | 84 | def test_invalid_types(): 85 | merge_request = MergeRequest() 86 | pytest.raises(TypeError, merge_request.upvote, {"invalid-object"}) 87 | 88 | 89 | def test_cannot_vote_on_closed_merge_request(): 90 | merge_request = MergeRequest() 91 | merge_request.close() 92 | pytest.raises(MergeRequestException, merge_request.upvote, "dev1") 93 | with pytest.raises( 94 | MergeRequestException, match="can't vote on a closed merge request" 95 | ): 96 | merge_request.downvote("dev1") 97 | 98 | 99 | @pytest.mark.parametrize( 100 | "context,expected_status", 101 | ( 102 | ({"downvotes": set(), "upvotes": set()}, MergeRequestStatus.PENDING), 103 | ( 104 | {"downvotes": set(), "upvotes": {"dev1"}}, 105 | MergeRequestStatus.PENDING, 106 | ), 107 | ({"downvotes": "dev1", "upvotes": set()}, MergeRequestStatus.REJECTED), 108 | ( 109 | {"downvotes": set(), "upvotes": {"dev1", "dev2"}}, 110 | MergeRequestStatus.APPROVED, 111 | ), 112 | ), 113 | ) 114 | def test_acceptance_threshold_status_resolution(context, expected_status): 115 | assert AcceptanceThreshold(context).status() == expected_status 116 | -------------------------------------------------------------------------------- /Chapter08/test_mock_1.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 8: Unit Testing 2 | 3 | > Mock Objects 4 | 5 | - File under test: mock_1.py 6 | 7 | """ 8 | 9 | from unittest.mock import MagicMock 10 | 11 | from mock_1 import GitBranch, author_by_id 12 | 13 | 14 | def test_find_commit(): 15 | branch = GitBranch([{"id": "123", "author": "dev1"}]) 16 | assert author_by_id("123", branch) == "dev1" 17 | 18 | 19 | def test_find_any(): 20 | mbranch = MagicMock() 21 | mbranch.__getitem__.return_value = {"author": "test"} 22 | assert author_by_id("123", mbranch) == "test" 23 | -------------------------------------------------------------------------------- /Chapter08/test_mock_2.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 8: Unit Testing 2 | 3 | > Mock Objects 4 | 5 | - File under test: mock_1.py 6 | 7 | """ 8 | 9 | from unittest import mock 10 | 11 | from constants import STATUS_ENDPOINT 12 | from mock_2 import BuildStatus 13 | 14 | 15 | @mock.patch("mock_2.requests") 16 | def test_build_notification_sent(mock_requests): 17 | build_date = "2018-01-01T00:00:01" 18 | with mock.patch("mock_2.BuildStatus.build_date", return_value=build_date): 19 | BuildStatus.notify(123, "OK") 20 | 21 | expected_payload = {"id": 123, "status": "OK", "built_at": build_date} 22 | mock_requests.post.assert_called_with( 23 | STATUS_ENDPOINT, json=expected_payload 24 | ) 25 | -------------------------------------------------------------------------------- /Chapter08/test_mutation_testing_1.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from mrstatus import MergeRequestStatus as Status 4 | from mutation_testing_1 import evaluate_merge_request 5 | 6 | 7 | class TestMergeRequestEvaluation(unittest.TestCase): 8 | def test_approved(self): 9 | result = evaluate_merge_request(3, 0) 10 | self.assertEqual(result, Status.APPROVED) 11 | 12 | 13 | if __name__ == "__main__": 14 | unittest.main() 15 | -------------------------------------------------------------------------------- /Chapter08/test_mutation_testing_2.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from collections import namedtuple 3 | from itertools import starmap 4 | 5 | from mrstatus import MergeRequestStatus as Status 6 | from mutation_testing_1 import evaluate_merge_request 7 | 8 | TestCase = namedtuple( 9 | "TestCase", "number_approved,number_rejected,expected_status" 10 | ) 11 | 12 | TEST_DATA = tuple( 13 | starmap( 14 | TestCase, 15 | ( 16 | (2, 1, Status.REJECTED), 17 | (0, 1, Status.REJECTED), 18 | (2, 0, Status.APPROVED), 19 | (3, 0, Status.APPROVED), 20 | (1, 0, Status.PENDING), 21 | (0, 0, Status.PENDING), 22 | ), 23 | ) 24 | ) 25 | 26 | status_str = { 27 | Status.REJECTED: "rejected", 28 | Status.APPROVED: "approved", 29 | Status.PENDING: "pending", 30 | } 31 | 32 | 33 | class TestMergeRequestEvaluation(unittest.TestCase): 34 | def test_status_resolution(self): 35 | for number_approved, number_rejected, expected_status in TEST_DATA: 36 | obtained = evaluate_merge_request(number_approved, number_rejected) 37 | 38 | self.assertEqual(obtained, expected_status) 39 | 40 | def test_string_values(self): 41 | for number_approved, number_rejected, expected_status in TEST_DATA: 42 | obtained = evaluate_merge_request(number_approved, number_rejected) 43 | 44 | self.assertEqual(obtained.value, status_str[obtained]) 45 | 46 | 47 | if __name__ == "__main__": 48 | unittest.main() 49 | -------------------------------------------------------------------------------- /Chapter08/test_refactoring_1.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 8: Unit Testing 2 | 3 | > Refactoring Code 4 | 5 | - File under test: refactoring_1.py 6 | 7 | """ 8 | from unittest.mock import Mock 9 | 10 | import pytest 11 | 12 | from refactoring_1 import BuildStatus 13 | 14 | 15 | @pytest.fixture 16 | def build_status(): 17 | bstatus = BuildStatus(Mock()) 18 | bstatus.build_date = Mock(return_value="2018-01-01T00:00:01") 19 | return bstatus 20 | 21 | 22 | def test_build_notification_sent(build_status): 23 | 24 | build_status.notify(1234, "OK") 25 | 26 | expected_payload = { 27 | "id": 1234, 28 | "status": "OK", 29 | "built_at": build_status.build_date(), 30 | } 31 | 32 | build_status.transport.post.assert_called_with( 33 | build_status.endpoint, json=expected_payload 34 | ) 35 | -------------------------------------------------------------------------------- /Chapter08/test_ut_design_2.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase, main 2 | from unittest.mock import Mock 3 | 4 | from ut_design_2 import WrappedClient 5 | 6 | 7 | class TestWrappedClient(TestCase): 8 | def test_send_converts_types(self): 9 | wrapped_client = WrappedClient() 10 | wrapped_client.client = Mock() 11 | wrapped_client.send("value", 1) 12 | 13 | wrapped_client.client.send.assert_called_with("value", "1") 14 | 15 | 16 | if __name__ == "__main__": 17 | main() 18 | -------------------------------------------------------------------------------- /Chapter08/test_ut_frameworks_4.py: -------------------------------------------------------------------------------- 1 | """Tests for ut_frameworks_4.py""" 2 | import pytest 3 | 4 | from mrstatus import MergeRequestException 5 | from ut_frameworks_4 import (AcceptanceThreshold, MergeRequest, 6 | MergeRequestStatus) 7 | 8 | 9 | def test_simple_rejected(): 10 | merge_request = MergeRequest() 11 | merge_request.downvote("maintainer") 12 | assert merge_request.status == MergeRequestStatus.REJECTED 13 | 14 | 15 | def test_just_created_is_pending(): 16 | assert MergeRequest().status == MergeRequestStatus.PENDING 17 | 18 | 19 | def test_pending_awaiting_review(): 20 | merge_request = MergeRequest() 21 | merge_request.upvote("core-dev") 22 | assert merge_request.status == MergeRequestStatus.PENDING 23 | 24 | 25 | def test_approved(): 26 | merge_request = MergeRequest() 27 | merge_request.upvote("dev1") 28 | merge_request.upvote("dev2") 29 | 30 | assert merge_request.status == MergeRequestStatus.APPROVED 31 | 32 | 33 | def test_no_double_approve(): 34 | merge_request = MergeRequest() 35 | merge_request.upvote("dev1") 36 | merge_request.upvote("dev1") 37 | 38 | assert merge_request.status == MergeRequestStatus.PENDING 39 | 40 | 41 | def test_upvote_changes_to_downvote(): 42 | merge_request = MergeRequest() 43 | merge_request.upvote("dev1") 44 | merge_request.upvote("dev2") 45 | merge_request.downvote("dev1") 46 | 47 | assert merge_request.status == MergeRequestStatus.REJECTED 48 | 49 | 50 | def test_downvote_to_upvote(): 51 | merge_request = MergeRequest() 52 | merge_request.upvote("dev1") 53 | merge_request.downvote("dev2") 54 | merge_request.upvote("dev2") 55 | 56 | assert merge_request.status == MergeRequestStatus.APPROVED 57 | 58 | 59 | def test_invalid_types(): 60 | merge_request = MergeRequest() 61 | pytest.raises(TypeError, merge_request.upvote, {"invalid-object"}) 62 | 63 | 64 | def test_cannot_vote_on_closed_merge_request(): 65 | merge_request = MergeRequest() 66 | merge_request.close() 67 | pytest.raises(MergeRequestException, merge_request.upvote, "dev1") 68 | with pytest.raises( 69 | MergeRequestException, match="can't vote on a closed merge request" 70 | ): 71 | merge_request.downvote("dev1") 72 | 73 | 74 | @pytest.mark.parametrize( 75 | "context,expected_status", 76 | ( 77 | ({"downvotes": set(), "upvotes": set()}, MergeRequestStatus.PENDING), 78 | ( 79 | {"downvotes": set(), "upvotes": {"dev1"}}, 80 | MergeRequestStatus.PENDING, 81 | ), 82 | ({"downvotes": "dev1", "upvotes": set()}, MergeRequestStatus.REJECTED), 83 | ( 84 | {"downvotes": set(), "upvotes": {"dev1", "dev2"}}, 85 | MergeRequestStatus.APPROVED, 86 | ), 87 | ), 88 | ) 89 | def test_acceptance_threshold_status_resolution(context, expected_status): 90 | assert AcceptanceThreshold(context).status() == expected_status 91 | -------------------------------------------------------------------------------- /Chapter08/ut_design_1.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 8: Unit Testing 2 | 3 | > Unit Testing and Software Design 4 | """ 5 | import logging 6 | import random 7 | 8 | logging.basicConfig(level=logging.INFO) 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class MetricsClient: 13 | """3rd-party metrics client""" 14 | 15 | def send(self, metric_name, metric_value): 16 | if not isinstance(metric_name, str): 17 | raise TypeError("expected type str for metric_name") 18 | 19 | if not isinstance(metric_value, str): 20 | raise TypeError("expected type str for metric_value") 21 | 22 | logger.info("sending %s = %s", metric_name, metric_value) 23 | 24 | 25 | class Process: 26 | """A job that runs in iterations, and depends on an external object.""" 27 | 28 | def __init__(self): 29 | self.client = MetricsClient() # A 3rd-party metrics client 30 | 31 | def process_iterations(self, n_iterations): 32 | for i in range(n_iterations): 33 | result = self.run_process() 34 | self.client.send("iteration.{}".format(i), result) 35 | 36 | def run_process(self): 37 | return random.randint(1, 100) 38 | 39 | 40 | if __name__ == "__main__": 41 | Process().process_iterations(10) 42 | -------------------------------------------------------------------------------- /Chapter08/ut_design_2.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 8: Unit Testing 2 | 3 | > Unit Testing and Software Design 4 | """ 5 | import logging 6 | import random 7 | 8 | logging.basicConfig(level=logging.INFO) 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class MetricsClient: 13 | """3rd-party metrics client""" 14 | 15 | def send(self, metric_name, metric_value): 16 | if not isinstance(metric_name, str): 17 | raise TypeError("expected type str for metric_name") 18 | 19 | if not isinstance(metric_value, str): 20 | raise TypeError("expected type str for metric_value") 21 | 22 | logger.info("sending %s = %s", metric_name, metric_value) 23 | 24 | 25 | class WrappedClient: 26 | """An object under our control that wraps the 3rd party one.""" 27 | 28 | def __init__(self): 29 | self.client = MetricsClient() 30 | 31 | def send(self, metric_name, metric_value): 32 | return self.client.send(str(metric_name), str(metric_value)) 33 | 34 | 35 | class Process: 36 | """Same process, now using a wrapper object.""" 37 | 38 | def __init__(self): 39 | self.client = WrappedClient() 40 | 41 | def process_iterations(self, n_iterations): 42 | for i in range(n_iterations): 43 | result = self.run_process() 44 | self.client.send("iteration.{}".format(i), result) 45 | 46 | def run_process(self): 47 | return random.randint(1, 100) 48 | 49 | 50 | if __name__ == "__main__": 51 | Process().process_iterations(10) 52 | -------------------------------------------------------------------------------- /Chapter08/ut_frameworks_1.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 8: Unit Testing & Refactoring 2 | 3 | > Frameworks and Libraries for Unit Testing 4 | """ 5 | 6 | from mrstatus import MergeRequestStatus 7 | 8 | 9 | class MergeRequest: 10 | """An entity abstracting a merge request.""" 11 | 12 | def __init__(self): 13 | self._context = {"upvotes": set(), "downvotes": set()} 14 | 15 | @property 16 | def status(self): 17 | if self._context["downvotes"]: 18 | return MergeRequestStatus.REJECTED 19 | elif len(self._context["upvotes"]) >= 2: 20 | return MergeRequestStatus.APPROVED 21 | return MergeRequestStatus.PENDING 22 | 23 | def upvote(self, by_user): 24 | self._context["downvotes"].discard(by_user) 25 | self._context["upvotes"].add(by_user) 26 | 27 | def downvote(self, by_user): 28 | self._context["upvotes"].discard(by_user) 29 | self._context["downvotes"].add(by_user) 30 | -------------------------------------------------------------------------------- /Chapter08/ut_frameworks_2.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 8: Unit Testing & Refactoring 2 | 3 | > Frameworks and Libraries for Unit Testing 4 | 5 | """ 6 | 7 | from mrstatus import MergeRequestException 8 | from mrstatus import MergeRequestExtendedStatus as MergeRequestStatus 9 | 10 | 11 | class MergeRequest: 12 | def __init__(self): 13 | self._context = {"upvotes": set(), "downvotes": set()} 14 | self._status = MergeRequestStatus.OPEN 15 | 16 | def close(self): 17 | self._status = MergeRequestStatus.CLOSED 18 | 19 | @property 20 | def status(self): 21 | if self._status == MergeRequestStatus.CLOSED: 22 | return self._status 23 | 24 | if self._context["downvotes"]: 25 | return MergeRequestStatus.REJECTED 26 | elif len(self._context["upvotes"]) >= 2: 27 | return MergeRequestStatus.APPROVED 28 | return MergeRequestStatus.PENDING 29 | 30 | def _cannot_vote_if_closed(self): 31 | if self._status == MergeRequestStatus.CLOSED: 32 | raise MergeRequestException("can't vote on a closed merge request") 33 | 34 | def upvote(self, by_user): 35 | self._cannot_vote_if_closed() 36 | 37 | self._context["downvotes"].discard(by_user) 38 | self._context["upvotes"].add(by_user) 39 | 40 | def downvote(self, by_user): 41 | self._cannot_vote_if_closed() 42 | 43 | self._context["upvotes"].discard(by_user) 44 | self._context["downvotes"].add(by_user) 45 | -------------------------------------------------------------------------------- /Chapter08/ut_frameworks_3.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 8: Unit Testing & Refactoring 2 | 3 | > Frameworks and Libraries for Unit Testing 4 | 5 | """ 6 | 7 | from mrstatus import MergeRequestException 8 | from mrstatus import MergeRequestExtendedStatus as MergeRequestStatus 9 | 10 | 11 | class AcceptanceThreshold: 12 | def __init__(self, merge_request_context: dict) -> None: 13 | self._context = merge_request_context 14 | 15 | def status(self): 16 | if self._context["downvotes"]: 17 | return MergeRequestStatus.REJECTED 18 | elif len(self._context["upvotes"]) >= 2: 19 | return MergeRequestStatus.APPROVED 20 | return MergeRequestStatus.PENDING 21 | 22 | 23 | class MergeRequest: 24 | def __init__(self): 25 | self._context = {"upvotes": set(), "downvotes": set()} 26 | self._status = MergeRequestStatus.OPEN 27 | 28 | def close(self): 29 | self._status = MergeRequestStatus.CLOSED 30 | 31 | @property 32 | def status(self): 33 | if self._status == MergeRequestStatus.CLOSED: 34 | return self._status 35 | 36 | return AcceptanceThreshold(self._context).status() 37 | 38 | def _cannot_vote_if_closed(self): 39 | if self._status == MergeRequestStatus.CLOSED: 40 | raise MergeRequestException("can't vote on a closed merge request") 41 | 42 | def upvote(self, by_user): 43 | self._cannot_vote_if_closed() 44 | 45 | self._context["downvotes"].discard(by_user) 46 | self._context["upvotes"].add(by_user) 47 | 48 | def downvote(self, by_user): 49 | self._cannot_vote_if_closed() 50 | 51 | self._context["upvotes"].discard(by_user) 52 | self._context["downvotes"].add(by_user) 53 | -------------------------------------------------------------------------------- /Chapter08/ut_frameworks_4.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 8: Unit Testing & Refactoring 2 | 3 | > Frameworks and Libraries for Unit Testing 4 | 5 | """ 6 | 7 | from mrstatus import MergeRequestException 8 | from mrstatus import MergeRequestExtendedStatus as MergeRequestStatus 9 | from ut_frameworks_3 import AcceptanceThreshold 10 | 11 | 12 | class MergeRequest: 13 | def __init__(self): 14 | self._context = {"upvotes": set(), "downvotes": set()} 15 | self._status = MergeRequestStatus.OPEN 16 | 17 | def close(self): 18 | self._status = MergeRequestStatus.CLOSED 19 | 20 | @property 21 | def status(self): 22 | if self._status == MergeRequestStatus.CLOSED: 23 | return self._status 24 | 25 | return AcceptanceThreshold(self._context).status() 26 | 27 | def _cannot_vote_if_closed(self): 28 | if self._status == MergeRequestStatus.CLOSED: 29 | raise MergeRequestException("can't vote on a closed merge request") 30 | 31 | def upvote(self, by_user): 32 | self._cannot_vote_if_closed() 33 | 34 | self._context["downvotes"].discard(by_user) 35 | self._context["upvotes"].add(by_user) 36 | 37 | def downvote(self, by_user): 38 | self._cannot_vote_if_closed() 39 | 40 | self._context["upvotes"].discard(by_user) 41 | self._context["downvotes"].add(by_user) 42 | -------------------------------------------------------------------------------- /Chapter09/Makefile: -------------------------------------------------------------------------------- 1 | PYTHON:=$(VIRTUAL_ENV)/bin/python 2 | 3 | test: 4 | @$(PYTHON) -m doctest *.py 5 | @$(PYTHON) -m unittest *.py 6 | 7 | clean: 8 | find . -type f -name "*.pyc" -delete 9 | find . -type d -name __pycache__ | xargs rm -fr {} 10 | 11 | .PHONY: test clean 12 | -------------------------------------------------------------------------------- /Chapter09/README.rst: -------------------------------------------------------------------------------- 1 | Clean code in Python - Chapter 09: Common design patterns 2 | ========================================================= 3 | 4 | Run tests with:: 5 | 6 | make test -------------------------------------------------------------------------------- /Chapter09/_adapter_base.py: -------------------------------------------------------------------------------- 1 | from log import logger 2 | 3 | 4 | class UsernameLookup: 5 | def search(self, user_namespace): 6 | logger.info("looking for %s", user_namespace) 7 | -------------------------------------------------------------------------------- /Chapter09/adapter_1.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 9: Common Design Patterns 2 | 3 | > Adapter (Inheritance) 4 | """ 5 | 6 | from _adapter_base import UsernameLookup 7 | 8 | 9 | class UserSource(UsernameLookup): 10 | def fetch(self, user_id, username): 11 | user_namespace = self._adapt_arguments(user_id, username) 12 | return self.search(user_namespace) 13 | 14 | @staticmethod 15 | def _adapt_arguments(user_id, username): 16 | return f"{user_id}:{username}" 17 | -------------------------------------------------------------------------------- /Chapter09/adapter_2.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 9: Common Design Patterns 2 | 3 | > Adapter (Composition) 4 | """ 5 | 6 | from _adapter_base import UsernameLookup 7 | 8 | 9 | class UserSource: 10 | def __init__(self, username_lookup: UsernameLookup) -> None: 11 | self.username_lookup = username_lookup 12 | 13 | def fetch(self, user_id, username): 14 | user_namespace = self._adapt_arguments(user_id, username) 15 | return self.username_lookup.search(user_namespace) 16 | 17 | @staticmethod 18 | def _adapt_arguments(user_id, username): 19 | return f"{user_id}:{username}" 20 | -------------------------------------------------------------------------------- /Chapter09/chain_of_responsibility_1.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 9: Common Design Patterns 2 | 3 | > Chain of Responsibility 4 | """ 5 | 6 | 7 | import re 8 | 9 | 10 | class Event: 11 | pattern = None 12 | 13 | def __init__(self, next_event=None): 14 | self.successor = next_event 15 | 16 | def process(self, logline: str): 17 | if self.can_process(logline): 18 | return self._process(logline) 19 | 20 | if self.successor is not None: 21 | return self.successor.process(logline) 22 | 23 | def _process(self, logline: str) -> dict: 24 | parsed_data = self._parse_data(logline) 25 | return { 26 | "type": self.__class__.__name__, 27 | "id": parsed_data["id"], 28 | "value": parsed_data["value"], 29 | } 30 | 31 | @classmethod 32 | def can_process(cls, logline: str) -> bool: 33 | return cls.pattern.match(logline) is not None 34 | 35 | @classmethod 36 | def _parse_data(cls, logline: str) -> dict: 37 | return cls.pattern.match(logline).groupdict() 38 | 39 | 40 | class LoginEvent(Event): 41 | pattern = re.compile(r"(?P\d+):\s+login\s+(?P\S+)") 42 | 43 | 44 | class LogoutEvent(Event): 45 | pattern = re.compile(r"(?P\d+):\s+logout\s+(?P\S+)") 46 | 47 | 48 | class SessionEvent(Event): 49 | pattern = re.compile(r"(?P\d+):\s+log(in|out)\s+(?P\S+)") 50 | -------------------------------------------------------------------------------- /Chapter09/composite_1.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 9: Common Design Patterns 2 | 3 | > Composite 4 | """ 5 | 6 | from typing import Iterable, Union 7 | 8 | 9 | class Product: 10 | def __init__(self, name, price): 11 | self._name = name 12 | self._price = price 13 | 14 | @property 15 | def price(self): 16 | return self._price 17 | 18 | 19 | class ProductBundle: 20 | def __init__( 21 | self, 22 | name, 23 | perc_discount, 24 | *products: Iterable[Union[Product, "ProductBundle"]] 25 | ) -> None: 26 | self._name = name 27 | self._perc_discount = perc_discount 28 | self._products = products 29 | 30 | @property 31 | def price(self): 32 | total = sum(p.price for p in self._products) 33 | return total * (1 - self._perc_discount) 34 | -------------------------------------------------------------------------------- /Chapter09/decorator_1.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 9: Common Design Patterns 2 | 3 | > Decorator 4 | """ 5 | 6 | 7 | class DictQuery: 8 | def __init__(self, **kwargs): 9 | self._raw_query = kwargs 10 | 11 | def render(self) -> dict: 12 | return self._raw_query 13 | 14 | 15 | class QueryEnhancer: 16 | def __init__(self, query: DictQuery): 17 | self.decorated = query 18 | 19 | def render(self): 20 | return self.decorated.render() 21 | 22 | 23 | class RemoveEmpty(QueryEnhancer): 24 | def render(self): 25 | original = super().render() 26 | return {k: v for k, v in original.items() if v} 27 | 28 | 29 | class CaseInsensitive(QueryEnhancer): 30 | def render(self): 31 | original = super().render() 32 | return {k: v.lower() for k, v in original.items()} 33 | -------------------------------------------------------------------------------- /Chapter09/decorator_2.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 9: Common Design Patterns 2 | 3 | > Decorator: A function-based version 4 | """ 5 | from typing import Callable, Dict, Iterable 6 | 7 | 8 | class DictQuery: 9 | def __init__(self, **kwargs): 10 | self._raw_query = kwargs 11 | 12 | def render(self) -> dict: 13 | return self._raw_query 14 | 15 | 16 | class QueryEnhancer: 17 | def __init__( 18 | self, 19 | query: DictQuery, 20 | *decorators: Iterable[Callable[[Dict[str, str]], Dict[str, str]]] 21 | ) -> None: 22 | self._decorated = query 23 | self._decorators = decorators 24 | 25 | def render(self): 26 | current_result = self._decorated.render() 27 | for deco in self._decorators: 28 | current_result = deco(current_result) 29 | return current_result 30 | 31 | 32 | def remove_empty(original: dict) -> dict: 33 | return {k: v for k, v in original.items() if v} 34 | 35 | 36 | def case_insensitive(original: dict) -> dict: 37 | return {k: v.lower() for k, v in original.items()} 38 | -------------------------------------------------------------------------------- /Chapter09/log.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | logging.basicConfig() 4 | logger = logging.getLogger(__name__) 5 | -------------------------------------------------------------------------------- /Chapter09/monostate_1.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 9: Common Design Patterns 2 | 3 | > Monostate Pattern 4 | """ 5 | from log import logger 6 | 7 | 8 | class GitFetcher: 9 | _current_tag = None 10 | 11 | def __init__(self, tag): 12 | self.current_tag = tag 13 | 14 | @property 15 | def current_tag(self): 16 | if self._current_tag is None: 17 | raise AttributeError("tag was never set") 18 | return self._current_tag 19 | 20 | @current_tag.setter 21 | def current_tag(self, new_tag): 22 | self.__class__._current_tag = new_tag 23 | 24 | def pull(self): 25 | logger.info("pulling from %s", self.current_tag) 26 | return self.current_tag 27 | -------------------------------------------------------------------------------- /Chapter09/monostate_2.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 9: Common Design Patterns 2 | 3 | > Monostate Pattern 4 | """ 5 | 6 | from log import logger 7 | 8 | 9 | class SharedAttribute: 10 | def __init__(self, initial_value=None): 11 | self.value = initial_value 12 | self._name = None 13 | 14 | def __get__(self, instance, owner): 15 | if instance is None: 16 | return self 17 | if self.value is None: 18 | raise AttributeError(f"{self._name} was never set") 19 | return self.value 20 | 21 | def __set__(self, instance, new_value): 22 | self.value = new_value 23 | 24 | def __set_name__(self, owner, name): 25 | self._name = name 26 | 27 | 28 | class GitFetcher: 29 | 30 | current_tag = SharedAttribute() 31 | current_branch = SharedAttribute() 32 | 33 | def __init__(self, tag, branch=None): 34 | self.current_tag = tag 35 | self.current_branch = branch 36 | 37 | def pull(self): 38 | logger.info("pulling from %s", self.current_tag) 39 | return self.current_tag 40 | -------------------------------------------------------------------------------- /Chapter09/monostate_3.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 9: Common Design Patterns 2 | 3 | > Monostate Pattern: BORG 4 | """ 5 | from log import logger 6 | 7 | 8 | class BaseFetcher: 9 | def __init__(self, source): 10 | self.source = source 11 | 12 | 13 | class TagFetcher(BaseFetcher): 14 | _attributes = {} 15 | 16 | def __init__(self, source): 17 | self.__dict__ = self.__class__._attributes 18 | super().__init__(source) 19 | 20 | def pull(self): 21 | logger.info("pulling from tag %s", self.source) 22 | return f"Tag = {self.source}" 23 | 24 | 25 | class BranchFetcher(BaseFetcher): 26 | _attributes = {} 27 | 28 | def __init__(self, source): 29 | self.__dict__ = self.__class__._attributes 30 | super().__init__(source) 31 | 32 | def pull(self): 33 | logger.info("pulling from branch %s", self.source) 34 | return f"Branch = {self.source}" 35 | -------------------------------------------------------------------------------- /Chapter09/monostate_4.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 9: Common Design Patterns 2 | 3 | > Monostate Pattern: Borg 4 | """ 5 | from log import logger 6 | 7 | 8 | class SharedAllMixin: 9 | def __init__(self, *args, **kwargs): 10 | try: 11 | self.__class__._attributes 12 | except AttributeError: 13 | self.__class__._attributes = {} 14 | 15 | self.__dict__ = self.__class__._attributes 16 | super().__init__(*args, **kwargs) 17 | 18 | 19 | class BaseFetcher: 20 | def __init__(self, source): 21 | self.source = source 22 | 23 | 24 | class TagFetcher(SharedAllMixin, BaseFetcher): 25 | def pull(self): 26 | logger.info("pulling from tag %s", self.source) 27 | return f"Tag = {self.source}" 28 | 29 | 30 | class BranchFetcher(SharedAllMixin, BaseFetcher): 31 | def pull(self): 32 | logger.info("pulling from branch %s", self.source) 33 | return f"Branch = {self.source}" 34 | -------------------------------------------------------------------------------- /Chapter09/state_1.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 9: Common Design Patterns 2 | 3 | > State 4 | """ 5 | import abc 6 | 7 | from log import logger 8 | 9 | 10 | class InvalidTransitionError(Exception): 11 | """Raised when trying to move to a target state from an unreachable source 12 | state. 13 | """ 14 | 15 | 16 | class MergeRequestState(abc.ABC): 17 | def __init__(self, merge_request): 18 | self._merge_request = merge_request 19 | 20 | @abc.abstractmethod 21 | def open(self): 22 | ... 23 | 24 | @abc.abstractmethod 25 | def close(self): 26 | ... 27 | 28 | @abc.abstractmethod 29 | def merge(self): 30 | ... 31 | 32 | def __str__(self): 33 | return self.__class__.__name__ 34 | 35 | 36 | class Open(MergeRequestState): 37 | def open(self): 38 | self._merge_request.approvals = 0 39 | 40 | def close(self): 41 | self._merge_request.approvals = 0 42 | self._merge_request.state = Closed 43 | 44 | def merge(self): 45 | logger.info("merging %s", self._merge_request) 46 | logger.info("deleting branch %s", self._merge_request.source_branch) 47 | self._merge_request.state = Merged 48 | 49 | 50 | class Closed(MergeRequestState): 51 | def open(self): 52 | logger.info("reopening closed merge request %s", self._merge_request) 53 | self._merge_request.state = Open 54 | 55 | def close(self): 56 | """Current state.""" 57 | 58 | def merge(self): 59 | raise InvalidTransitionError("can't merge a closed request") 60 | 61 | 62 | class Merged(MergeRequestState): 63 | def open(self): 64 | raise InvalidTransitionError("already merged request") 65 | 66 | def close(self): 67 | raise InvalidTransitionError("already merged request") 68 | 69 | def merge(self): 70 | """Current state.""" 71 | 72 | 73 | class MergeRequest: 74 | def __init__(self, source_branch: str, target_branch: str) -> None: 75 | self.source_branch = source_branch 76 | self.target_branch = target_branch 77 | self._state = None 78 | self.approvals = 0 79 | self.state = Open 80 | 81 | @property 82 | def state(self): 83 | return self._state 84 | 85 | @state.setter 86 | def state(self, new_state_cls): 87 | self._state = new_state_cls(self) 88 | 89 | def open(self): 90 | return self.state.open() 91 | 92 | def close(self): 93 | return self.state.close() 94 | 95 | def merge(self): 96 | return self.state.merge() 97 | 98 | def __str__(self): 99 | return f"{self.target_branch}:{self.source_branch}" 100 | -------------------------------------------------------------------------------- /Chapter09/state_2.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 9: Common Design Patterns 2 | 3 | > State 4 | """ 5 | import abc 6 | 7 | from log import logger 8 | from state_1 import InvalidTransitionError 9 | 10 | 11 | class MergeRequestState(abc.ABC): 12 | def __init__(self, merge_request): 13 | self._merge_request = merge_request 14 | 15 | @abc.abstractmethod 16 | def open(self): 17 | ... 18 | 19 | @abc.abstractmethod 20 | def close(self): 21 | ... 22 | 23 | @abc.abstractmethod 24 | def merge(self): 25 | ... 26 | 27 | def __str__(self): 28 | return self.__class__.__name__ 29 | 30 | 31 | class Open(MergeRequestState): 32 | def open(self): 33 | self._merge_request.approvals = 0 34 | 35 | def close(self): 36 | self._merge_request.approvals = 0 37 | self._merge_request.state = Closed 38 | 39 | def merge(self): 40 | logger.info("merging %s", self._merge_request) 41 | logger.info("deleting branch %s", self._merge_request.source_branch) 42 | self._merge_request.state = Merged 43 | 44 | 45 | class Closed(MergeRequestState): 46 | def open(self): 47 | logger.info("reopening closed merge request %s", self._merge_request) 48 | self._merge_request.state = Open 49 | 50 | def close(self): 51 | """Current state.""" 52 | 53 | def merge(self): 54 | raise InvalidTransitionError("can't merge a closed request") 55 | 56 | 57 | class Merged(MergeRequestState): 58 | def open(self): 59 | raise InvalidTransitionError("already merged request") 60 | 61 | def close(self): 62 | raise InvalidTransitionError("already merged request") 63 | 64 | def merge(self): 65 | """Current state.""" 66 | 67 | 68 | class MergeRequest: 69 | def __init__(self, source_branch: str, target_branch: str) -> None: 70 | self.source_branch = source_branch 71 | self.target_branch = target_branch 72 | self._state: MergeRequestState 73 | self.approvals = 0 74 | self.state = Open 75 | 76 | @property 77 | def state(self): 78 | return self._state 79 | 80 | @state.setter 81 | def state(self, new_state_cls): 82 | self._state = new_state_cls(self) 83 | 84 | @property 85 | def status(self): 86 | return str(self.state) 87 | 88 | def __getattr__(self, method): 89 | return getattr(self.state, method) 90 | 91 | def __str__(self): 92 | return f"{self.target_branch}:{self.source_branch}" 93 | -------------------------------------------------------------------------------- /Chapter09/test_chain_of_responsibility_1.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 9: Common Design Patterns 2 | 3 | > Chain of Responsibility 4 | 5 | """ 6 | import unittest 7 | 8 | from chain_of_responsibility_1 import LoginEvent, LogoutEvent, SessionEvent 9 | 10 | 11 | class TestMatching(unittest.TestCase): 12 | def test_match_login_event(self): 13 | logline = "1234: login John" 14 | expected = {"id": "1234", "value": "John"} 15 | 16 | self.assertTrue(LoginEvent.can_process(logline)) 17 | self.assertDictEqual(LoginEvent._parse_data(logline), expected) 18 | 19 | def test_match_logout(self): 20 | logline = "5678: logout Jade" 21 | expected = {"id": "5678", "value": "Jade"} 22 | 23 | self.assertTrue(LogoutEvent.can_process(logline)) 24 | self.assertDictEqual(LogoutEvent._parse_data(logline), expected) 25 | 26 | def test_session_event(self): 27 | cases = ("1234: login John", "789: logout Jade") 28 | for logline in cases: 29 | with self.subTest(logline=logline): 30 | self.assertTrue(SessionEvent.can_process(logline)) 31 | 32 | 33 | class TestChain(unittest.TestCase): 34 | def test_no_reception(self): 35 | logline = "no event can match this log" 36 | chain = LoginEvent(LogoutEvent(SessionEvent())) 37 | self.assertIsNone(chain.process(logline)) 38 | 39 | def test_login(self): 40 | logline = "567: login User" 41 | chain = LogoutEvent(LoginEvent()) 42 | expected = {"id": "567", "type": LoginEvent.__name__, "value": "User"} 43 | 44 | self.assertEqual(chain.process(logline), expected) 45 | 46 | def test_login_first(self): 47 | logline = "567: login User" 48 | chain = LogoutEvent(LoginEvent(SessionEvent())) 49 | result = chain.process(logline) 50 | expected = {"id": "567", "type": LoginEvent.__name__, "value": "User"} 51 | 52 | self.assertEqual(result, expected) 53 | 54 | def test_logout_first(self): 55 | logline = "987: logout other_user" 56 | chain = LoginEvent(LogoutEvent(SessionEvent())) 57 | expected = { 58 | "id": "987", 59 | "type": LogoutEvent.__name__, 60 | "value": "other_user", 61 | } 62 | self.assertDictEqual(chain.process(logline), expected) 63 | 64 | def test_generic_first(self): 65 | cases = ("123: login user", "123: logout user") 66 | expected = { 67 | "id": "123", 68 | "type": SessionEvent.__name__, 69 | "value": "user", 70 | } 71 | chain = SessionEvent(LoginEvent(LogoutEvent())) 72 | for logline in cases: 73 | with self.subTest(logline=logline): 74 | self.assertDictEqual(chain.process(logline), expected) 75 | 76 | 77 | if __name__ == "__main__": 78 | unittest.main() 79 | -------------------------------------------------------------------------------- /Chapter09/test_composite_1.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 9: Common Design Patterns 2 | 3 | > Tests Composite 4 | """ 5 | 6 | import unittest 7 | 8 | from composite_1 import Product, ProductBundle 9 | 10 | 11 | class TestProducts(unittest.TestCase): 12 | def test_product_bundle(self): 13 | 14 | tablet = Product("tablet", 200) 15 | bundle = ProductBundle( 16 | "electronics", 17 | 0.1, 18 | tablet, 19 | Product("smartphone", 100), 20 | Product("laptop", 700), 21 | ) 22 | self.assertEqual(tablet.price, 200) 23 | self.assertEqual(bundle.price, 900) 24 | 25 | def test_nested_bundle(self): 26 | electronics = ProductBundle( 27 | "electronics", 28 | 0, 29 | ProductBundle( 30 | "smartphones", 31 | 0.15, 32 | Product("smartphone1", 200), 33 | Product("smartphone2", 700), 34 | ), 35 | ProductBundle( 36 | "laptops", 37 | 0.05, 38 | Product("laptop1", 700), 39 | Product("laptop2", 950), 40 | ), 41 | ) 42 | tablets = ProductBundle( 43 | "tablets", 0.05, Product("tablet1", 200), Product("tablet2", 300) 44 | ) 45 | total = ProductBundle("total", 0, electronics, tablets) 46 | expected_total_price = (0.85 * 900) + (0.95 * 1650) + (0.95 * 500) 47 | 48 | self.assertEqual(total.price, expected_total_price) 49 | 50 | 51 | if __name__ == "__main__": 52 | unittest.main() 53 | -------------------------------------------------------------------------------- /Chapter09/test_decorator_1.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 9: Common Design Patterns 2 | 3 | > Test Decorator 4 | """ 5 | import unittest 6 | 7 | from decorator_1 import CaseInsensitive, DictQuery, RemoveEmpty 8 | 9 | 10 | class TestDecoration(unittest.TestCase): 11 | def setUp(self): 12 | self.query = DictQuery( 13 | foo="bar", empty="", none=None, upper="UPPERCASE", title="Title" 14 | ) 15 | 16 | def test_no_decorate(self): 17 | expected = { 18 | "foo": "bar", 19 | "empty": "", 20 | "none": None, 21 | "upper": "UPPERCASE", 22 | "title": "Title", 23 | } 24 | self.assertDictEqual(self.query.render(), expected) 25 | 26 | def test_decorate(self): 27 | expected = {"foo": "bar", "upper": "uppercase", "title": "title"} 28 | result = CaseInsensitive(RemoveEmpty(self.query)).render() 29 | self.assertDictEqual(result, expected) 30 | 31 | 32 | if __name__ == "__main__": 33 | unittest.main() 34 | -------------------------------------------------------------------------------- /Chapter09/test_decorator_2.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 9: Common Design Patterns 2 | 3 | > Test Decorator 2 4 | """ 5 | import unittest 6 | 7 | from decorator_2 import (DictQuery, QueryEnhancer, case_insensitive, 8 | remove_empty) 9 | 10 | 11 | class TestDecoration(unittest.TestCase): 12 | def setUp(self): 13 | self.query = DictQuery( 14 | foo="bar", empty="", none=None, upper="UPPERCASE", title="Title" 15 | ) 16 | 17 | def test_no_decorate(self): 18 | expected = { 19 | "foo": "bar", 20 | "empty": "", 21 | "none": None, 22 | "upper": "UPPERCASE", 23 | "title": "Title", 24 | } 25 | self.assertDictEqual(self.query.render(), expected) 26 | 27 | def test_decorate(self): 28 | expected = {"foo": "bar", "upper": "uppercase", "title": "title"} 29 | result = QueryEnhancer( 30 | self.query, remove_empty, case_insensitive 31 | ).render() 32 | self.assertDictEqual(result, expected) 33 | 34 | 35 | if __name__ == "__main__": 36 | unittest.main() 37 | -------------------------------------------------------------------------------- /Chapter09/test_monostate_1.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 9: Common Design Patterns 2 | 3 | > Test Monostate Pattern 4 | """ 5 | import unittest 6 | 7 | from monostate_1 import GitFetcher 8 | 9 | 10 | class TestFetcher(unittest.TestCase): 11 | def test_fetch_single(self): 12 | fetcher = GitFetcher(0.1) 13 | self.assertEqual(fetcher.pull(), 0.1) 14 | 15 | def test_fetch_multiple(self): 16 | f1 = GitFetcher(0.1) 17 | f2 = GitFetcher(0.2) 18 | 19 | self.assertEqual(f1.pull(), 0.2) 20 | # There is a new version in f1's request 21 | f1.current_tag = 0.3 22 | 23 | self.assertEqual(f2.pull(), 0.3) 24 | self.assertEqual(f1.pull(), 0.3) 25 | 26 | def test_multiple_consecutive_versions(self): 27 | fetchers = {GitFetcher(i) for i in range(5)} 28 | 29 | self.assertTrue(all(f.current_tag == 4 for f in fetchers)) 30 | 31 | def test_never_set(self): 32 | fetcher = GitFetcher(None) 33 | self.assertRaisesRegex( 34 | AttributeError, "\S+ was never set", fetcher.pull 35 | ) 36 | 37 | 38 | if __name__ == "__main__": 39 | unittest.main() 40 | -------------------------------------------------------------------------------- /Chapter09/test_monostate_2.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 9: Common Design Patterns 2 | 3 | > Test Monostate Pattern 4 | """ 5 | import unittest 6 | 7 | from monostate_2 import GitFetcher 8 | 9 | 10 | class TestCurrentTag(unittest.TestCase): 11 | def test_fetch_single(self): 12 | fetcher = GitFetcher(0.1) 13 | self.assertEqual(fetcher.pull(), 0.1) 14 | 15 | def test_fetch_multiple(self): 16 | f1 = GitFetcher(0.1) 17 | f2 = GitFetcher(0.2) 18 | 19 | self.assertEqual(f1.pull(), 0.2) 20 | # There is a new version in f1's request 21 | f1.current_tag = 0.3 22 | 23 | self.assertEqual(f2.pull(), 0.3) 24 | self.assertEqual(f1.pull(), 0.3) 25 | 26 | def test_multiple_consecutive_versions(self): 27 | fetchers = {GitFetcher(i) for i in range(5)} 28 | 29 | self.assertTrue(all(f.current_tag == 4 for f in fetchers)) 30 | 31 | def test_never_set(self): 32 | fetcher = GitFetcher(None) 33 | self.assertRaisesRegex( 34 | AttributeError, "\S+ was never set", fetcher.pull 35 | ) 36 | 37 | 38 | class TestCurrentBranch(unittest.TestCase): 39 | def test_current_branch(self): 40 | f1 = GitFetcher(0.1, "master") 41 | GitFetcher(0.2, "develop") 42 | 43 | self.assertEqual(f1.current_tag, 0.2) 44 | self.assertEqual(f1.current_branch, "develop") 45 | 46 | 47 | if __name__ == "__main__": 48 | unittest.main() 49 | -------------------------------------------------------------------------------- /Chapter09/test_monostate_3.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 9: Common Design Patterns 2 | 3 | > Test monostate_3.py 4 | """ 5 | import unittest 6 | 7 | from monostate_3 import BranchFetcher, TagFetcher 8 | 9 | 10 | class BaseTest: 11 | def test_pull(self): 12 | tf = self.test_cls(0.1) 13 | for i in range(3): 14 | self.test_cls(i) 15 | 16 | self.assertEqual(tf.source, 2) 17 | self.assertEqual(tf.pull(), f"{self.exp} = 2") 18 | 19 | def test_change_any(self): 20 | tf1, tf2, tf3 = (self.test_cls(i) for i in range(3)) 21 | 22 | self.assertEqual(tf1.source, tf2.source) 23 | 24 | tf3.new_attribute = "any data" 25 | 26 | self.assertEqual(tf1.new_attribute, "any data") 27 | self.assertEqual(tf2.new_attribute, "any data") 28 | 29 | def test_not_any(self): 30 | tf1, tf2 = self.test_cls(1), self.test_cls(2) 31 | 32 | self.assertRaises(AttributeError, getattr, tf1, "non_existing") 33 | 34 | tf1.new = "new" 35 | self.assertEqual(tf2.new, "new") 36 | 37 | 38 | class TestTag(BaseTest, unittest.TestCase): 39 | def setUp(self): 40 | self.test_cls = TagFetcher 41 | self.exp = "Tag" 42 | 43 | 44 | class TestBranch(BaseTest, unittest.TestCase): 45 | def setUp(self): 46 | self.test_cls = BranchFetcher 47 | self.exp = "Branch" 48 | 49 | 50 | class TestTagAndBranch(unittest.TestCase): 51 | """Test attributes aren't mixed between different hierarchies""" 52 | 53 | def test_tag_and_branch(self): 54 | tf1 = TagFetcher(1) 55 | tf2 = TagFetcher(2) 56 | bf1 = BranchFetcher("branch-1") 57 | bf2 = BranchFetcher("branch-2") 58 | 59 | self.assertEqual(tf1.source, 2) 60 | self.assertEqual(bf1.source, "branch-2") 61 | 62 | bf2.source = "develop" 63 | tf1.source = 2.1 64 | 65 | self.assertEqual(bf1.source, "develop") 66 | self.assertEqual(tf2.source, 2.1) 67 | 68 | 69 | if __name__ == "__main__": 70 | unittest.main() 71 | -------------------------------------------------------------------------------- /Chapter09/test_monostate_4.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 9: Common Design Patterns 2 | 3 | > Test monostate_4.py 4 | """ 5 | import unittest 6 | 7 | from monostate_4 import BranchFetcher, TagFetcher 8 | 9 | 10 | class BaseTest: 11 | def test_pull(self): 12 | tf = self.test_cls(0.1) 13 | for i in range(3): 14 | self.test_cls(i) 15 | 16 | self.assertEqual(tf.source, 2) 17 | self.assertEqual(tf.pull(), f"{self.exp} = 2") 18 | 19 | def test_change_any(self): 20 | tf1, tf2, tf3 = (self.test_cls(i) for i in range(3)) 21 | 22 | self.assertEqual(tf1.source, tf2.source) 23 | 24 | tf3.new_attribute = "any data" 25 | 26 | self.assertEqual(tf1.new_attribute, "any data") 27 | self.assertEqual(tf2.new_attribute, "any data") 28 | 29 | def test_not_any(self): 30 | tf1, tf2 = self.test_cls(1), self.test_cls(2) 31 | 32 | self.assertRaises(AttributeError, getattr, tf1, "non_existing") 33 | 34 | tf1.new = "new" 35 | self.assertEqual(tf2.new, "new") 36 | 37 | 38 | class TestTag(BaseTest, unittest.TestCase): 39 | def setUp(self): 40 | self.test_cls = TagFetcher 41 | self.exp = "Tag" 42 | 43 | 44 | class TestBranch(BaseTest, unittest.TestCase): 45 | def setUp(self): 46 | self.test_cls = BranchFetcher 47 | self.exp = "Branch" 48 | 49 | 50 | class TestTagAndBranch(unittest.TestCase): 51 | """Test attributes aren't mixed between different hierarchies""" 52 | 53 | def test_tag_and_branch(self): 54 | tf1 = TagFetcher(1) 55 | tf2 = TagFetcher(2) 56 | bf1 = BranchFetcher("branch-1") 57 | bf2 = BranchFetcher("branch-2") 58 | 59 | self.assertEqual(tf1.source, 2) 60 | self.assertEqual(bf1.source, "branch-2") 61 | 62 | bf2.source = "develop" 63 | tf1.source = 2.1 64 | 65 | self.assertEqual(bf1.source, "develop") 66 | self.assertEqual(tf2.source, 2.1) 67 | 68 | 69 | if __name__ == "__main__": 70 | unittest.main() 71 | -------------------------------------------------------------------------------- /Chapter09/test_state_1.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 9: Common Design Patterns 2 | 3 | > Test State 4 | """ 5 | 6 | 7 | import unittest 8 | 9 | from state_1 import Closed, InvalidTransitionError, Merged, MergeRequest, Open 10 | 11 | 12 | class TestMergeRequestTransitions(unittest.TestCase): 13 | def setUp(self): 14 | self.mr = MergeRequest("develop", "master") 15 | 16 | def test_reopen(self): 17 | self.mr.approvals = 3 18 | self.mr.open() 19 | 20 | self.assertEqual(self.mr.approvals, 0) 21 | 22 | def test_open_to_closed(self): 23 | self.mr.approvals = 2 24 | self.assertIsInstance(self.mr.state, Open) 25 | self.mr.close() 26 | self.assertEqual(self.mr.approvals, 0) 27 | self.assertIsInstance(self.mr.state, Closed) 28 | 29 | def test_closed_to_open(self): 30 | self.mr.close() 31 | self.assertIsInstance(self.mr.state, Closed) 32 | self.mr.open() 33 | self.assertIsInstance(self.mr.state, Open) 34 | 35 | def test_double_close(self): 36 | self.mr.close() 37 | self.mr.close() 38 | 39 | def test_open_to_merge(self): 40 | self.mr.merge() 41 | self.assertIsInstance(self.mr.state, Merged) 42 | 43 | def test_merge_is_final(self): 44 | self.mr.merge() 45 | regex = "already merged request" 46 | self.assertRaisesRegex(InvalidTransitionError, regex, self.mr.open) 47 | self.assertRaisesRegex(InvalidTransitionError, regex, self.mr.close) 48 | 49 | def test_cannot_merge_closed(self): 50 | self.mr.close() 51 | self.assertRaises(InvalidTransitionError, self.mr.merge) 52 | 53 | 54 | if __name__ == "__main__": 55 | unittest.main() 56 | -------------------------------------------------------------------------------- /Chapter09/test_state_2.py: -------------------------------------------------------------------------------- 1 | """Clean Code in Python - Chapter 9: Common Design Patterns 2 | 3 | > Test State 2 4 | """ 5 | 6 | 7 | import unittest 8 | 9 | from state_2 import Closed, InvalidTransitionError, Merged, MergeRequest, Open 10 | 11 | 12 | class TestMergeRequestTransitions(unittest.TestCase): 13 | def setUp(self): 14 | self.mr = MergeRequest("develop", "master") 15 | 16 | def test_reopen(self): 17 | self.mr.approvals = 3 18 | self.mr.open() 19 | 20 | self.assertEqual(self.mr.approvals, 0) 21 | 22 | def test_open_to_closed(self): 23 | self.mr.approvals = 2 24 | self.assertEqual(self.mr.status, Open.__name__) 25 | self.mr.close() 26 | self.assertEqual(self.mr.approvals, 0) 27 | self.assertEqual(self.mr.status, Closed.__name__) 28 | 29 | def test_closed_to_open(self): 30 | self.mr.close() 31 | self.assertEqual(self.mr.status, Closed.__name__) 32 | self.mr.open() 33 | self.assertEqual(self.mr.status, Open.__name__) 34 | 35 | def test_double_close(self): 36 | self.mr.close() 37 | self.mr.close() 38 | 39 | def test_open_to_merge(self): 40 | self.mr.merge() 41 | self.assertEqual(self.mr.status, Merged.__name__) 42 | 43 | def test_merge_is_final(self): 44 | self.mr.merge() 45 | regex = "already merged request" 46 | self.assertRaisesRegex(InvalidTransitionError, regex, self.mr.open) 47 | self.assertRaisesRegex(InvalidTransitionError, regex, self.mr.close) 48 | 49 | def test_cannot_merge_closed(self): 50 | self.mr.close() 51 | self.assertRaises(InvalidTransitionError, self.mr.merge) 52 | 53 | 54 | if __name__ == "__main__": 55 | unittest.main() 56 | -------------------------------------------------------------------------------- /Chapter10/README.rst: -------------------------------------------------------------------------------- 1 | Clean code in Python - Chapter 10: Clean architecture 2 | ===================================================== 3 | 4 | Requirements for running the examples: 5 | 6 | * Docker 7 | * Python 3.7 8 | 9 | There is an example service under the ``service`` directory. Follow its ``Makefile`` and ``README`` instructions to setup. 10 | 11 | Check the ``README`` files under ``service/``. 12 | 13 | To test with a local database, you will need to create its Docker image. 14 | Instructions for this are at ``service/libs/storage/db/``. This will create the 15 | database image with some testing data on it. -------------------------------------------------------------------------------- /Chapter10/service/.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | -------------------------------------------------------------------------------- /Chapter10/service/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.6.6-alpine3.6 2 | 3 | RUN apk add --update \ 4 | python-dev \ 5 | gcc \ 6 | musl-dev \ 7 | make 8 | 9 | WORKDIR /app 10 | ADD . /app 11 | 12 | RUN pip install /app/libs/web /app/libs/storage 13 | RUN pip install /app 14 | 15 | EXPOSE 8080 16 | CMD ["/usr/local/bin/status-service"] 17 | -------------------------------------------------------------------------------- /Chapter10/service/Makefile: -------------------------------------------------------------------------------- 1 | LISTEN_HOST:="0.0.0.0" 2 | LISTEN_PORT:=8080 3 | DBHOST:="127.0.0.1" 4 | DBPORT:=5432 5 | 6 | clean: 7 | find . -type d -name __pycache__ | xargs rm -fr {} 8 | find . -type d -name "*.egg-info" | xargs rm -fr {} 9 | 10 | container: 11 | docker build -t deliveryimg . 12 | 13 | service: 14 | docker run \ 15 | --network=host \ 16 | -p 8080:8080 \ 17 | -e DBUSER=$(DBUSER) \ 18 | -e DBPASSWORD=$(DBPASSWORD) \ 19 | -e DBNAME=$(DBNAME) \ 20 | -e DBHOST=$(DBHOST) \ 21 | -e DBPORT=$(DBPORT) \ 22 | -e LISTEN_HOST=$(LISTEN_HOST) \ 23 | -e LISTEN_PORT=$(LISTEN_PORT) \ 24 | -d deliveryimg 25 | 26 | .PHONY: clean container service 27 | -------------------------------------------------------------------------------- /Chapter10/service/README.rst: -------------------------------------------------------------------------------- 1 | Chapter 10 - Example of a Service 2 | ================================= 3 | 4 | Case: A delivery platform. Check the status of each delivery order by a 5 | GET. 6 | 7 | 8 | Service: Delivery Status 9 | Persistency: A RDBMS 10 | Response Format: JSON 11 | 12 | 13 | Running the Service 14 | ------------------- 15 | 16 | The following environment variables are required to be set:: 17 | 18 | - DBUSER 19 | - DBPASSWORD 20 | - DBNAME 21 | - DBHOST 22 | - DBPORT 23 | 24 | 25 | Create the container as (this is needed only the first time):: 26 | 27 | sudo make container 28 | 29 | Run the container with:: 30 | 31 | sudo -E make service 32 | 33 | 34 | This will start the service, which requires a database available to connect to, 35 | according to the provided parameters of the environment variables ``DBHOST`` 36 | and ``DBPORT``. There is an example of a database in ``./libs/storage/db``. 37 | 38 | If no parameters are configured, the HTTP service will be running in port 39 | ``8080`` by default. Assuming data has been loaded, this can be tested with any 40 | HTTP client:: 41 | 42 | $ curl http://localhost:8080/status/1 43 | {"id":1,"status":"dispatched","msg":"Order was dispatched on 2018-08-01T22:25:12+00:00"} 44 | 45 | $ curl http://localhost:8080/status/99 46 | Error: 99 was not found 47 | 48 | Structure 49 | --------- 50 | Main directories: 51 | 52 | - ``libs``: With the Python packages (dependencies) needed by the service. 53 | - ``statusweb``: The service itself. Imports its dependencies from ``libs```. 54 | -------------------------------------------------------------------------------- /Chapter10/service/libs/README.rst: -------------------------------------------------------------------------------- 1 | service/libs 2 | ============ 3 | 4 | Directory with the dependencies (libraries) for the main application. 5 | 6 | - ``storage``: Python package that abstracts the database. 7 | - ``web``: Python package that abstracts the web framework. 8 | -------------------------------------------------------------------------------- /Chapter10/service/libs/storage/.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | -------------------------------------------------------------------------------- /Chapter10/service/libs/storage/README.rst: -------------------------------------------------------------------------------- 1 | Layer of Abstraction to the DB 2 | ============================== 3 | 4 | This package is to be used as a library, imported from ``service``. 5 | 6 | Instructions to create a test database are at ``db/README.rst``. 7 | -------------------------------------------------------------------------------- /Chapter10/service/libs/storage/db/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM postgres:10.4-alpine 2 | 3 | RUN mkdir -p /usr/share/sql 4 | -------------------------------------------------------------------------------- /Chapter10/service/libs/storage/db/Makefile: -------------------------------------------------------------------------------- 1 | IMAGE:=pgimg 2 | 3 | getdb: 4 | docker build -t $(IMAGE) . 5 | 6 | rundb: 7 | docker run --name testpg \ 8 | -p 5432:5432 \ 9 | -v $(PWD)/sql:/usr/share/sql:z \ 10 | -e POSTGRES_USER=$(DBUSER) \ 11 | -e POSTGRES_PASSWORD=$(DBPASSWORD)\ 12 | -e POSTGRES_DB=$(DBNAME) \ 13 | -d $(IMAGE) 14 | 15 | db: getdb rundb 16 | 17 | _runscript: 18 | docker run -it --rm --link testpg:pgcli \ 19 | -v $(PWD)/sql:/usr/share/sql:z \ 20 | $(IMAGE) psql \ 21 | -h testpg \ 22 | -U $(DBUSER) \ 23 | -d $(DBNAME) \ 24 | -a -f /usr/share/sql/$(SCRIPT) 25 | 26 | schema: 27 | make _runscript SCRIPT="schema.sql" 28 | 29 | data: 30 | make _runscript SCRIPT="data.sql" 31 | 32 | .PHONY: db getdb rundb _runscript schema data 33 | -------------------------------------------------------------------------------- /Chapter10/service/libs/storage/db/README.rst: -------------------------------------------------------------------------------- 1 | Database Service 2 | ^^^^^^^^^^^^^^^^ 3 | 4 | Not strictly required, it jsut creates a database to test the service against. 5 | 6 | This needs some environment variables to be set: 7 | 8 | - DBUSER 9 | - DBPASSWORD 10 | - DBNAME 11 | 12 | Create the container with:: 13 | 14 | make db 15 | 16 | 17 | Depending on the setup, docker might need to run with sudo permissions, in 18 | which case, the environment variables will have to be passed to this process, 19 | as:: 20 | 21 | sudo -E make db 22 | 23 | 24 | Create the schema:: 25 | 26 | sudo -E make schema 27 | 28 | Add some test data:: 29 | 30 | sudo -E make data 31 | -------------------------------------------------------------------------------- /Chapter10/service/libs/storage/db/sql/data.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO delivery_order_status (delivery_id, status) VALUES 2 | (1, 'd'), 3 | (2, 'd'), 4 | (3, 't'), 5 | (4, 't'), 6 | (5, 't'), 7 | (6, 't'), 8 | (7, 't'), 9 | (8, 'f'), 10 | (9, 'f'), 11 | (10, 'f'), 12 | (11, 'f') 13 | ; 14 | 15 | INSERT INTO dispatched (delivery_id, dispatched_at) VALUES 16 | (1, '2018-08-01 22:25:12'), 17 | (2, '2018-08-02 14:45:18') 18 | ; 19 | 20 | INSERT INTO in_transit(delivery_id, location) VALUES 21 | (3, 'at (41.3870° N, 2.1700° E)'), 22 | (4, 'at (41.3870° N, 2.1700° E)'), 23 | (5, 'at (41.3870° N, 2.1700° E)'), 24 | (6, 'at (41.3870° N, 2.1700° E)'), 25 | (7, 'at (41.3870° N, 2.1700° E)') 26 | ; 27 | 28 | INSERT INTO finished (delivery_id, delivered_at) VALUES 29 | (8, '2018-08-01 22:25:12'), 30 | (9, '2018-08-01 22:25:12'), 31 | (10, '2018-08-01 22:25:12'), 32 | (11, '2018-08-01 22:25:12') 33 | ; 34 | -------------------------------------------------------------------------------- /Chapter10/service/libs/storage/db/sql/schema.sql: -------------------------------------------------------------------------------- 1 | /** DB schema for test **/ 2 | 3 | CREATE TABLE delivery_order_status ( 4 | delivery_id SERIAL NOT NULL PRIMARY KEY, 5 | status CHAR(1) -- 'd', 't', 'f' 6 | ); 7 | 8 | 9 | CREATE TABLE dispatched ( 10 | delivery_id INTEGER NOT NULL PRIMARY KEY REFERENCES delivery_order_status(delivery_id), 11 | dispatched_at TIMESTAMP WITH TIME ZONE 12 | ); 13 | 14 | 15 | CREATE TABLE in_transit ( 16 | delivery_id INTEGER NOT NULL PRIMARY KEY REFERENCES delivery_order_status(delivery_id), 17 | location VARCHAR(200) 18 | ); 19 | 20 | 21 | CREATE TABLE finished ( 22 | delivery_id INTEGER NOT NULL PRIMARY KEY REFERENCES delivery_order_status(delivery_id), 23 | delivered_at TIMESTAMP WITH TIME ZONE 24 | ); 25 | -------------------------------------------------------------------------------- /Chapter10/service/libs/storage/setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages, setup 2 | 3 | with open("README.rst", "r") as longdesc: 4 | long_description = longdesc.read() 5 | 6 | 7 | install_requires = ["asyncpg"] 8 | 9 | setup( 10 | name="storage", 11 | description="Abstraction of the Database for the delivery status service", 12 | long_description=long_description, 13 | author="Dev team", 14 | version="0.1.0", 15 | packages=find_packages(where="src/"), 16 | package_dir={"": "src"}, 17 | install_requires=install_requires, 18 | ) 19 | -------------------------------------------------------------------------------- /Chapter10/service/libs/storage/src/storage/__init__.py: -------------------------------------------------------------------------------- 1 | """Expose the functionality of the package.""" 2 | from .client import DBClient 3 | from .storage import DeliveryStatusQuery 4 | from .converters import OrderNotFoundError 5 | 6 | 7 | __all__ = ["DBClient", "DeliveryStatusQuery", "OrderNotFoundError"] 8 | -------------------------------------------------------------------------------- /Chapter10/service/libs/storage/src/storage/client.py: -------------------------------------------------------------------------------- 1 | """Abstraction to the database. 2 | 3 | Provide a client to connect to the database and expose a custom API, at the 4 | convenience of the application. 5 | 6 | """ 7 | import os 8 | 9 | import asyncpg 10 | 11 | 12 | def _extract_from_env(variable, *, default=None): 13 | try: 14 | return os.environ[variable] 15 | 16 | except KeyError as e: 17 | if default is not None: 18 | return default 19 | 20 | raise RuntimeError(f"Environment variable {variable} not set") from e 21 | 22 | 23 | DBUSER = _extract_from_env("DBUSER") 24 | DBPASSWORD = _extract_from_env("DBPASSWORD") 25 | DBNAME = _extract_from_env("DBNAME") 26 | DBHOST = _extract_from_env("DBHOST", default="127.0.0.1") 27 | DBPORT = _extract_from_env("DBPORT", default=5432) 28 | 29 | 30 | async def DBClient(): 31 | return await asyncpg.connect( 32 | user=DBUSER, 33 | password=DBPASSWORD, 34 | database=DBNAME, 35 | host=DBHOST, 36 | port=DBPORT, 37 | ) 38 | -------------------------------------------------------------------------------- /Chapter10/service/libs/storage/src/storage/converters.py: -------------------------------------------------------------------------------- 1 | """Convert the row resulting from a query to the Entities object.""" 2 | from .status import (DeliveryOrder, DispatchedOrder, OrderDelivered, 3 | OrderInTransit) 4 | 5 | 6 | def build_dispatched(row): 7 | return DispatchedOrder(row.dispatched_at) 8 | 9 | 10 | def build_in_transit(row): 11 | return OrderInTransit(row.location) 12 | 13 | 14 | def build_delivered(row): 15 | return OrderDelivered(row.delivered_at) 16 | 17 | 18 | _BUILD_MAPPING = { 19 | "d": build_dispatched, 20 | "t": build_in_transit, 21 | "f": build_delivered, 22 | } 23 | 24 | 25 | class WrappedRow: 26 | def __init__(self, row): 27 | self._row = row 28 | 29 | def __getattr__(self, attrname): 30 | return self._row[attrname] 31 | 32 | 33 | class OrderNotFoundError(Exception): 34 | """The requested order does not appear listed.""" 35 | 36 | 37 | def build_from_row(delivery_id, row): 38 | if row is None: 39 | raise OrderNotFoundError(f"{delivery_id} was not found") 40 | 41 | row = WrappedRow(row) 42 | status_builder = _BUILD_MAPPING[row.status] 43 | status = status_builder(row) 44 | return DeliveryOrder(delivery_id, status) 45 | -------------------------------------------------------------------------------- /Chapter10/service/libs/storage/src/storage/status.py: -------------------------------------------------------------------------------- 1 | """Abstractions for the status of a delivery.""" 2 | from typing import Union 3 | 4 | 5 | class DispatchedOrder: 6 | """An order that was just created and notified to start its delivery.""" 7 | 8 | status = "dispatched" 9 | 10 | def __init__(self, when): 11 | self._when = when 12 | 13 | def message(self) -> dict: 14 | return { 15 | "status": self.status, 16 | "msg": "Order was dispatched on {0}".format( 17 | self._when.isoformat() 18 | ), 19 | } 20 | 21 | 22 | class OrderInTransit: 23 | """An order that is currently being sent to the customer.""" 24 | 25 | status = "in transit" 26 | 27 | def __init__(self, current_location): 28 | self._current_location = current_location 29 | 30 | def message(self) -> dict: 31 | return { 32 | "status": self.status, 33 | "msg": "The order is in progress (current location: {})".format( 34 | self._current_location 35 | ), 36 | } 37 | 38 | 39 | class OrderDelivered: 40 | """An order that was already delivered to the customer.""" 41 | 42 | status = "delivered" 43 | 44 | def __init__(self, delivered_at): 45 | self._delivered_at = delivered_at 46 | 47 | def message(self) -> dict: 48 | return { 49 | "status": self.status, 50 | "msg": "Order delivered on {0}".format( 51 | self._delivered_at.isoformat() 52 | ), 53 | } 54 | 55 | 56 | class DeliveryOrder: 57 | def __init__( 58 | self, 59 | delivery_id: str, 60 | status: Union[DispatchedOrder, OrderInTransit, OrderDelivered], 61 | ) -> None: 62 | self._delivery_id = delivery_id 63 | self._status = status 64 | 65 | def message(self) -> dict: 66 | return {"id": self._delivery_id, **self._status.message()} 67 | -------------------------------------------------------------------------------- /Chapter10/service/libs/storage/src/storage/storage.py: -------------------------------------------------------------------------------- 1 | """Adapter from the low level (BD) to high level objects.""" 2 | 3 | from .client import DBClient 4 | from .converters import build_from_row 5 | from .status import DeliveryOrder 6 | 7 | 8 | class DeliveryStatusQuery: 9 | def __init__(self, delivery_id: int, dbclient: DBClient) -> None: 10 | self._delivery_id = delivery_id 11 | self._client = dbclient 12 | 13 | async def get(self) -> DeliveryOrder: 14 | """Get the current status for this delivery.""" 15 | results = await self._run_query() 16 | return build_from_row(self._delivery_id, results) 17 | 18 | async def _run_query(self): 19 | return await self._client.fetchrow( 20 | """ 21 | SELECT status, d.dispatched_at, t.location, f.delivered_at 22 | FROM delivery_order_status as dos 23 | LEFT JOIN dispatched as d ON (dos.delivery_id = d.delivery_id) 24 | LEFT JOIN in_transit as t ON (dos.delivery_id = t.delivery_id) 25 | LEFT JOIN finished as f ON (dos.delivery_id = f.delivery_id) 26 | WHERE dos.delivery_id = $1 27 | """, 28 | self._delivery_id, 29 | ) 30 | -------------------------------------------------------------------------------- /Chapter10/service/libs/storage/tests/integration/test_retrieve_data.py: -------------------------------------------------------------------------------- 1 | from storage import DeliveryStatusQuery, DBClient 2 | import asyncio 3 | 4 | 5 | loop = asyncio.get_event_loop() 6 | 7 | 8 | async def main(): 9 | client = await DBClient() 10 | dsq = DeliveryStatusQuery(1, client) 11 | result = await dsq.get() 12 | 13 | print(">>>>>>>>>>>>>", result.message()) 14 | r = await DeliveryStatusQuery(99, client).get() 15 | print(r.message()) 16 | 17 | 18 | loop.run_until_complete(main()) 19 | -------------------------------------------------------------------------------- /Chapter10/service/libs/web/README.rst: -------------------------------------------------------------------------------- 1 | libs/web 2 | ======== 3 | 4 | Python package that installs abstractions over the web application. This 5 | package is being imported from the service, and it doesn't require extra setup 6 | (no Docker image, etc.). -------------------------------------------------------------------------------- /Chapter10/service/libs/web/setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages, setup 2 | 3 | with open("README.rst", "r") as longdesc: 4 | long_description = longdesc.read() 5 | 6 | 7 | install_requires = ["sanic"] 8 | 9 | setup( 10 | name="web", 11 | description="Library with helpers for the web-related functionality", 12 | long_description=long_description, 13 | author="Dev team", 14 | version="0.1.0", 15 | packages=find_packages(where="src/"), 16 | package_dir={"": "src"}, 17 | install_requires=install_requires, 18 | ) 19 | -------------------------------------------------------------------------------- /Chapter10/service/libs/web/src/web/__init__.py: -------------------------------------------------------------------------------- 1 | from sanic import Sanic 2 | from sanic.exceptions import NotFound 3 | 4 | from .view import View 5 | 6 | app = Sanic("delivery_status") 7 | 8 | 9 | def register_route(view_object, route): 10 | app.add_route(view_object.as_view(), route) 11 | 12 | 13 | __all__ = ["View", "app", "register_route"] 14 | -------------------------------------------------------------------------------- /Chapter10/service/libs/web/src/web/view.py: -------------------------------------------------------------------------------- 1 | """View helper Objects""" 2 | from sanic.response import json 3 | from sanic.views import HTTPMethodView 4 | 5 | 6 | class View(HTTPMethodView): 7 | """Extend with the logic of the application""" 8 | 9 | async def get(self, request, *args, **kwargs): 10 | response = await self._get(request, *args, **kwargs) 11 | return json(response) 12 | -------------------------------------------------------------------------------- /Chapter10/service/setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages, setup 2 | 3 | with open("README.rst", "r") as longdesc: 4 | long_description = longdesc.read() 5 | 6 | 7 | install_requires = ["web", "storage"] 8 | 9 | setup( 10 | name="delistatus", 11 | description="Check the status of a delivery order", 12 | long_description=long_description, 13 | author="Dev team", 14 | version="0.1.0", 15 | packages=find_packages(), 16 | install_requires=install_requires, 17 | entry_points={ 18 | "console_scripts": [ 19 | "status-service = statusweb.service:main", 20 | ], 21 | }, 22 | ) 23 | -------------------------------------------------------------------------------- /Chapter10/service/statusweb/README.rst: -------------------------------------------------------------------------------- 1 | Micro-service: ``statusweb`` 2 | ============================ 3 | 4 | Dependencies 5 | ------------ 6 | 7 | Python packages: 8 | 9 | - ``libs.storage`` 10 | - ``libs.web`` 11 | 12 | System Dependencies: 13 | 14 | - A ``PostgreSQL`` database up and running. 15 | 16 | Structure 17 | --------- 18 | 19 | - ``service.py`` runs the micro-servie (exposing an HTTP endpoint). 20 | -------------------------------------------------------------------------------- /Chapter10/service/statusweb/__init__.py: -------------------------------------------------------------------------------- 1 | """Entry point to the ``statusweb`` package.""" -------------------------------------------------------------------------------- /Chapter10/service/statusweb/service.py: -------------------------------------------------------------------------------- 1 | """Entry point of the delivery service.""" 2 | import os 3 | 4 | from storage import DBClient, DeliveryStatusQuery, OrderNotFoundError 5 | from web import NotFound, View, app, register_route 6 | 7 | LISTEN_HOST = os.getenv("LISTEN_HOST", "0.0.0.0") 8 | LISTEN_PORT = os.getenv("LISTEN_PORT", 8080) 9 | 10 | 11 | class DeliveryView(View): 12 | async def _get(self, request, delivery_id: int): 13 | dsq = DeliveryStatusQuery(int(delivery_id), await DBClient()) 14 | try: 15 | result = await dsq.get() 16 | except OrderNotFoundError as e: 17 | raise NotFound(str(e)) from e 18 | 19 | return result.message() 20 | 21 | 22 | register_route(DeliveryView, "/status/") 23 | 24 | 25 | def main(): 26 | app.run(host=LISTEN_HOST, port=LISTEN_PORT) 27 | 28 | 29 | if __name__ == "__main__": 30 | main() 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Packt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | clean: 2 | find . -name "*.swp" -o -name "__pycache__" | xargs rm -fr 3 | 4 | setup: 5 | pip install -r requirements.txt 6 | 7 | .PHONY: clean setup 8 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | black==18.6b4 2 | flake8==3.5.0 3 | pyflakes==1.6.0 4 | isort==4.3.4 5 | mypy==0.620 6 | pylint==2.1.1 7 | --------------------------------------------------------------------------------