From 1689eec623a07f4b2824d5195d9b2a34d39b36e2 Mon Sep 17 00:00:00 2001 From: Luciano Ramalho Date: Thu, 20 May 2021 22:58:05 -0300 Subject: [PATCH] ch15: draft examples --- 15-more-types/cafeteria/cafeteria.py | 41 +++++++ 15-more-types/cafeteria/cafeteria_demo.py | 23 ++++ 15-more-types/collections_variance.py | 16 +++ {15-type-hints => 15-more-types}/erp.py | 0 {15-type-hints => 15-more-types}/erp_test.py | 4 +- 15-more-types/gen_contra.py | 59 +++++++++ 15-more-types/petbox/petbox.py | 47 ++++++++ 15-more-types/petbox/petbox_demo.py | 42 +++++++ .../randompick_generic.py | 0 .../randompick_generic_test.py | 2 +- {15-type-hints => 15-more-types}/randompop.py | 2 +- .../randompop_test.py | 2 +- 15-more-types/typeddict/books.py | 32 +++++ 15-more-types/typeddict/books_any.py | 32 +++++ 15-more-types/typeddict/demo_books.py | 20 ++++ 15-more-types/typeddict/demo_not_book.py | 23 ++++ 15-more-types/typeddict/test_books.py | 112 ++++++++++++++++++ .../typeddict/test_books_check_fails.py | 20 ++++ README.md | 60 +++++----- 19 files changed, 501 insertions(+), 36 deletions(-) create mode 100644 15-more-types/cafeteria/cafeteria.py create mode 100644 15-more-types/cafeteria/cafeteria_demo.py create mode 100644 15-more-types/collections_variance.py rename {15-type-hints => 15-more-types}/erp.py (100%) rename {15-type-hints => 15-more-types}/erp_test.py (94%) create mode 100644 15-more-types/gen_contra.py create mode 100644 15-more-types/petbox/petbox.py create mode 100644 15-more-types/petbox/petbox_demo.py rename {15-type-hints => 15-more-types}/randompick_generic.py (100%) rename {15-type-hints => 15-more-types}/randompick_generic_test.py (97%) rename {15-type-hints => 15-more-types}/randompop.py (59%) rename {15-type-hints => 15-more-types}/randompop_test.py (96%) create mode 100644 15-more-types/typeddict/books.py create mode 100644 15-more-types/typeddict/books_any.py create mode 100644 15-more-types/typeddict/demo_books.py create mode 100644 15-more-types/typeddict/demo_not_book.py create mode 100644 15-more-types/typeddict/test_books.py create mode 100644 15-more-types/typeddict/test_books_check_fails.py diff --git a/15-more-types/cafeteria/cafeteria.py b/15-more-types/cafeteria/cafeteria.py new file mode 100644 index 0000000..8c88a92 --- /dev/null +++ b/15-more-types/cafeteria/cafeteria.py @@ -0,0 +1,41 @@ +from typing import TypeVar, Generic + + +class Beverage: + """Any beverage""" + + +class Juice(Beverage): + """Any fruit juice""" + + +class OrangeJuice(Juice): + """Delicious juice Brazilian oranges""" + + +class Coak(Beverage): + """Secret formula with lots of sugar""" + + +BeverageT = TypeVar('BeverageT', bound=Beverage) +JuiceT = TypeVar('JuiceT', bound=Juice) + + +class BeverageDispenser(Generic[BeverageT]): + + beverage: BeverageT + + def __init__(self, beverage: BeverageT) -> None: + self.beverage = beverage + + def dispense(self) -> BeverageT: + return self.beverage + + +class JuiceDispenser(BeverageDispenser[JuiceT]): + pass + + +class Cafeteria: + def __init__(self, dispenser: BeverageDispenser[JuiceT]): + self.dispenser = dispenser diff --git a/15-more-types/cafeteria/cafeteria_demo.py b/15-more-types/cafeteria/cafeteria_demo.py new file mode 100644 index 0000000..d93636b --- /dev/null +++ b/15-more-types/cafeteria/cafeteria_demo.py @@ -0,0 +1,23 @@ +from cafeteria import ( + Cafeteria, + BeverageDispenser, + JuiceDispenser, + Juice, + OrangeJuice, + Coak, +) + +orange = OrangeJuice() + +orange_dispenser: JuiceDispenser[OrangeJuice] = JuiceDispenser(orange) + +juice: Juice = orange_dispenser.dispense() + +soda = Coak() + +## Value of type variable "JuiceT" of "JuiceDispenser" cannot be "Coak" +# soda_dispenser = JuiceDispenser(soda) + +soda_dispenser = BeverageDispenser(soda) + +arnold_hall = Cafeteria(soda_dispenser) diff --git a/15-more-types/collections_variance.py b/15-more-types/collections_variance.py new file mode 100644 index 0000000..12fb9dc --- /dev/null +++ b/15-more-types/collections_variance.py @@ -0,0 +1,16 @@ +from collections.abc import Collection, Sequence + +col_int: Collection[int] + +seq_int: Sequence[int] = (1, 2, 3) + +## Incompatible types in assignment +## expression has type "Collection[int]" +## variable has type "Sequence[int]" +# seq_int = col_int + +col_int = seq_int + +## List item 0 has incompatible type "float" +## expected "int" +# col_int = [1.1] diff --git a/15-type-hints/erp.py b/15-more-types/erp.py similarity index 100% rename from 15-type-hints/erp.py rename to 15-more-types/erp.py diff --git a/15-type-hints/erp_test.py b/15-more-types/erp_test.py similarity index 94% rename from 15-type-hints/erp_test.py rename to 15-more-types/erp_test.py index 5e8c067..9abdc86 100644 --- a/15-type-hints/erp_test.py +++ b/15-more-types/erp_test.py @@ -1,5 +1,4 @@ -import random -from typing import Iterable, TYPE_CHECKING, List +from typing import TYPE_CHECKING from erp import EnterpriserRandomPopper import randompop @@ -9,7 +8,6 @@ def test_issubclass() -> None: assert issubclass(EnterpriserRandomPopper, randompop.RandomPopper) - def test_isinstance_untyped_items_argument() -> None: items = [1, 2, 3] popper = EnterpriserRandomPopper(items) # [int] is not required diff --git a/15-more-types/gen_contra.py b/15-more-types/gen_contra.py new file mode 100644 index 0000000..a8ed9a9 --- /dev/null +++ b/15-more-types/gen_contra.py @@ -0,0 +1,59 @@ +""" +In ``Generator[YieldType, SendType, ReturnType]``, +``SendType`` is contravariant. +The other type variables are covariant. + +This is how ``typing.Generator`` is declared:: + + class Generator(Iterator[T_co], Generic[T_co, T_contra, V_co]): + +(from https://docs.python.org/3/library/typing.html#typing.Generator) + +""" + +from typing import Generator + + +# Generator[YieldType, SendType, ReturnType] + +def gen_float_take_int() -> Generator[float, int, str]: + received = yield -1.0 + while received: + received = yield float(received) + return 'Done' + + +def gen_float_take_float() -> Generator[float, float, str]: + received = yield -1.0 + while received: + received = yield float(received) + return 'Done' + + +def gen_float_take_complex() -> Generator[float, complex, str]: + received = yield -1.0 + while received: + received = yield abs(received) + return 'Done' + +# Generator[YieldType, SendType, ReturnType] + +g0: Generator[float, float, str] = gen_float_take_float() + +g1: Generator[complex, float, str] = gen_float_take_float() + +## Incompatible types in assignment +## expression has type "Generator[float, float, str]" +## variable has type "Generator[int, float, str]") +# g2: Generator[int, float, str] = gen_float_take_float() + + +# Generator[YieldType, SendType, ReturnType] + +g3: Generator[float, int, str] = gen_float_take_float() + +## Incompatible types in assignment +## expression has type "Generator[float, float, str]" +## variable has type "Generator[float, complex, str]") +## g4: Generator[float, complex, str] = gen_float_take_float() + diff --git a/15-more-types/petbox/petbox.py b/15-more-types/petbox/petbox.py new file mode 100644 index 0000000..04b72ea --- /dev/null +++ b/15-more-types/petbox/petbox.py @@ -0,0 +1,47 @@ +from typing import TypeVar, Generic, Any + + +class Pet: + """Domestic animal kept for companionship.""" + + +class Dog(Pet): + """Canis familiaris""" + + +class Cat(Pet): + """Felis catus""" + + +class Siamese(Cat): + """Cat breed from Thailand""" + + +T = TypeVar('T') + + +class Box(Generic[T]): + def put(self, item: T) -> None: + self.contents = item + + def get(self) -> T: + return self.contents + + +T_contra = TypeVar('T_contra', contravariant=True) + + +class InBox(Generic[T_contra]): + def put(self, item: T) -> None: + self.contents = item + + +T_co = TypeVar('T_co', covariant=True) + + +class OutBox(Generic[T_co]): + def __init__(self, contents: Any): + self.contents = contents + + def get(self) -> Any: + return self.contents diff --git a/15-more-types/petbox/petbox_demo.py b/15-more-types/petbox/petbox_demo.py new file mode 100644 index 0000000..d09cab4 --- /dev/null +++ b/15-more-types/petbox/petbox_demo.py @@ -0,0 +1,42 @@ +from typing import TYPE_CHECKING + +from petbox import * + + +cat_box: Box[Cat] = Box() + +si = Siamese() + +cat_box.put(si) + +animal = cat_box.get() + +#if TYPE_CHECKING: +# reveal_type(animal) # Revealed: petbox.Cat* + + +################### Covariance + +out_box: OutBox[Cat] = OutBox(Cat()) + +out_box_si: OutBox[Siamese] = OutBox(Siamese()) + +## Incompatible types in assignment +## expression has type "OutBox[Cat]" +# variable has type "OutBox[Siamese]" +# out_box_si = out_box + +out_box = out_box_si + +################### Contravariance + +in_box: InBox[Cat] = InBox() + +in_box_si: InBox[Siamese] = InBox() + +in_box_si = in_box + +## Incompatible types in assignment +## expression has type "InBox[Siamese]" +## variable has type "InBox[Cat]" +# in_box = in_box_si diff --git a/15-type-hints/randompick_generic.py b/15-more-types/randompick_generic.py similarity index 100% rename from 15-type-hints/randompick_generic.py rename to 15-more-types/randompick_generic.py diff --git a/15-type-hints/randompick_generic_test.py b/15-more-types/randompick_generic_test.py similarity index 97% rename from 15-type-hints/randompick_generic_test.py rename to 15-more-types/randompick_generic_test.py index 07ebdc4..d642b26 100644 --- a/15-type-hints/randompick_generic_test.py +++ b/15-more-types/randompick_generic_test.py @@ -4,7 +4,7 @@ from typing import Iterable, TYPE_CHECKING from randompick_generic import GenericRandomPicker -class LottoPicker(): +class LottoPicker: def __init__(self, items: Iterable[int]) -> None: self._items = list(items) random.shuffle(self._items) diff --git a/15-type-hints/randompop.py b/15-more-types/randompop.py similarity index 59% rename from 15-type-hints/randompop.py rename to 15-more-types/randompop.py index 35cad38..cf9c811 100644 --- a/15-type-hints/randompop.py +++ b/15-more-types/randompop.py @@ -1,4 +1,4 @@ -from typing import Protocol, TypeVar, runtime_checkable, Any +from typing import Protocol, runtime_checkable, Any @runtime_checkable diff --git a/15-type-hints/randompop_test.py b/15-more-types/randompop_test.py similarity index 96% rename from 15-type-hints/randompop_test.py rename to 15-more-types/randompop_test.py index 0b0f317..7cce7f6 100644 --- a/15-type-hints/randompop_test.py +++ b/15-more-types/randompop_test.py @@ -3,7 +3,7 @@ import random from typing import Any, Iterable, TYPE_CHECKING -class SimplePopper(): +class SimplePopper: def __init__(self, items: Iterable) -> None: self._items = list(items) random.shuffle(self._items) diff --git a/15-more-types/typeddict/books.py b/15-more-types/typeddict/books.py new file mode 100644 index 0000000..5a0bc82 --- /dev/null +++ b/15-more-types/typeddict/books.py @@ -0,0 +1,32 @@ +# tag::BOOKDICT[] +from typing import TypedDict, List +import json + +class BookDict(TypedDict): + isbn: str + title: str + authors: List[str] + pagecount: int +# end::BOOKDICT[] + +# tag::TOXML[] +AUTHOR_EL = '{}' + +def to_xml(book: BookDict) -> str: # <1> + elements: List[str] = [] # <2> + for key, value in book.items(): + if isinstance(value, list): # <3> + elements.extend( + AUTHOR_EL.format(n) for n in value) # <4> + else: + tag = key.upper() + elements.append(f'<{tag}>{value}') + xml = '\n\t'.join(elements) + return f'\n\t{xml}\n' +# end::TOXML[] + +# tag::FROMJSON[] +def from_json(data: str) -> BookDict: + whatever: BookDict = json.loads(data) # <1> + return whatever # <2> +# end::FROMJSON[] \ No newline at end of file diff --git a/15-more-types/typeddict/books_any.py b/15-more-types/typeddict/books_any.py new file mode 100644 index 0000000..49a544e --- /dev/null +++ b/15-more-types/typeddict/books_any.py @@ -0,0 +1,32 @@ +# tag::BOOKDICT[] +from typing import TypedDict, List +import json + +class BookDict(TypedDict): + isbn: str + title: str + authors: List[str] + pagecount: int +# end::BOOKDICT[] + +# tag::TOXML[] +AUTHOR_EL = '{}' + +def to_xml(book: BookDict) -> str: # <1> + elements: List[str] = [] # <2> + for key, value in book.items(): + if isinstance(value, list): # <3> + elements.extend(AUTHOR_EL.format(n) + for n in value) + else: + tag = key.upper() + elements.append(f'<{tag}>{value}') + xml = '\n\t'.join(elements) + return f'\n\t{xml}\n' +# end::TOXML[] + +# tag::FROMJSON[] +def from_json(data: str) -> BookDict: + whatever = json.loads(data) # <1> + return whatever # <2> +# end::FROMJSON[] diff --git a/15-more-types/typeddict/demo_books.py b/15-more-types/typeddict/demo_books.py new file mode 100644 index 0000000..5203acb --- /dev/null +++ b/15-more-types/typeddict/demo_books.py @@ -0,0 +1,20 @@ +from books import BookDict +from typing import TYPE_CHECKING + +def demo() -> None: # <1> + book = BookDict( # <2> + isbn='0134757599', + title='Refactoring, 2e', + authors=['Martin Fowler', 'Kent Beck'], + pagecount=478 + ) + authors = book['authors'] # <3> + if TYPE_CHECKING: # <4> + reveal_type(authors) # <5> + authors = 'Bob' # <6> + book['weight'] = 4.2 + del book['title'] + + +if __name__ == '__main__': + demo() diff --git a/15-more-types/typeddict/demo_not_book.py b/15-more-types/typeddict/demo_not_book.py new file mode 100644 index 0000000..7bf0711 --- /dev/null +++ b/15-more-types/typeddict/demo_not_book.py @@ -0,0 +1,23 @@ +from books import to_xml, from_json +from typing import TYPE_CHECKING + +def demo() -> None: + NOT_BOOK_JSON = """ + {"title": "Andromeda Strain", + "flavor": "pistachio", + "authors": true} + """ + not_book = from_json(NOT_BOOK_JSON) # <1> + if TYPE_CHECKING: # <2> + reveal_type(not_book) + reveal_type(not_book['authors']) + + print(not_book) # <3> + print(not_book['flavor']) # <4> + + xml = to_xml(not_book) # <5> + print(xml) # <6> + + +if __name__ == '__main__': + demo() diff --git a/15-more-types/typeddict/test_books.py b/15-more-types/typeddict/test_books.py new file mode 100644 index 0000000..fc9d245 --- /dev/null +++ b/15-more-types/typeddict/test_books.py @@ -0,0 +1,112 @@ +import json +from typing import cast + +from books import BookDict, to_xml, from_json + +XML_SAMPLE = """ + +\t0134757599 +\tRefactoring, 2e +\tMartin Fowler +\tKent Beck +\t478 + +""".strip() + + +# using plain dicts + +def test_1() -> None: + xml = to_xml({ + 'isbn': '0134757599', + 'title': 'Refactoring, 2e', + 'authors': ['Martin Fowler', 'Kent Beck'], + 'pagecount': 478, + }) + assert xml == XML_SAMPLE + +def test_2() -> None: + xml = to_xml(dict( + isbn='0134757599', + title='Refactoring, 2e', + authors=['Martin Fowler', 'Kent Beck'], + pagecount=478)) + assert xml == XML_SAMPLE + +def test_5() -> None: + book_data: BookDict = dict( + isbn='0134757599', + title='Refactoring, 2e', + authors=['Martin Fowler', 'Kent Beck'], + pagecount=478 + ) + xml = to_xml(book_data) + assert xml == XML_SAMPLE + +def test_6() -> None: + book_data = dict( + isbn='0134757599', + title='Refactoring, 2e', + authors=['Martin Fowler', 'Kent Beck'], + pagecount=478 + ) + xml = to_xml(cast(BookDict, book_data)) # cast needed + assert xml == XML_SAMPLE + +def test_4() -> None: + xml = to_xml(BookDict( + isbn='0134757599', + title='Refactoring, 2e', + authors=['Martin Fowler', 'Kent Beck'], + pagecount=478)) + assert xml == XML_SAMPLE + +def test_7() -> None: + book_data = BookDict( + isbn='0134757599', + title='Refactoring, 2e', + authors=['Martin Fowler', 'Kent Beck'], + pagecount=478 + ) + xml = to_xml(book_data) + assert xml == XML_SAMPLE + +def test_8() -> None: + book_data: BookDict = { + 'isbn': '0134757599', + 'title': 'Refactoring, 2e', + 'authors': ['Martin Fowler', 'Kent Beck'], + 'pagecount': 478, + } + xml = to_xml(book_data) + assert xml == XML_SAMPLE + +BOOK_JSON = """ + {"isbn": "0134757599", + "title": "Refactoring, 2e", + "authors": ["Martin Fowler", "Kent Beck"], + "pagecount": 478} +""" + +def test_load_book_0() -> None: + book_data: BookDict = json.loads(BOOK_JSON) # typed var + xml = to_xml(book_data) + assert xml == XML_SAMPLE + +def test_load_book() -> None: + book_data = from_json(BOOK_JSON) + xml = to_xml(book_data) + assert xml == XML_SAMPLE + + +NOT_BOOK_JSON = """ + {"isbn": 3.141592653589793 + "title": [1, 2, 3], + "authors": ["Martin Fowler", "Kent Beck"], + "flavor": "strawberry"} +""" + +def test_load_not_book() -> None: + book_data: BookDict = json.loads(BOOK_JSON) # typed var + xml = to_xml(book_data) + assert xml == XML_SAMPLE diff --git a/15-more-types/typeddict/test_books_check_fails.py b/15-more-types/typeddict/test_books_check_fails.py new file mode 100644 index 0000000..6166f97 --- /dev/null +++ b/15-more-types/typeddict/test_books_check_fails.py @@ -0,0 +1,20 @@ +from books import BookDict, to_xml + +XML_SAMPLE = """ + +\t0134757599 +\tRefactoring, 2e +\tMartin Fowler +\tKent Beck +\t478 + +""".strip() + +def test_3() -> None: + xml = to_xml(BookDict(dict([ # Expected keyword arguments, {...}, or dict(...) in TypedDict constructor + ('isbn', '0134757599'), + ('title', 'Refactoring, 2e'), + ('authors', ['Martin Fowler', 'Kent Beck']), + ('pagecount', 478), + ]))) + assert xml == XML_SAMPLE diff --git a/README.md b/README.md index 56e7420..d0b57b3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Fluent Python 2e example code -Example code for the book **Fluent Python, 2nd edition** by Luciano Ramalho (O'Reilly, 2021). +Example code for the book **Fluent Python, 2nd edition** by Luciano Ramalho (O'Reilly, 2020). > **BEWARE**: This is a work in progress! > @@ -14,40 +14,40 @@ Example code for the book **Fluent Python, 2nd edition** by Luciano R All chapters are undergoing review and updates, including significant rewrites in the chapters about concurrency in **Part V**. -New chapters in **Fluent Python 2nd edition** are marked with 🆕. +New chapters in **Fluent Python 2e** are marked with 🆕. -🚨 This table of contents is subject to change at any time until the book goes to the printer. +🚨 This table of contents is subject to change at any time until the book goes to the printer. -Part / Chapter #|Title|Directory|1st edition Chapter # ----:|---|---|:---: +Part / Chapter #|Title|Directory|Notebook|1st ed. Chapter # +---:|---|---|---|:---: **I – Prologue**| -1|The Python Data Model|[01-data-model](01-data-model)|1 +1|The Python Data Model|[01-data-model](01-data-model)|[data-model.ipynb](01-data-model/data-model.ipynb)|1 **II – Data Structures**| -2|An Array of Sequences|[02-array-seq](02-array-seq)|2 -3|Dictionaries and Sets|[03-dict-set](03-dict-set)|3 -4|Text versus Bytes|[04-text-byte](04-text-byte)|4 -5|Record-like Data Structures|[05-record-like](05-record-like)|🆕 -6|Object References, Mutability, and Recycling|[06-obj-ref](06-obj-ref)|8 +2|An Array of Sequences|[02-array-seq](02-array-seq)|[array-seq.ipynb](02-array-seq/array-seq.ipynb)|2 +3|Dictionaries and Sets|[03-dict-set](03-dict-set)||3 +4|Text versus Bytes|[04-text-byte](04-text-byte)||4 +🆕 5|Record-like Data Structures|[05-record-like](05-record-like)||– +6|Object References, Mutability, and Recycling|[06-obj-ref](06-obj-ref)||8 **III – Functions as Objects**| -7|First-Class Funcions|[07-1class-func](07-1class-func)|5 -8|Type Hints in Function Definitions|[08-def-type-hints](08-def-type-hints)|🆕 -9|Function Decorators and Closures|[09-closure-deco](09-closure-deco)|7 -10|Design Patterns with First-Class Functions|[10-dp-1class-func](10-dp-1class-func)|6 +7|First-Class Funcions|[07-1class-func](07-1class-func)||5 +🆕 8|Type Hints in Function Definitions|[08-def-type-hints](08-def-type-hints)||– +9|Function Decorators and Closures|[09-closure-deco](09-closure-deco)||7 +10|Design Patterns with First-Class Functions|[10-dp-1class-func](10-dp-1class-func)||6 **IV – Object-Oriented Idioms**| -11|A Pythonic Object|[11-pythonic-obj](11-pythonic-obj)|9 -12|Sequence Hacking, Hashing, and Slicing|[12-seq-hacking](12-seq-hacking)|10 -13|Interfaces, Protocols, and ABCs|[13-protocl-abc](13-protocol-abc)|11 -14|Inheritance: For Good or For Worse|[14-inheritance](14-inheritance)|12 -15|More About Type Hints|15-more-typing|🆕 -16|Operator Overloading: Doing It Right|[16-op-overloading](16-op-overloading)|13 +11|A Pythonic Object|[11-pythonic-obj](11-pythonic-obj)||9 +12|Sequence Hacking, Hashing, and Slicing|[12-seq-hacking](12-seq-hacking)||10 +13|Interfaces, Protocols, and ABCs|[13-protocl-abc](13-protocol-abc)||11 +14|Inheritance: For Good or For Worse|[14-inheritance](14-inheritance)||12 +🆕 15|More About Type Hints|[15-more-types](15-more-types)||– +16|Operator Overloading: Doing It Right|[16-op-overloading](16-op-overloading)||13 **V – Control Flow**| -17|Iterables, Iterators, and Generators|[17-it-generator](17-it-generator)|14 -18|Context Managers and else Blocks|[18-context-mngr](18-context-mngr)|15 -19|Classic Coroutines|[19-coroutine](19-coroutine)|16 -20|Concurrency Models in Python|[20-concurrency](20-concurrency)|🆕 -21|Concurrency with Futures|[21-futures](21-futures)|17 -22|Asynchronous Programming|[22-async](22-async)|18 +17|Iterables, Iterators, and Generators|[17-it-generator](17-it-generator)||14 +18|Context Managers and else Blocks|[18-context-mngr](18-context-mngr)||15 +19|Classic Coroutines|[19-coroutine](19-coroutine)||16 +🆕 20|Concurrency Models in Python|[20-concurrency](20-concurrency)||- +21|Concurrency with Futures|[21-futures](21-futures)||17 +22|Asynchronous Programming|[22-async](22-async)||18 **VI – Metaprogramming**| -23|Dynamic Attributes and Properties|[23-dyn-attr-prop](23-dyn-attr-prop)|19 -24|Attribute Descriptors|[24-descriptor](24-descriptor)|20 -25|Class Metaprogramming|[25-class-metaprog](25-class-metaprog)|21 +23|Dynamic Attributes and Properties|[22-dyn-attr-prop](22-dyn-attr-prop)||19 +24|Attribute Descriptors|[23-descriptor](23-descriptor)||20 +25|Class Metaprogramming|[24-class-metaprog](24-class-metaprog)||21