Bỏ qua để đến nội dung

Hệ thống import của Python: sys.modules, sys.path, package và circular import

import là câu lệnh bạn viết đầu tiên trong mọi file, nhưng cũng là nguồn gốc của những lỗi khó hiểu nhất: ModuleNotFoundError dù file nằm ngay đó, ImportError: cannot import name chỉ xảy ra khi chạy theo một thứ tự nhất định, hay code “chạy được trong PyCharm nhưng không chạy được trên terminal”. Tất cả đều trở nên dễ hiểu khi bạn biết import hoạt động ra sao.

Trong bài này, bạn sẽ học:

  • Các bước Python thực hiện khi gặp import x
  • sys.modules - bộ nhớ đệm quyết định mọi thứ
  • sys.path được tạo ra thế nào và vì sao “cùng code mà chạy chỗ này được, chỗ kia không”
  • Package, __init__.py, namespace package, import tương đối và python -m
  • Circular import: vì sao xảy ra, đọc thông báo lỗi, và 4 cách sửa
  • Đo và giảm thời gian import, import lười (lazy)
  • Nạp module/plugin động với importlib

Khi Python gặp import spam, nó làm các bước sau:

1. Tra sys.modules["spam"]
├─ có -> dùng luôn object module đó (KHÔNG chạy lại code)
└─ chưa có:
2. Tìm module: hỏi lần lượt các "finder" trong sys.meta_path
-> finder trả về một ModuleSpec (tên, loader, đường dẫn file...)
3. Tạo object module RỖNG từ spec
4. Đặt module vào sys.modules["spam"] <- TRƯỚC khi chạy code của nó!
5. Loader thực thi code của spam.py bên trong namespace của module
(nếu lỗi: xoá spam khỏi sys.modules rồi ném exception)
6. Gán tên "spam" trong namespace hiện tại trỏ tới module

Hãy ghi nhớ bước 1bước 4 - chúng giải thích hầu hết mọi hành vi “lạ” của import.

import sys
import json
print(type(json)) # <class 'module'>
print(json.__spec__.origin) # .../lib/python3.13/json/__init__.py
print(json.__spec__.loader) # SourceFileLoader
print(sys.modules["json"] is json) # True

Hệ quả 1: code của module chỉ chạy một lần

Phần tiêu đề “Hệ quả 1: code của module chỉ chạy một lần”
config.py
print("đang nạp config...")
SETTINGS = {"debug": True}
import config # đang nạp config...
import config # (không in gì - lấy từ sys.modules)

Đây là lý do một module là singleton tự nhiên: mọi nơi import config đều nhận cùng một object, sửa config.SETTINGS ở một nơi thì mọi nơi khác thấy.

Hệ quả 2: from x import y copy tham chiếu tại thời điểm import

Phần tiêu đề “Hệ quả 2: from x import y copy tham chiếu tại thời điểm import”
settings.py
DEBUG = False
import settings
from settings import DEBUG
settings.DEBUG = True
print(settings.DEBUG) # True
print(DEBUG) # False - DEBUG là một tên riêng, trỏ tới giá trị CŨ

from settings import DEBUG tương đương DEBUG = settings.DEBUG - một phép gán tại thời điểm import. Nếu cần thấy giá trị thay đổi lúc chạy, dùng import settings rồi truy cập settings.DEBUG. Điều này cũng quan trọng khi dùng unittest.mock.patch: bạn phải patch nơi tên được dùng, không phải nơi nó được định nghĩa.

Finder mặc định cho file .py (PathFinder) duyệt lần lượt các thư mục trong sys.path:

import sys
for p in sys.path:
print(repr(p))

sys.path được tạo lúc khởi động theo thứ tự:

  1. Thư mục chứa script đang chạy (python app/main.pyapp/), hoặc thư mục hiện tại ('') khi dùng python -c / python -m / REPL.
  2. Biến môi trường PYTHONPATH.
  3. Thư viện chuẩn (lib/python3.13, file zip, lib-dynload).
  4. site-packages của môi trường (nơi pip install cài thư viện), cùng các đường dẫn từ file .pth.

Điểm 1 là nguồn gốc của vấn đề “chạy được trong IDE nhưng không chạy trên terminal”: IDE thường tự thêm thư mục gốc dự án vào sys.path, còn terminal thì chỉ thêm thư mục chứa script.

Vì thư mục của script đứng đầu sys.path, một file tên random.py trong dự án sẽ che module chuẩn random:

du-an/
├── random.py # file của bạn: print("random giả")
└── game.py # import random; random.randint(1, 6)
AttributeError: module 'random' has no attribute 'randint'
(consider renaming '.../random.py' since it has the same name as the standard
library module named 'random' and prevents importing that standard library module)

Python 3.13 đã đưa ra gợi ý rõ ràng như trên; các bản cũ chỉ báo AttributeError. Tránh đặt tên file là random.py, json.py, test.py, email.py, requests.py

Package là thư mục chứa module. Khi import shop.cart, Python:

  1. import shop trước - chạy shop/__init__.py,
  2. tìm cart trong shop.__path__ (danh sách thư mục của package),
  3. import shop.cart và gán nó làm thuộc tính cart của module shop.
shop/
├── __init__.py
├── cart.py
└── payment/
├── __init__.py
└── momo.py

__init__.py thường dùng để:

  • Định nghĩa API công khai của package, giúp người dùng viết from shop import Cart thay vì from shop.cart import Cart:

    shop/__init__.py
    from .cart import Cart
    from .payment import pay
    __all__ = ["Cart", "pay"] # những tên được xuất khi "from shop import *"
  • Khai báo __version__, cấu hình logging cho package.

Đừng đặt code nặng (kết nối database, đọc file lớn) trong __init__.py: nó chạy mỗi khi bất kỳ module con nào được import.

Từ Python 3.3, thư mục không có __init__.py vẫn import được, gọi là namespace package. Nhiều thư mục cùng tên nằm ở các vị trí khác nhau trong sys.path được ghép thành một package - cơ chế cho phép nhiều bản phân phối riêng cùng cung cấp module con, ví dụ google.cloud.storagegoogle.cloud.bigquery là hai gói pip khác nhau.

Với dự án của bạn, hãy luôn tạo __init__.py cho package thường. Quên nó vẫn chạy được, nhưng tìm kiếm chậm hơn và một số công cụ (pytest, mypy) có thể xử lý khác.

Import tuyệt đối, tương đối và python -m

Phần tiêu đề “Import tuyệt đối, tương đối và python -m”
# trong shop/payment/momo.py
from shop.cart import Cart # tuyệt đối: rõ ràng, luôn được khuyến nghị
from ..cart import Cart # tương đối: ".." = package cha (shop)
from . import utils # "." = package hiện tại (shop.payment)

Import tương đối dựa vào __package__ của module. Khi bạn chạy trực tiếp một file trong package:

$ python shop/payment/momo.py
ImportError: attempted relative import with no known parent package

File chạy trực tiếp có __name__ == "__main__" và không thuộc package nào, nên .. không có ý nghĩa. Cách chạy đúng là dùng -m từ thư mục gốc dự án:

$ python -m shop.payment.momo

python -m tìm module theo sys.path (với thư mục hiện tại ở đầu), import các package cha đúng cách, rồi chạy module như __main__. Đây là cách chạy khuyến nghị cho mọi module nằm trong package. Package có file __main__.py còn chạy được bằng python -m shop.

a.py
print("bắt đầu a")
import b
def hello_a():
return "A"
print("kết thúc a")
b.py
print("bắt đầu b")
import a
print("b thấy a.hello_a?", hasattr(a, "hello_a"))
def hello_b():
return a.hello_a()
print("kết thúc b")
main.py
import a
import b
print(b.hello_b())
bắt đầu a
bắt đầu b
b thấy a.hello_a? False <- a đang nạp dở!
kết thúc b
kết thúc a
A

Theo dõi từng bước:

main: import a
a chưa có trong sys.modules -> tạo module a, đặt vào sys.modules, chạy a.py
a.py: import b
b chưa có -> tạo module b, đặt vào sys.modules, chạy b.py
b.py: import a
a ĐÃ có trong sys.modules (bước 4!) -> trả về module a đang nạp dở
a lúc này chưa có hello_a (dòng def chưa chạy tới)
b.py chạy xong
a.py tiếp tục, định nghĩa hello_a

Chương trình này vẫn chạy đượcb chỉ dùng a.hello_a bên trong hàm - lúc hàm được gọi thì a đã nạp xong. Bây giờ đổi b.py thành:

b.py
from a import hello_a # cần hello_a NGAY lúc import
def hello_b():
return hello_a()

Python 3.12 báo:

ImportError: cannot import name 'hello_a' from partially initialized module 'a'
(most likely due to a circular import)

Còn Python 3.13 và 3.14 lại báo một thông điệp dễ gây hiểu nhầm:

ImportError: cannot import name 'hello_a' from 'a'
(consider renaming '.../a.py' if it has the same name as a library you intended to import)

Gợi ý “đổi tên file” ở đây là sai hướng - nguyên nhân thật vẫn là vòng import. from a import hello_a cần thuộc tính hello_a ngay lập tức, nhưng a mới nạp được một nửa (dòng def hello_a chưa chạy tới). Khi gặp cannot import name với một tên mà bạn chắc chắn có tồn tại, hãy nghĩ ngay tới circular import.

1. Tái cấu trúc (tốt nhất). Vòng import thường là dấu hiệu thiết kế: hai module phụ thuộc lẫn nhau. Tách phần dùng chung ra module thứ ba:

Trước: a <──> b Sau: a ──> common <── b

2. Dùng import module thay vì from module import name, và chỉ truy cập thuộc tính bên trong hàm (như ví dụ đầu tiên). Việc tra a.hello_a bị hoãn tới lúc gọi hàm.

3. Import bên trong hàm (import cục bộ):

b.py
def hello_b():
from a import hello_a # chỉ chạy khi hàm được gọi
return hello_a()

Sau lần đầu, import cục bộ chỉ là một lần tra sys.modules - rất rẻ.

4. Chỉ cần cho type hint? Dùng TYPE_CHECKING:

from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING: # False lúc chạy, True khi mypy/pyright kiểm tra
from a import Order
def process(order: Order) -> None:
...

Mỗi lần khởi động, chương trình phải import mọi module cần thiết. Với CLI hoặc serverless function, thời gian này rất đáng kể. Đo bằng -X importtime:

$ python -X importtime -c "import json" 2>&1 | tail -4
import time: self [us] | cumulative | imported package
import time: 418 | 1497 | json.scanner
import time: 714 | 2210 | json.decoder
import time: 411 | 411 | json.encoder
import time: 1093 | 3714 | json

Cột cumulative cho biết tổng thời gian (micro-giây) import module đó cùng các module con. Công cụ tuna có thể vẽ kết quả này thành biểu đồ.

Cách giảm thời gian import:

  • Import cục bộ cho thư viện nặng chỉ dùng trong một vài lệnh (ví dụ import pandas trong hàm export_excel() của một CLI).
  • Tránh code nặng ở cấp module (đọc file, gọi mạng, biên dịch hàng loạt regex không cần thiết).
  • Dùng importlib.util.LazyLoader để trì hoãn việc thực thi module cho tới lần truy cập thuộc tính đầu tiên:
import importlib.util
import sys
def lazy_import(name):
spec = importlib.util.find_spec(name)
loader = importlib.util.LazyLoader(spec.loader)
spec.loader = loader
module = importlib.util.module_from_spec(spec)
sys.modules[name] = module
loader.exec_module(module)
return module
json = lazy_import("json") # chưa thực sự chạy code của json
print(json.dumps({"a": 1})) # bây giờ mới nạp
import importlib
def load_backend(name):
# name lấy từ config, ví dụ "json" hoặc "pickle"
return importlib.import_module(name)
backend = load_backend("json")
print(backend.dumps([1, 2]))

Đây là nền tảng của hệ thống plugin: Django đọc chuỗi "myapp.middleware.Auth" trong settings và import lúc chạy.

import importlib.util
import sys
from pathlib import Path
def load_file(path):
path = Path(path)
spec = importlib.util.spec_from_file_location(path.stem, path)
module = importlib.util.module_from_spec(spec)
sys.modules[path.stem] = module
spec.loader.exec_module(module)
return module

Cách chuẩn để một gói pip “đăng ký” plugin cho ứng dụng khác (pytest, Flask CLI dùng cơ chế này). Gói plugin khai báo trong pyproject.toml:

[project.entry-points."myapp.exporters"]
pdf = "myapp_pdf:PdfExporter"

Ứng dụng chính tìm mọi plugin đã cài:

from importlib.metadata import entry_points
for ep in entry_points(group="myapp.exporters"):
exporter_cls = ep.load() # import module và lấy thuộc tính
print(ep.name, exporter_cls)
import importlib
import config
importlib.reload(config) # chạy lại code của config.py trong CÙNG object module

Cẩn thận: các tên đã lấy bằng from config import X ở module khác vẫn trỏ tới object cũ; instance của class cũ vẫn thuộc class cũ (isinstance với class mới trả về False). reload hữu ích trong REPL/Jupyter, không nên dùng trong code production.

  1. Tạo package mathkitmathkit/__init__.py xuất add, mul từ mathkit/basic.py, và mathkit/__main__.py in bảng cửu chương. Chạy bằng python -m mathkit.
  2. Tạo cố ý một circular import giữa models.pyservices.py với from ... import ..., đọc thông báo lỗi, rồi sửa bằng cả ba cách: tách module, import cục bộ, TYPE_CHECKING.
  3. Viết hàm discover_plugins(folder) nạp mọi file .py trong một thư mục bằng spec_from_file_location và trả về dict {tên: module} cho những module có hàm run().
  • import = tra sys.modules → tìm spec → tạo module → đặt vào sys.modules → chạy code.
  • Code module chỉ chạy một lần; from x import y copy tham chiếu tại thời điểm import.
  • sys.path bắt đầu bằng thư mục của script - cẩn thận đặt tên file trùng thư viện.
  • Chạy module trong package bằng python -m, ưu tiên import tuyệt đối.
  • Circular import lỗi khi cần tên ngay lúc import từ module đang nạp dở; sửa bằng tái cấu trúc, import module, import cục bộ hoặc TYPE_CHECKING.
  • Đo bằng -X importtime, nạp động bằng importlib, plugin bằng entry points.

Bài tiếp theo: Type hints nâng cao.