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
import làm những gì?
Phần tiêu đề “import làm những gì?”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ừ spec4. Đặ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 moduleHãy ghi nhớ bước 1 và bước 4 - chúng giải thích hầu hết mọi hành vi “lạ” của import.
import sysimport json
print(type(json)) # <class 'module'>print(json.__spec__.origin) # .../lib/python3.13/json/__init__.pyprint(json.__spec__.loader) # SourceFileLoaderprint(sys.modules["json"] is json) # TrueHệ 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”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”DEBUG = Falseimport settingsfrom settings import DEBUG
settings.DEBUG = Trueprint(settings.DEBUG) # Trueprint(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.
sys.path: Python tìm module ở đâu?
Phần tiêu đề “sys.path: Python tìm module ở đâu?”Finder mặc định cho file .py (PathFinder) duyệt lần lượt các thư mục trong sys.path:
import sysfor p in sys.path: print(repr(p))sys.path được tạo lúc khởi động theo thứ tự:
- Thư mục chứa script đang chạy (
python app/main.py→app/), hoặc thư mục hiện tại ('') khi dùngpython -c/python -m/ REPL. - Biến môi trường
PYTHONPATH. - Thư viện chuẩn (
lib/python3.13, file zip,lib-dynload). site-packagescủa môi trường (nơipip installcà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.
Bẫy: đặt tên file trùng thư viện
Phần tiêu đề “Bẫy: đặt tên file trùng thư viện”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 standardlibrary 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 và __init__.py
Phần tiêu đề “Package và __init__.py”Package là thư mục chứa module. Khi import shop.cart, Python:
- import
shoptrước - chạyshop/__init__.py, - tìm
carttrongshop.__path__(danh sách thư mục của package), - import
shop.cartvà gán nó làm thuộc tínhcartcủa moduleshop.
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 Cartthay vìfrom shop.cart import Cart:shop/__init__.py from .cart import Cartfrom .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.
Namespace package (không có __init__.py)
Phần tiêu đề “Namespace package (không có __init__.py)”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.storage và google.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.pyfrom 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.pyImportError: attempted relative import with no known parent packageFile 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.momopython -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.
Circular import
Phần tiêu đề “Circular import”Tái hiện
Phần tiêu đề “Tái hiện”print("bắt đầu a")import bdef hello_a(): return "A"print("kết thúc a")print("bắt đầu b")import aprint("b thấy a.hello_a?", hasattr(a, "hello_a"))def hello_b(): return a.hello_a()print("kết thúc b")import aimport bprint(b.hello_b())bắt đầu abắt đầu bb thấy a.hello_a? False <- a đang nạp dở!kết thúc bkết thúc aATheo 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_aChương trình này vẫn chạy được vì b 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:
from a import hello_a # cần hello_a NGAY lúc importdef 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.
Bốn cách sửa
Phần tiêu đề “Bốn cách sửa”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 <── b2. 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ộ):
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 annotationsfrom 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: ...Thời gian import
Phần tiêu đề “Thời gian import”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 -4import time: self [us] | cumulative | imported packageimport time: 418 | 1497 | json.scannerimport time: 714 | 2210 | json.decoderimport time: 411 | 411 | json.encoderimport time: 1093 | 3714 | jsonCộ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 pandastrong hàmexport_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.utilimport 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 jsonprint(json.dumps({"a": 1})) # bây giờ mới nạpNạp module động với importlib
Phần tiêu đề “Nạp module động với importlib”Import theo tên chuỗi
Phần tiêu đề “Import theo tên chuỗi”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.
Nạp một file bất kỳ theo đường dẫn
Phần tiêu đề “Nạp một file bất kỳ theo đường dẫn”import importlib.utilimport sysfrom 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 modulePlugin qua entry points
Phần tiêu đề “Plugin qua entry points”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)importlib.reload và giới hạn của nó
Phần tiêu đề “importlib.reload và giới hạn của nó”import importlibimport configimportlib.reload(config) # chạy lại code của config.py trong CÙNG object moduleCẩ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.
Bài tập
Phần tiêu đề “Bài tập”- Tạo package
mathkitcómathkit/__init__.pyxuấtadd,multừmathkit/basic.py, vàmathkit/__main__.pyin bảng cửu chương. Chạy bằngpython -m mathkit. - Tạo cố ý một circular import giữa
models.pyvàservices.pyvớifrom ... 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. - Viết hàm
discover_plugins(folder)nạp mọi file.pytrong một thư mục bằngspec_from_file_locationvà trả về dict{tên: module}cho những module có hàmrun().
Kết luận
Phần tiêu đề “Kết luận”import= trasys.modules→ tìm spec → tạo module → đặt vàosys.modules→ chạy code.- Code module chỉ chạy một lần;
from x import ycopy tham chiếu tại thời điểm import. sys.pathbắ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ặcTYPE_CHECKING. - Đo bằng
-X importtime, nạp động bằngimportlib, plugin bằng entry points.
Bài tiếp theo: Type hints nâng cao.