From f00a2b31248639890698ac604968cccb76bc787c Mon Sep 17 00:00:00 2001 From: Stephen Gruppetta Date: Tue, 15 Sep 2026 13:04:25 +0000 Subject: [PATCH] Sample code for: Python Circular Imports: Why They Happen and How to Fix Them Seven self-contained projects, one per stage of the tutorial. Each has the same shop/ package plus a main.py, so a reader can run any stage without editing files in place. Two of them fail on purpose: broken/ is the circular import the tutorial sets out to fix, and workaround3_attribute_error/ shows the module-level attribute access failing. Verified on CPython 3.14.6. Passes ruff format and ruff check under the repo config, with two noqa directives where the tutorial is deliberately showing code a linter objects to. Co-Authored-By: Claude Opus 5 (1M context) --- python-circular-imports/README.md | 37 +++++++++++++++++++ python-circular-imports/broken/main.py | 7 ++++ .../broken/shop/__init__.py | 0 python-circular-imports/broken/shop/order.py | 17 +++++++++ .../broken/shop/product.py | 13 +++++++ .../step1_remove_dependency/main.py | 8 ++++ .../step1_remove_dependency/shop/__init__.py | 0 .../step1_remove_dependency/shop/order.py | 17 +++++++++ .../step1_remove_dependency/shop/product.py | 9 +++++ .../step2_guard_annotation/main.py | 8 ++++ .../step2_guard_annotation/shop/__init__.py | 0 .../step2_guard_annotation/shop/order.py | 17 +++++++++ .../step2_guard_annotation/shop/product.py | 13 +++++++ .../workaround1_defer_import/main.py | 7 ++++ .../workaround1_defer_import/shop/__init__.py | 0 .../workaround1_defer_import/shop/order.py | 17 +++++++++ .../workaround1_defer_import/shop/product.py | 18 +++++++++ .../workaround2_bottom_import/main.py | 7 ++++ .../shop/__init__.py | 0 .../workaround2_bottom_import/shop/order.py | 17 +++++++++ .../workaround2_bottom_import/shop/product.py | 14 +++++++ .../workaround3_attribute_error/main.py | 7 ++++ .../shop/__init__.py | 0 .../workaround3_attribute_error/shop/order.py | 24 ++++++++++++ .../shop/product.py | 13 +++++++ .../workaround3_import_module/main.py | 7 ++++ .../shop/__init__.py | 0 .../workaround3_import_module/shop/order.py | 22 +++++++++++ .../workaround3_import_module/shop/product.py | 13 +++++++ 29 files changed, 312 insertions(+) create mode 100644 python-circular-imports/README.md create mode 100644 python-circular-imports/broken/main.py create mode 100644 python-circular-imports/broken/shop/__init__.py create mode 100644 python-circular-imports/broken/shop/order.py create mode 100644 python-circular-imports/broken/shop/product.py create mode 100644 python-circular-imports/step1_remove_dependency/main.py create mode 100644 python-circular-imports/step1_remove_dependency/shop/__init__.py create mode 100644 python-circular-imports/step1_remove_dependency/shop/order.py create mode 100644 python-circular-imports/step1_remove_dependency/shop/product.py create mode 100644 python-circular-imports/step2_guard_annotation/main.py create mode 100644 python-circular-imports/step2_guard_annotation/shop/__init__.py create mode 100644 python-circular-imports/step2_guard_annotation/shop/order.py create mode 100644 python-circular-imports/step2_guard_annotation/shop/product.py create mode 100644 python-circular-imports/workaround1_defer_import/main.py create mode 100644 python-circular-imports/workaround1_defer_import/shop/__init__.py create mode 100644 python-circular-imports/workaround1_defer_import/shop/order.py create mode 100644 python-circular-imports/workaround1_defer_import/shop/product.py create mode 100644 python-circular-imports/workaround2_bottom_import/main.py create mode 100644 python-circular-imports/workaround2_bottom_import/shop/__init__.py create mode 100644 python-circular-imports/workaround2_bottom_import/shop/order.py create mode 100644 python-circular-imports/workaround2_bottom_import/shop/product.py create mode 100644 python-circular-imports/workaround3_attribute_error/main.py create mode 100644 python-circular-imports/workaround3_attribute_error/shop/__init__.py create mode 100644 python-circular-imports/workaround3_attribute_error/shop/order.py create mode 100644 python-circular-imports/workaround3_attribute_error/shop/product.py create mode 100644 python-circular-imports/workaround3_import_module/main.py create mode 100644 python-circular-imports/workaround3_import_module/shop/__init__.py create mode 100644 python-circular-imports/workaround3_import_module/shop/order.py create mode 100644 python-circular-imports/workaround3_import_module/shop/product.py diff --git a/python-circular-imports/README.md b/python-circular-imports/README.md new file mode 100644 index 0000000000..ff2ee0fa01 --- /dev/null +++ b/python-circular-imports/README.md @@ -0,0 +1,37 @@ +# Python Circular Imports: Why They Happen and How to Fix Them + +This folder provides the code examples for the Real Python tutorial [Python Circular Imports: Why They Happen and How to Fix Them](https://realpython.com/python-circular-imports/) + +Everything here is standard library only. The examples target **Python 3.14**, which evaluates annotations lazily. Where a result differs on older versions, the table below says so. + +Each folder is a self-contained project with the same layout. Run it from inside that folder: + +```console +$ cd broken/ +$ python main.py +``` + +## What's Here + +| Path | Section | Result | +| --- | --- | --- | +| `broken/` | A Python Circular Imports Example | `ImportError` | +| `step1_remove_dependency/` | Step 1: Remove the Runtime Dependency | `2004.99` on 3.14, `NameError` on 3.13 and earlier | +| `step2_guard_annotation/` | Step 2: Guard the Type-Only Import | `2004.99` | +| `workaround1_defer_import/` | 1. Defer Imports to Inside Functions | `1004.99` | +| `workaround2_bottom_import/` | 2. Move Imports to the Bottom | `1004.99` | +| `workaround3_attribute_error/` | 3. Change How You Import the Module | `AttributeError` | +| `workaround3_import_module/` | 3. Change How You Import the Module | `1004.99` | + +Two folders fail on purpose. `broken/` is the circular import the tutorial sets out to fix, and `workaround3_attribute_error/` shows what happens when a module-level line reaches for a class that isn't defined yet. + +## Notes on the Linting + +Two files carry a `# noqa` comment, because the tutorial is deliberately showing code that a linter would object to: + +- `step1_remove_dependency/shop/product.py` annotates with `Order` while no import provides it. That is exactly the gap Step 2 closes, so the annotation stays unresolved here. +- `workaround2_bottom_import/shop/product.py` puts an import at the bottom of the file, which is the technique that section demonstrates. + +## Import Order + +The workarounds behave differently depending on which module is imported first. Every `main.py` here imports `shop.product` first. Change that to `shop.order` and both `workaround2_bottom_import/` and `workaround3_import_module/` raise `ImportError` again, while `workaround1_defer_import/` keeps working. diff --git a/python-circular-imports/broken/main.py b/python-circular-imports/broken/main.py new file mode 100644 index 0000000000..a0df6706d7 --- /dev/null +++ b/python-circular-imports/broken/main.py @@ -0,0 +1,7 @@ +from shop.product import Product + +laptop = Product("Laptop", 1000.0) +order = laptop.order(1) +order.add_shipping() + +print(order.get_total()) diff --git a/python-circular-imports/broken/shop/__init__.py b/python-circular-imports/broken/shop/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/python-circular-imports/broken/shop/order.py b/python-circular-imports/broken/shop/order.py new file mode 100644 index 0000000000..ab30079149 --- /dev/null +++ b/python-circular-imports/broken/shop/order.py @@ -0,0 +1,17 @@ +from shop.product import Product + +SHIPPING_FEE = 4.99 + + +class Order: + def __init__(self, product: Product, quantity: int) -> None: + self.product = product + self.quantity = quantity + self.extras: list[Product] = [] + + def add_shipping(self) -> None: + self.extras.append(Product("Shipping", SHIPPING_FEE)) + + def get_total(self) -> float: + subtotal = self.product.price * self.quantity + return subtotal + sum(extra.price for extra in self.extras) diff --git a/python-circular-imports/broken/shop/product.py b/python-circular-imports/broken/shop/product.py new file mode 100644 index 0000000000..2cad0b1f3f --- /dev/null +++ b/python-circular-imports/broken/shop/product.py @@ -0,0 +1,13 @@ +from shop.order import Order + + +class Product: + def __init__(self, name: str, price: float) -> None: + self.name = name + self.price = price + + def order(self, quantity: int) -> Order: + return Order(self, quantity) + + def is_in_order(self, order: Order) -> bool: + return order.product.name == self.name diff --git a/python-circular-imports/step1_remove_dependency/main.py b/python-circular-imports/step1_remove_dependency/main.py new file mode 100644 index 0000000000..1d9858397c --- /dev/null +++ b/python-circular-imports/step1_remove_dependency/main.py @@ -0,0 +1,8 @@ +from shop.order import Order +from shop.product import Product + +laptop = Product("Laptop", 1000.0) +order = Order(laptop, 2) +order.add_shipping() + +print(order.get_total()) diff --git a/python-circular-imports/step1_remove_dependency/shop/__init__.py b/python-circular-imports/step1_remove_dependency/shop/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/python-circular-imports/step1_remove_dependency/shop/order.py b/python-circular-imports/step1_remove_dependency/shop/order.py new file mode 100644 index 0000000000..ab30079149 --- /dev/null +++ b/python-circular-imports/step1_remove_dependency/shop/order.py @@ -0,0 +1,17 @@ +from shop.product import Product + +SHIPPING_FEE = 4.99 + + +class Order: + def __init__(self, product: Product, quantity: int) -> None: + self.product = product + self.quantity = quantity + self.extras: list[Product] = [] + + def add_shipping(self) -> None: + self.extras.append(Product("Shipping", SHIPPING_FEE)) + + def get_total(self) -> float: + subtotal = self.product.price * self.quantity + return subtotal + sum(extra.price for extra in self.extras) diff --git a/python-circular-imports/step1_remove_dependency/shop/product.py b/python-circular-imports/step1_remove_dependency/shop/product.py new file mode 100644 index 0000000000..325b109887 --- /dev/null +++ b/python-circular-imports/step1_remove_dependency/shop/product.py @@ -0,0 +1,9 @@ +class Product: + def __init__(self, name: str, price: float) -> None: + self.name = name + self.price = price + + # 'Order' is deliberately unresolvable here: the runtime import is + # gone but the annotation still names it. step2 guards the import. + def is_in_order(self, order: Order) -> bool: # noqa: F821 + return order.product.name == self.name diff --git a/python-circular-imports/step2_guard_annotation/main.py b/python-circular-imports/step2_guard_annotation/main.py new file mode 100644 index 0000000000..1d9858397c --- /dev/null +++ b/python-circular-imports/step2_guard_annotation/main.py @@ -0,0 +1,8 @@ +from shop.order import Order +from shop.product import Product + +laptop = Product("Laptop", 1000.0) +order = Order(laptop, 2) +order.add_shipping() + +print(order.get_total()) diff --git a/python-circular-imports/step2_guard_annotation/shop/__init__.py b/python-circular-imports/step2_guard_annotation/shop/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/python-circular-imports/step2_guard_annotation/shop/order.py b/python-circular-imports/step2_guard_annotation/shop/order.py new file mode 100644 index 0000000000..ab30079149 --- /dev/null +++ b/python-circular-imports/step2_guard_annotation/shop/order.py @@ -0,0 +1,17 @@ +from shop.product import Product + +SHIPPING_FEE = 4.99 + + +class Order: + def __init__(self, product: Product, quantity: int) -> None: + self.product = product + self.quantity = quantity + self.extras: list[Product] = [] + + def add_shipping(self) -> None: + self.extras.append(Product("Shipping", SHIPPING_FEE)) + + def get_total(self) -> float: + subtotal = self.product.price * self.quantity + return subtotal + sum(extra.price for extra in self.extras) diff --git a/python-circular-imports/step2_guard_annotation/shop/product.py b/python-circular-imports/step2_guard_annotation/shop/product.py new file mode 100644 index 0000000000..98668744ce --- /dev/null +++ b/python-circular-imports/step2_guard_annotation/shop/product.py @@ -0,0 +1,13 @@ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from shop.order import Order + + +class Product: + def __init__(self, name: str, price: float) -> None: + self.name = name + self.price = price + + def is_in_order(self, order: "Order") -> bool: + return order.product.name == self.name diff --git a/python-circular-imports/workaround1_defer_import/main.py b/python-circular-imports/workaround1_defer_import/main.py new file mode 100644 index 0000000000..a0df6706d7 --- /dev/null +++ b/python-circular-imports/workaround1_defer_import/main.py @@ -0,0 +1,7 @@ +from shop.product import Product + +laptop = Product("Laptop", 1000.0) +order = laptop.order(1) +order.add_shipping() + +print(order.get_total()) diff --git a/python-circular-imports/workaround1_defer_import/shop/__init__.py b/python-circular-imports/workaround1_defer_import/shop/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/python-circular-imports/workaround1_defer_import/shop/order.py b/python-circular-imports/workaround1_defer_import/shop/order.py new file mode 100644 index 0000000000..ab30079149 --- /dev/null +++ b/python-circular-imports/workaround1_defer_import/shop/order.py @@ -0,0 +1,17 @@ +from shop.product import Product + +SHIPPING_FEE = 4.99 + + +class Order: + def __init__(self, product: Product, quantity: int) -> None: + self.product = product + self.quantity = quantity + self.extras: list[Product] = [] + + def add_shipping(self) -> None: + self.extras.append(Product("Shipping", SHIPPING_FEE)) + + def get_total(self) -> float: + subtotal = self.product.price * self.quantity + return subtotal + sum(extra.price for extra in self.extras) diff --git a/python-circular-imports/workaround1_defer_import/shop/product.py b/python-circular-imports/workaround1_defer_import/shop/product.py new file mode 100644 index 0000000000..7499224a5f --- /dev/null +++ b/python-circular-imports/workaround1_defer_import/shop/product.py @@ -0,0 +1,18 @@ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from shop.order import Order + + +class Product: + def __init__(self, name: str, price: float) -> None: + self.name = name + self.price = price + + def order(self, quantity: int) -> "Order": + from shop.order import Order + + return Order(self, quantity) + + def is_in_order(self, order: "Order") -> bool: + return order.product.name == self.name diff --git a/python-circular-imports/workaround2_bottom_import/main.py b/python-circular-imports/workaround2_bottom_import/main.py new file mode 100644 index 0000000000..a0df6706d7 --- /dev/null +++ b/python-circular-imports/workaround2_bottom_import/main.py @@ -0,0 +1,7 @@ +from shop.product import Product + +laptop = Product("Laptop", 1000.0) +order = laptop.order(1) +order.add_shipping() + +print(order.get_total()) diff --git a/python-circular-imports/workaround2_bottom_import/shop/__init__.py b/python-circular-imports/workaround2_bottom_import/shop/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/python-circular-imports/workaround2_bottom_import/shop/order.py b/python-circular-imports/workaround2_bottom_import/shop/order.py new file mode 100644 index 0000000000..ab30079149 --- /dev/null +++ b/python-circular-imports/workaround2_bottom_import/shop/order.py @@ -0,0 +1,17 @@ +from shop.product import Product + +SHIPPING_FEE = 4.99 + + +class Order: + def __init__(self, product: Product, quantity: int) -> None: + self.product = product + self.quantity = quantity + self.extras: list[Product] = [] + + def add_shipping(self) -> None: + self.extras.append(Product("Shipping", SHIPPING_FEE)) + + def get_total(self) -> float: + subtotal = self.product.price * self.quantity + return subtotal + sum(extra.price for extra in self.extras) diff --git a/python-circular-imports/workaround2_bottom_import/shop/product.py b/python-circular-imports/workaround2_bottom_import/shop/product.py new file mode 100644 index 0000000000..039df07d1e --- /dev/null +++ b/python-circular-imports/workaround2_bottom_import/shop/product.py @@ -0,0 +1,14 @@ +class Product: + def __init__(self, name: str, price: float) -> None: + self.name = name + self.price = price + + def order(self, quantity: int) -> "Order": + return Order(self, quantity) + + def is_in_order(self, order: "Order") -> bool: + return order.product.name == self.name + + +# Importing at the bottom is the point of this example. +from shop.order import Order # noqa: E402 diff --git a/python-circular-imports/workaround3_attribute_error/main.py b/python-circular-imports/workaround3_attribute_error/main.py new file mode 100644 index 0000000000..a0df6706d7 --- /dev/null +++ b/python-circular-imports/workaround3_attribute_error/main.py @@ -0,0 +1,7 @@ +from shop.product import Product + +laptop = Product("Laptop", 1000.0) +order = laptop.order(1) +order.add_shipping() + +print(order.get_total()) diff --git a/python-circular-imports/workaround3_attribute_error/shop/__init__.py b/python-circular-imports/workaround3_attribute_error/shop/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/python-circular-imports/workaround3_attribute_error/shop/order.py b/python-circular-imports/workaround3_attribute_error/shop/order.py new file mode 100644 index 0000000000..b706862f11 --- /dev/null +++ b/python-circular-imports/workaround3_attribute_error/shop/order.py @@ -0,0 +1,24 @@ +from typing import TYPE_CHECKING + +from shop import product + +if TYPE_CHECKING: + from shop.product import Product + +SHIPPING_FEE = 4.99 + +DEFAULT_PRODUCT = product.Product("product_a", 0.0) + + +class Order: + def __init__(self, product: "Product", quantity: int) -> None: + self.product = product + self.quantity = quantity + self.extras: list["Product"] = [] + + def add_shipping(self) -> None: + self.extras.append(product.Product("Shipping", SHIPPING_FEE)) + + def get_total(self) -> float: + subtotal = self.product.price * self.quantity + return subtotal + sum(extra.price for extra in self.extras) diff --git a/python-circular-imports/workaround3_attribute_error/shop/product.py b/python-circular-imports/workaround3_attribute_error/shop/product.py new file mode 100644 index 0000000000..2cad0b1f3f --- /dev/null +++ b/python-circular-imports/workaround3_attribute_error/shop/product.py @@ -0,0 +1,13 @@ +from shop.order import Order + + +class Product: + def __init__(self, name: str, price: float) -> None: + self.name = name + self.price = price + + def order(self, quantity: int) -> Order: + return Order(self, quantity) + + def is_in_order(self, order: Order) -> bool: + return order.product.name == self.name diff --git a/python-circular-imports/workaround3_import_module/main.py b/python-circular-imports/workaround3_import_module/main.py new file mode 100644 index 0000000000..a0df6706d7 --- /dev/null +++ b/python-circular-imports/workaround3_import_module/main.py @@ -0,0 +1,7 @@ +from shop.product import Product + +laptop = Product("Laptop", 1000.0) +order = laptop.order(1) +order.add_shipping() + +print(order.get_total()) diff --git a/python-circular-imports/workaround3_import_module/shop/__init__.py b/python-circular-imports/workaround3_import_module/shop/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/python-circular-imports/workaround3_import_module/shop/order.py b/python-circular-imports/workaround3_import_module/shop/order.py new file mode 100644 index 0000000000..30c1fc6cf6 --- /dev/null +++ b/python-circular-imports/workaround3_import_module/shop/order.py @@ -0,0 +1,22 @@ +from typing import TYPE_CHECKING + +from shop import product + +if TYPE_CHECKING: + from shop.product import Product + +SHIPPING_FEE = 4.99 + + +class Order: + def __init__(self, product: "Product", quantity: int) -> None: + self.product = product + self.quantity = quantity + self.extras: list["Product"] = [] + + def add_shipping(self) -> None: + self.extras.append(product.Product("Shipping", SHIPPING_FEE)) + + def get_total(self) -> float: + subtotal = self.product.price * self.quantity + return subtotal + sum(extra.price for extra in self.extras) diff --git a/python-circular-imports/workaround3_import_module/shop/product.py b/python-circular-imports/workaround3_import_module/shop/product.py new file mode 100644 index 0000000000..2cad0b1f3f --- /dev/null +++ b/python-circular-imports/workaround3_import_module/shop/product.py @@ -0,0 +1,13 @@ +from shop.order import Order + + +class Product: + def __init__(self, name: str, price: float) -> None: + self.name = name + self.price = price + + def order(self, quantity: int) -> Order: + return Order(self, quantity) + + def is_in_order(self, order: Order) -> bool: + return order.product.name == self.name