Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions python-circular-imports/README.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions python-circular-imports/broken/main.py
Original file line number Diff line number Diff line change
@@ -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())
Empty file.
17 changes: 17 additions & 0 deletions python-circular-imports/broken/shop/order.py
Original file line number Diff line number Diff line change
@@ -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)
13 changes: 13 additions & 0 deletions python-circular-imports/broken/shop/product.py
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions python-circular-imports/step1_remove_dependency/main.py
Original file line number Diff line number Diff line change
@@ -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())
Empty file.
17 changes: 17 additions & 0 deletions python-circular-imports/step1_remove_dependency/shop/order.py
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions python-circular-imports/step2_guard_annotation/main.py
Original file line number Diff line number Diff line change
@@ -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())
Empty file.
17 changes: 17 additions & 0 deletions python-circular-imports/step2_guard_annotation/shop/order.py
Original file line number Diff line number Diff line change
@@ -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)
13 changes: 13 additions & 0 deletions python-circular-imports/step2_guard_annotation/shop/product.py
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions python-circular-imports/workaround1_defer_import/main.py
Original file line number Diff line number Diff line change
@@ -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())
Empty file.
17 changes: 17 additions & 0 deletions python-circular-imports/workaround1_defer_import/shop/order.py
Original file line number Diff line number Diff line change
@@ -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)
18 changes: 18 additions & 0 deletions python-circular-imports/workaround1_defer_import/shop/product.py
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions python-circular-imports/workaround2_bottom_import/main.py
Original file line number Diff line number Diff line change
@@ -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())
Empty file.
17 changes: 17 additions & 0 deletions python-circular-imports/workaround2_bottom_import/shop/order.py
Original file line number Diff line number Diff line change
@@ -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)
14 changes: 14 additions & 0 deletions python-circular-imports/workaround2_bottom_import/shop/product.py
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions python-circular-imports/workaround3_attribute_error/main.py
Original file line number Diff line number Diff line change
@@ -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())
Empty file.
24 changes: 24 additions & 0 deletions python-circular-imports/workaround3_attribute_error/shop/order.py
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions python-circular-imports/workaround3_import_module/main.py
Original file line number Diff line number Diff line change
@@ -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())
Empty file.
22 changes: 22 additions & 0 deletions python-circular-imports/workaround3_import_module/shop/order.py
Original file line number Diff line number Diff line change
@@ -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)
13 changes: 13 additions & 0 deletions python-circular-imports/workaround3_import_module/shop/product.py
Original file line number Diff line number Diff line change
@@ -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
Loading