import datetime
datetime<module 'datetime' from '/home/knaert/.local/share/uv/python/cpython-3.13.5-linux-x86_64-gnu/lib/python3.13/datetime.py'>
Why Python has no import tariffs
Karsten Naert
November 15, 2025
Python’s import system is surprisingly complex. Part of the confusion stems from three distinct concepts that often get mixed up:
The term “package” adds to the confusion because it’s used both colloquially (as a synonym for “distribution”) and technically (as a specific type of module). We’ll clarify this as we go.
Today we focus on (1) and (2): understanding how Python transforms files on disk into module objects at runtime.
Let’s start with the practical side - how to use imports in everyday code.
An import statement creates a module object:
<module 'datetime' from '/home/knaert/.local/share/uv/python/cpython-3.13.5-linux-x86_64-gnu/lib/python3.13/datetime.py'>
Think of an import as a two-step operation:
Python uses __import__() internally to perform step 1. You can see this separation yourself:
Don’t use __import__() in your own code - it’s a low-level function meant for the import machinery itself. We’re only using it here to illustrate the concept.
Python offers several import syntaxes:
Aliases with as:
(<module 'sys' (built-in)>,
<module 'datetime' from '/home/knaert/.local/share/uv/python/cpython-3.13.5-linux-x86_64-gnu/lib/python3.13/datetime.py'>)
Selective imports with from:
Dotted imports create nested module structures:
<module 'json.tool' from '/home/knaert/.local/share/uv/python/cpython-3.13.5-linux-x86_64-gnu/lib/python3.13/json/tool.py'>
The module object json now has an attribute tool which is itself a module:
<module 'json' from '/home/knaert/.local/share/uv/python/cpython-3.13.5-linux-x86_64-gnu/lib/python3.13/json/__init__.py'>
You can combine from with dotted paths:
import x.y always imports x first, then y. You can’t skip importing x to save time.
Here’s a crucial behavior you’ll encounter immediately: modules are only executed once.
Import it again:
The print statement only ran once! That’s because Python caches modules in sys.modules:
This means:
We’ll explore sys.modules in detail shortly, but remember: import once, use everywhere.
As your codebase grows, you’ll accumulate many imports. Here’s the conventional ordering:
from __future__ import annotationsWithin each category, sort alphabetically.
Example:
from __future__ import annotations
from dataclasses import dataclass
from functools import cached_property, partial
from typing import Callable
import flet as ft
from data_connector import DataConnector
from containerize import ft_col, ft_row, ft_tabTools like ruff can automatically organize your imports.
Now let’s understand what modules actually are at runtime.
Modules are objects, just like functions, classes, or lists. Most objects in Python are created with syntax like x = ..., but some have special syntax:
for x in L: defines xdef f(): defines fclass K: defines Kimport os defines osThe import statement creates a module object:
If you need the module type itself:
Like any object, modules have attributes - these are the functions, classes, and variables the module provides:
You can also access attributes with getattr():
Modules play an important role when considering how to structure code in Python. The effectively form a level of structure above the classes. They effectively play the role of a singleton class with static methods. This can be useful for instance to store a configuration that is unique throughout the program. For programmers used to other languages it’s important to suppress the need to wrap things in a class when a module would be more appropriate.
__name__ attribute and __main__Every module has a __name__ attribute containing its name:
But when you run a Python file directly (e.g., python script.py), that file gets a special name:
This leads to one of Python’s most common idioms:
This pattern lets you write code that:
if block is skipped)if block executes)When imported:
Always use if __name__ == '__main__': for code you want to run only when the file is executed directly, not when imported.
Not all modules are created equal. Let’s examine a few:
<module 'json' from '/home/knaert/.local/share/uv/python/cpython-3.13.5-linux-x86_64-gnu/lib/python3.13/json/__init__.py'>
<module 'datetime' from '/home/knaert/.local/share/uv/python/cpython-3.13.5-linux-x86_64-gnu/lib/python3.13/datetime.py'>
Notice the differences in their representations! Python has several types of modules:
Built-in modules like sys are compiled into Python itself:
Frozen modules like os are also compiled in, but differently:
File-based modules like datetime come from .py files:
'/home/knaert/.local/share/uv/python/cpython-3.13.5-linux-x86_64-gnu/lib/python3.13/datetime.py'
Packages are modules with a __path__ attribute:
['/home/knaert/.local/share/uv/python/cpython-3.13.5-linux-x86_64-gnu/lib/python3.13/json']
According to the official documentation:
All packages are modules, but not all modules are packages. Packages are a special kind of module - specifically, any module that contains a
__path__attribute.
The hierarchy:
sys)os)datetime)__path__)
__init__.py)sys.modulesAll imported modules live in sys.modules:
This is why repeated imports are fast - Python just returns the cached module:
<module 'datetime' from '/home/knaert/.local/share/uv/python/cpython-3.13.5-linux-x86_64-gnu/lib/python3.13/datetime.py'>
The cache ensures consistency: every part of your program sees the same module object, with the same state.
Creating modules is straightforward, though different from creating functions or classes because the code goes in a separate file.
Let’s create a simple module. First, we need to create the file:
Quarto-specific note: Because I want this documentation to render when I build the Quarto document, I use Python itself to write these files. As you are following along, it would make more sense to create these files yourself manually.
Now import it:
The code executed immediately! This includes the print statements. This is a security consideration: importing untrusted code is inherently risky because arbitrary code runs.
All definitions in the module become attributes:
Even the imports inside the module are accessible:
The module knows its name and file location:
Remember that modules are cached. If we modify the file, the changes won’t appear automatically:
The triple function isn’t there! The module was already cached. To see changes during development, use importlib.reload():
Hello from my_module!
My name is 'my_module'
<module 'my_module' from '/var/www/pydev2/advanced-python/my_module.py'>
Module caching exists for two reasons:
To create a package, make a folder with an __init__.py file:
Import it like any module:
The result is a package (note the __path__ attribute):
The __init__.py file is executed when the package is imported, just like a regular .py file. Everything defined in __init__.py becomes an attribute of the package.
Packages become interesting when they contain multiple modules.
Let’s add a submodule to our package:
Now import it:
Important insights:
my_package:my_package is being imported!
True
import my_package.math_utils, Python always imports my_package first:import my_package.math_utils
# Both are now in sys.modules:
'my_package' in sys.modules, 'my_package.math_utils' in sys.modulesmy_package is being imported!
math_utils submodule loaded!
(True, True)
You can control what users see by importing into __init__.py:
import os
os.makedirs('organized_package', exist_ok=True)
# Create submodules
with open('organized_package/core.py', 'w') as f:
f.write('''
def process(data):
return f"Processing: {data}"
''')
with open('organized_package/utils.py', 'w') as f:
f.write('''
def helper(x):
return x * 2
''')
# Import them in __init__.py
with open('organized_package/__init__.py', 'w') as f:
f.write('''
from .core import process
from .utils import helper
__all__ = ['process', 'helper']
''')Now users can access functions directly:
This is how popular packages like numpy work - you import numpy and get access to many functions, even though they’re defined in various submodules.
In the example above, we used relative imports: from .core import process. The . means “current package”.
Relative import syntax:
from . import foo - Import foo from the current packagefrom .module import function - Import function from module in the current packagefrom ..parent import something - Go up one levelfrom ...grandparent import other - Go up two levelsAdvantages:
Limitations:
from syntax (not import .foo)Example with nested packages:
Relative imports are perfectly fine in modern Python. The old advice against them was about “implicit relative imports” (a Python 2 feature that’s long gone). For stylistic reasons it’s preferable to have a consistent style throughout a project, so either choose relative imports or absolute imports, but not both.
__all__The from module import * syntax imports “everything” from a module:
Only items in __all__ are imported:
--------------------------------------------------------------------------- NameError Traceback (most recent call last) Cell In[66], line 1 ----> 1 _private_func() NameError: name '_private_func' is not defined
Use import * sparingly - it makes code harder to understand because you can’t easily see where names come from. You sometimes see it in older documentation for specific libraries, such as from tkinter import *, but I consider it to be old-fashioned.
The __all__ attribute also serves as documentation, explicitly declaring your module’s public API.
Circular imports occur when module A imports module B, and module B imports module A.
Here’s why they’re problematic:
import AA in sys.modulesA’s codeA tries to import BB in sys.modulesB’s codeB tries to import AA in sys.modules and returns it… but it’s only partially initialized!The result depends on execution order and is extremely fragile:
with open('circular_a.py', 'w') as f:
f.write('''
print("A: Starting import")
from circular_b import b_func
def a_func():
return "From A"
print("A: Finished import")
''')
with open('circular_b.py', 'w') as f:
f.write('''
print("B: Starting import")
from circular_a import a_func
def b_func():
return "From B"
print("B: Finished import")
''')A: Starting import
B: Starting import
--------------------------------------------------------------------------- ImportError Traceback (most recent call last) Cell In[69], line 1 ----> 1 import circular_a File /var/www/pydev2/advanced-python/circular_a.py:3 2 print("A: Starting import") ----> 3 from circular_b import b_func 5 def a_func(): 6 return "From A" File /var/www/pydev2/advanced-python/circular_b.py:3 2 print("B: Starting import") ----> 3 from circular_a import a_func 5 def b_func(): 6 return "From B" ImportError: cannot import name 'a_func' from 'circular_a' (consider renaming '/var/www/pydev2/advanced-python/circular_a.py' if it has the same name as a library you intended to import)
Module B tries to import a_func, but A hasn’t defined it yet!
Solution: Don’t create circular imports. Restructure your code:
Now let’s understand how imports actually work.
When you run import some_module, Python follows these steps:
some_module in sys.modules? If yes, return it immediately.sys.meta_path to locate the module and get a “module spec”.Let’s explore each step.
The sys.meta_path list contains objects that know how to find modules:
[<_distutils_hack.DistutilsMetaFinder at 0x7aedf4536ba0>,
<_virtualenv._Finder at 0x7aedf4536a50>,
_frozen_importlib.BuiltinImporter,
_frozen_importlib.FrozenImporter,
_frozen_importlib_external.PathFinder,
<six._SixMetaPathImporter at 0x7aedf33dcd70>]
Each finder has a find_spec() method that tries to locate a module:
builtin_finder = sys.meta_path[2]
frozen_finder = sys.meta_path[3]
pathfinder = sys.meta_path[4]
for name in ['sys', 'os', 'datetime', 'json']:
print(f"{name:10} -> builtin: {builtin_finder.find_spec(name) is not None}, "
f"frozen: {frozen_finder.find_spec(name) is not None}, "
f"path: {pathfinder.find_spec(name) is not None}")sys -> builtin: True, frozen: False, path: False
os -> builtin: False, frozen: True, path: True
datetime -> builtin: False, frozen: False, path: True
json -> builtin: False, frozen: False, path: True
The first finder to return a spec wins.
The path-based finder is the most important for your code. It searches locations in sys.path:
['/home/knaert/.local/share/uv/python/cpython-3.13.5-linux-x86_64-gnu/lib/python313.zip',
'/home/knaert/.local/share/uv/python/cpython-3.13.5-linux-x86_64-gnu/lib/python3.13',
'/home/knaert/.local/share/uv/python/cpython-3.13.5-linux-x86_64-gnu/lib/python3.13/lib-dynload',
'',
'/var/www/pydev2/.venv/lib/python3.13/site-packages']
sys.path typically contains:
PYTHONPATH environment variable contentsThe path-based finder uses sys.path_hooks to create specialized finders for each location:
[zipimport.zipimporter,
<function _frozen_importlib_external.FileFinder.path_hook.<locals>.path_hook_for_FileFinder(path)>]
For file-based searching, there’s FileFinder:
<function _frozen_importlib_external.FileFinder.path_hook.<locals>.path_hook_for_FileFinder(path)>
This creates a finder for a specific directory:
FileFinder('/var/www/pydev2/advanced-python')
This finder can locate modules:
ModuleSpec(name='my_module', loader=<_frozen_importlib_external.SourceFileLoader object at 0x7aedc3bba750>, origin='/var/www/pydev2/advanced-python/my_module.py')
The spec contains all the information needed to load the module:
We can manually load a module using the import machinery:
Step by step:
Spec: ModuleSpec(name='manual_demo', loader=<_frozen_importlib_external.SourceFileLoader object at 0x7aedc3bba8d0>, origin='/var/www/pydev2/advanced-python/manual_demo.py')
# Create an empty module
import types
module = types.ModuleType(spec.name)
print("Empty module:", module)Empty module: <module 'manual_demo'>
This is essentially what Python does when you write import manual_demo.
__path__For submodules like import package.submodule, Python doesn’t search sys.path. Instead, it searches package.__path__:
['/home/knaert/.local/share/uv/python/cpython-3.13.5-linux-x86_64-gnu/lib/python3.13/json']
ModuleSpec(name='tool', loader=<_frozen_importlib_external.SourceFileLoader object at 0x7aedc3bbab10>, origin='/home/knaert/.local/share/uv/python/cpython-3.13.5-linux-x86_64-gnu/lib/python3.13/json/tool.py')
This is how packages organize their submodules - the parent package’s __path__ determines where to look for children.
You can influence how Python finds modules:
1. Modify sys.path (most common):
2. Set PYTHONPATH environment variable:
3. Use site-packages: Installing packages with pip puts them in site-packages, which is automatically in sys.path.
4. Custom finders (advanced): Add your own finder to sys.meta_path to implement custom import logic (e.g., loading modules from a database, over the network, etc.).
The import system has more advanced features we won’t cover today:
importlib library - Utilities for working with the import system programmatically---
title: "Modules and Import System"
subtitle: "Why Python has no import tariffs"
author: "Karsten Naert"
date: today
toc: true
execute:
echo: true
output: true
---
# Introduction: The Import Landscape
Python's import system is surprisingly complex. Part of the confusion stems from three distinct concepts that often get mixed up:
1. **File structure** - How Python code is organized on your hard drive
2. **Runtime objects** - The module objects that exist during a running Python session
3. **Distributions** - Bundled Python code shared with others (e.g., via PyPI)
The term "package" adds to the confusion because it's used both colloquially (as a synonym for "distribution") and technically (as a specific type of module). We'll clarify this as we go.
Today we focus on (1) and (2): understanding how Python transforms files on disk into module objects at runtime.
# Imports: The Quick Version
Let's start with the practical side - how to use imports in everyday code.
## Basic syntax and behavior
An import statement creates a module object:
```{python}
import datetime
datetime
```
```{python}
type(datetime)
```
Think of an import as a two-step operation:
1. **Execute** the module's code and create a module object
2. **Assign** that object to a variable
Python uses `__import__()` internally to perform step 1. You can see this separation yourself:
```{python}
sys = __import__('sys')
sys
```
::: {.callout-warning}
Don't use `__import__()` in your own code - it's a low-level function meant for the import machinery itself. We're only using it here to illustrate the concept.
:::
## Import variations
Python offers several import syntaxes:
**Aliases** with `as`:
```{python}
import sys as system
import datetime as dt
system, dt
```
**Selective imports** with `from`:
```{python}
from datetime import datetime as d1, date as d2
d1, d2
```
**Dotted imports** create nested module structures:
```{python}
import json.tool
json.tool
```
The module object `json` now has an attribute `tool` which is itself a module:
```{python}
json
```
You can combine `from` with dotted paths:
```{python}
from json.tool import main as json_main
json_main
```
::: {.callout-note}
`import x.y` always imports `x` first, then `y`. You can't skip importing `x` to save time.
:::
## Critical: Modules are cached
Here's a crucial behavior you'll encounter immediately: **modules are only executed once**.
```{python}
#| echo: false
import sys
with open('demo_module.py', 'w') as f:
f.write('''
print("Module is being imported!")
def greet(name):
return f"Hello, {name}!"
''')
```
```{python}
import demo_module
```
Import it again:
```{python}
import demo_module
```
The print statement only ran once! That's because Python caches modules in `sys.modules`:
```{python}
import sys
'demo_module' in sys.modules
```
This means:
- Imports are fast after the first time
- Side effects (like print statements) only happen once
- All parts of your program see the same module object
We'll explore `sys.modules` in detail shortly, but remember: **import once, use everywhere**.
## Import style guide
As your codebase grows, you'll accumulate many imports. Here's the conventional ordering:
1. Special imports like `from __future__ import annotations`
2. **Standard library** imports (Python's built-in modules)
3. **Third-party** imports (installed packages)
4. **Local** imports (your own code)
Within each category, sort alphabetically.
Example:
```python
from __future__ import annotations
from dataclasses import dataclass
from functools import cached_property, partial
from typing import Callable
import flet as ft
from data_connector import DataConnector
from containerize import ft_col, ft_row, ft_tab
```
Tools like `ruff` can automatically organize your imports.
# Module Objects Deep Dive
Now let's understand what modules actually are at runtime.
## Modules as runtime objects
Modules are objects, just like functions, classes, or lists. Most objects in Python are created with syntax like `x = ...`, but some have special syntax:
- `for x in L:` defines `x`
- `def f():` defines `f`
- `class K:` defines `K`
- `import os` defines `os`
The `import` statement creates a module object:
```{python}
import os
print(type(os))
```
If you need the module type itself:
```{python}
import types
types.ModuleType
```
Like any object, modules have attributes - these are the functions, classes, and variables the module provides:
```{python}
os.getcwd
```
```{python}
os.getcwd()
```
You can also access attributes with `getattr()`:
```{python}
getattr(os, 'getcwd')
```
::: {.callout-tip}
Modules play an important role when considering how to structure code in Python. The effectively form a level of structure above the classes. They effectively play the role of a singleton class with static methods. This can be useful for instance to store a configuration that is unique throughout the program. For programmers used to other languages it's important to suppress the need to wrap things in a class when a module would be more appropriate.
:::
## The `__name__` attribute and `__main__`
Every module has a `__name__` attribute containing its name:
```{python}
import pandas as pd
os.__name__, pd.__name__
```
But when you run a Python file directly (e.g., `python script.py`), that file gets a special name:
```{python}
__name__
```
This leads to one of Python's most common idioms:
```python
def main():
print("Running the script!")
if __name__ == '__main__':
main()
```
This pattern lets you write code that:
- Can be imported as a library (the `if` block is skipped)
- Can be run as a script (the `if` block executes)
```{python}
#| echo: false
with open('runnable_module.py', 'w') as f:
f.write('''
def add(a, b):
return a + b
def main():
print("Running as a script!")
print(f"2 + 3 = {add(2, 3)}")
if __name__ == '__main__':
main()
''')
```
When imported:
```{python}
import runnable_module
runnable_module.add(5, 7)
```
::: {.callout-tip}
Always use `if __name__ == '__main__':` for code you want to run only when the file is executed directly, not when imported.
:::
## Types of modules
Not all modules are created equal. Let's examine a few:
```{python}
import os
os
```
```{python}
import sys
sys
```
```{python}
import json
json
```
```{python}
import datetime
datetime
```
Notice the differences in their representations! Python has several types of modules:
**Built-in modules** like `sys` are compiled into Python itself:
```{python}
hasattr(sys, '__file__')
```
**Frozen modules** like `os` are also compiled in, but differently:
```{python}
hasattr(os, '__file__')
```
**File-based modules** like `datetime` come from `.py` files:
```{python}
datetime.__file__
```
**Packages** are modules with a `__path__` attribute:
```{python}
json.__path__
```
According to the [official documentation](https://docs.python.org/3/reference/import.html#module-path):
> All packages are modules, but not all modules are packages. Packages are a special kind of module - specifically, any module that contains a `__path__` attribute.
The hierarchy:
- **Built-in modules** (e.g., `sys`)
- **Frozen modules** (e.g., `os`)
- **Regular modules**
- File-based modules (e.g., `datetime`)
- **Packages** (modules with `__path__`)
- Regular packages (with `__init__.py`)
- Namespace packages (advanced topic)
## The module cache: `sys.modules`
All imported modules live in `sys.modules`:
```{python}
import sys
len(sys.modules)
```
This is why repeated imports are fast - Python just returns the cached module:
```{python}
sys.modules['datetime']
```
The cache ensures consistency: every part of your program sees the same module object, with the same state.
```{python}
#| echo: false
import sys
# Clean up demo modules
if 'demo_module' in sys.modules:
del sys.modules['demo_module']
if 'runnable_module' in sys.modules:
del sys.modules['runnable_module']
```
# Creating Your Own Modules
Creating modules is straightforward, though different from creating functions or classes because the code goes in a separate file.
## From files: The basics
Let's create a simple module. First, we need to create the file:
```{python}
code = '''
print("Hello from my_module!")
print(f"My name is '{__name__}'")
MAGIC_NUMBER = 42
def double(x):
return 2 * x
class Calculator:
def add(self, a, b):
return a + b
import os
'''
with open('my_module.py', 'w') as f:
f.write(code)
```
::: {.callout-warning}
**Quarto-specific note**: Because I want this documentation to render when I build the Quarto document, I use Python itself to write these files. As you are following along, it would make more sense to create these files yourself manually.
:::
Now import it:
```{python}
import my_module
```
The code executed immediately! This includes the print statements. This is a security consideration: **importing untrusted code is inherently risky** because arbitrary code runs.
All definitions in the module become attributes:
```{python}
my_module.MAGIC_NUMBER
```
```{python}
my_module.double(5)
```
```{python}
my_module.Calculator().add(10, 20)
```
Even the imports inside the module are accessible:
```{python}
my_module.os
```
The module knows its name and file location:
```{python}
my_module.__name__, my_module.__file__
```
## Module caching in practice
Remember that modules are cached. If we modify the file, the changes won't appear automatically:
```{python}
with open('my_module.py', 'a') as f:
f.write('\n\ndef triple(x):\n return 3 * x\n')
```
```{python}
import my_module
hasattr(my_module, 'triple')
```
The `triple` function isn't there! The module was already cached. To see changes during development, use `importlib.reload()`:
```{python}
import importlib
importlib.reload(my_module)
```
```{python}
my_module.triple(4)
```
Module caching exists for two reasons:
1. **Performance** - Loading modules can be expensive
2. **Consistency** - All code sees the same module state
## From folders: Creating packages
To create a package, make a folder with an `__init__.py` file:
```{python}
import os
os.makedirs('my_package', exist_ok=True)
with open('my_package/__init__.py', 'w') as f:
f.write('''
print("my_package is being imported!")
VERSION = "1.0.0"
def greet(name):
return f"Hello from my_package, {name}!"
''')
```
Import it like any module:
```{python}
import my_package
```
The result is a package (note the `__path__` attribute):
```{python}
my_package.__path__
```
```{python}
my_package.VERSION
```
```{python}
my_package.greet("Alice")
```
::: {.callout-note}
The `__init__.py` file is executed when the package is imported, just like a regular `.py` file. Everything defined in `__init__.py` becomes an attribute of the package.
:::
# Working with Packages
Packages become interesting when they contain multiple modules.
## Submodules and dotted imports
Let's add a submodule to our package:
```{python}
with open('my_package/math_utils.py', 'w') as f:
f.write('''
print("math_utils submodule loaded!")
def add(a, b):
return a + b
def multiply(a, b):
return a * b
''')
```
Now import it:
```{python}
import my_package.math_utils
my_package.math_utils.add(5, 7)
```
Important insights:
1. The submodule is **not** automatically available just by importing `my_package`:
```{python}
import importlib
importlib.reload(my_package) # Fresh import
hasattr(my_package, 'math_utils')
```
2. When you do `import my_package.math_utils`, Python **always imports `my_package` first**:
```{python}
#| echo: false
# Clean up for demonstration
if 'my_package' in sys.modules:
del sys.modules['my_package']
if 'my_package.math_utils' in sys.modules:
del sys.modules['my_package.math_utils']
```
```{python}
import my_package.math_utils
# Both are now in sys.modules:
'my_package' in sys.modules, 'my_package.math_utils' in sys.modules
```
3. The submodule becomes an attribute of the parent:
```{python}
my_package.math_utils
```
## Structuring a project
You can control what users see by importing into `__init__.py`:
```{python}
#| echo: false
# Clean up
if 'organized_package' in sys.modules:
del sys.modules['organized_package']
if 'organized_package.core' in sys.modules:
del sys.modules['organized_package.core']
if 'organized_package.utils' in sys.modules:
del sys.modules['organized_package.utils']
```
```{python}
import os
os.makedirs('organized_package', exist_ok=True)
# Create submodules
with open('organized_package/core.py', 'w') as f:
f.write('''
def process(data):
return f"Processing: {data}"
''')
with open('organized_package/utils.py', 'w') as f:
f.write('''
def helper(x):
return x * 2
''')
# Import them in __init__.py
with open('organized_package/__init__.py', 'w') as f:
f.write('''
from .core import process
from .utils import helper
__all__ = ['process', 'helper']
''')
```
Now users can access functions directly:
```{python}
import organized_package
organized_package.process("data")
```
```{python}
organized_package.helper(21)
```
This is how popular packages like `numpy` work - you import `numpy` and get access to many functions, even though they're defined in various submodules.
## Relative imports
In the example above, we used relative imports: `from .core import process`. The `.` means "current package".
Relative import syntax:
- `from . import foo` - Import `foo` from the current package
- `from .module import function` - Import `function` from `module` in the current package
- `from ..parent import something` - Go up one level
- `from ...grandparent import other` - Go up two levels
Advantages:
- **Refactoring-friendly**: Rename your package without changing imports
- **Relocatable**: Move subpackages around more easily
Limitations:
- Only work with `from` syntax (not `import .foo`)
- Only work inside packages (not in standalone scripts)
Example with nested packages:
```{python}
#| echo: false
import shutil
if os.path.exists('nested_package'):
shutil.rmtree('nested_package')
for mod in list(sys.modules.keys()):
if mod.startswith('nested_package'):
del sys.modules[mod]
```
```{python}
os.makedirs('nested_package/sub', exist_ok=True)
with open('nested_package/__init__.py', 'w') as f:
f.write('TOP_LEVEL = "main package"')
with open('nested_package/sub/__init__.py', 'w') as f:
f.write('''
from .. import TOP_LEVEL
def show_parent():
return f"Parent defines: {TOP_LEVEL}"
''')
```
```{python}
from nested_package.sub import show_parent
show_parent()
```
::: {.callout-tip}
Relative imports are perfectly fine in modern Python. The old advice against them was about "implicit relative imports" (a Python 2 feature that's long gone). For stylistic reasons it's preferable to have a consistent style throughout a project, so either choose relative imports or absolute imports, but not both.
:::
## Import wildcards and `__all__`
The `from module import *` syntax imports "everything" from a module:
```{python}
#| echo: false
if 'star_module' in sys.modules:
del sys.modules['star_module']
```
```{python}
with open('star_module.py', 'w') as f:
f.write('''
__all__ = ['public_func', 'PUBLIC_VAR']
def public_func():
return "I'm public"
def _private_func():
return "I'm private"
PUBLIC_VAR = 42
_PRIVATE_VAR = 99
''')
```
```{python}
from star_module import *
```
Only items in `__all__` are imported:
```{python}
public_func(), PUBLIC_VAR
```
```{python}
#| error: true
_private_func()
```
::: {.callout-warning}
Use `import *` sparingly - it makes code harder to understand because you can't easily see where names come from. You sometimes see it in older documentation for specific libraries, such as `from tkinter import *`, but I consider it to be old-fashioned.
:::
The `__all__` attribute also serves as documentation, explicitly declaring your module's public API.
# Circular Imports: Just Don't
Circular imports occur when module A imports module B, and module B imports module A.
Here's why they're problematic:
1. User runs `import A`
2. Python creates an empty module object for `A` in `sys.modules`
3. Python starts executing `A`'s code
4. `A` tries to `import B`
5. Python creates an empty module object for `B` in `sys.modules`
6. Python starts executing `B`'s code
7. `B` tries to `import A`
8. Python finds `A` in `sys.modules` and returns it... **but it's only partially initialized!**
The result depends on execution order and is extremely fragile:
```{python}
#| echo: false
if 'circular_a' in sys.modules:
del sys.modules['circular_a']
if 'circular_b' in sys.modules:
del sys.modules['circular_b']
```
```{python}
with open('circular_a.py', 'w') as f:
f.write('''
print("A: Starting import")
from circular_b import b_func
def a_func():
return "From A"
print("A: Finished import")
''')
with open('circular_b.py', 'w') as f:
f.write('''
print("B: Starting import")
from circular_a import a_func
def b_func():
return "From B"
print("B: Finished import")
''')
```
```{python}
#| error: true
import circular_a
```
Module B tries to import `a_func`, but A hasn't defined it yet!
**Solution**: Don't create circular imports. Restructure your code:
- Extract shared code to a third module
- Move imports inside functions (if you must)
- Rethink your module boundaries
# The Import System Under the Hood
Now let's understand how imports actually work.
## The import process
When you run `import some_module`, Python follows these steps:
1. **Check the cache**: Is `some_module` in `sys.modules`? If yes, return it immediately.
2. **Find the module**: Use finders in `sys.meta_path` to locate the module and get a "module spec".
3. **Load the module**: Use the spec's loader to create and execute the module object.
Let's explore each step.
## Meta path finders
The `sys.meta_path` list contains objects that know how to find modules:
```{python}
import sys
sys.meta_path
```
Each finder has a `find_spec()` method that tries to locate a module:
```{python}
builtin_finder = sys.meta_path[2]
frozen_finder = sys.meta_path[3]
pathfinder = sys.meta_path[4]
for name in ['sys', 'os', 'datetime', 'json']:
print(f"{name:10} -> builtin: {builtin_finder.find_spec(name) is not None}, "
f"frozen: {frozen_finder.find_spec(name) is not None}, "
f"path: {pathfinder.find_spec(name) is not None}")
```
The first finder to return a spec wins.
## The path-based finder
The path-based finder is the most important for your code. It searches locations in `sys.path`:
```{python}
sys.path[:5] # First few entries
```
`sys.path` typically contains:
- The directory of the script being run (or current directory)
- `PYTHONPATH` environment variable contents
- Standard library directories
- Site-packages (third-party packages)
The path-based finder uses `sys.path_hooks` to create specialized finders for each location:
```{python}
sys.path_hooks
```
For file-based searching, there's `FileFinder`:
```{python}
file_finder_factory = sys.path_hooks[1]
file_finder_factory
```
This creates a finder for a specific directory:
```{python}
import os
cwd = os.getcwd()
finder = file_finder_factory(cwd)
finder
```
This finder can locate modules:
```{python}
spec = finder.find_spec('my_module')
spec
```
The spec contains all the information needed to load the module:
```{python}
spec.name, spec.origin, spec.loader
```
## Loading a module manually
We can manually load a module using the import machinery:
```{python}
#| echo: false
if 'manual_demo' in sys.modules:
del sys.modules['manual_demo']
```
```{python}
with open('manual_demo.py', 'w') as f:
f.write('''
print("Manual demo loaded!")
VALUE = 100
''')
```
Step by step:
```{python}
# Find the spec
spec = pathfinder.find_spec('manual_demo')
print("Spec:", spec)
```
```{python}
# Create an empty module
import types
module = types.ModuleType(spec.name)
print("Empty module:", module)
```
```{python}
# Add to sys.modules (important!)
sys.modules[spec.name] = module
```
```{python}
# Execute the module code
spec.loader.exec_module(module)
```
```{python}
# Now the module is populated
module.VALUE
```
This is essentially what Python does when you write `import manual_demo`.
## Submodules use parent's `__path__`
For submodules like `import package.submodule`, Python doesn't search `sys.path`. Instead, it searches `package.__path__`:
```{python}
import json
json.__path__
```
```{python}
spec = pathfinder.find_spec('tool', path=json.__path__)
spec
```
This is how packages organize their submodules - the parent package's `__path__` determines where to look for children.
## Controlling the import system
You can influence how Python finds modules:
**1. Modify `sys.path`** (most common):
```python
import sys
sys.path.insert(0, '/path/to/my/modules')
```
**2. Set `PYTHONPATH`** environment variable:
```bash
export PYTHONPATH=/path/to/my/modules
python script.py
```
**3. Use `site-packages`**: Installing packages with `pip` puts them in `site-packages`, which is automatically in `sys.path`.
**4. Custom finders** (advanced): Add your own finder to `sys.meta_path` to implement custom import logic (e.g., loading modules from a database, over the network, etc.).
# Related Topics for Another Day
The import system has more advanced features we won't cover today:
- **Custom importers and import hooks** - Loading modules from non-standard sources
- **Entry points** - Plugin systems using package metadata
- **Lazy imports** - See [PEP 810](https://peps.python.org/pep-0810/) (expected in Python 3.15)
- **The `importlib` library** - Utilities for working with the import system programmatically
# Additional Resources
- [Python Import System Documentation](https://docs.python.org/3/reference/import.html)
- [importlib Module Documentation](https://docs.python.org/3/library/importlib.html)
- [PEP 420 - Implicit Namespace Packages](https://peps.python.org/pep-0420/)
- [PEP 328 - Imports: Multi-Line and Absolute/Relative](https://peps.python.org/pep-0328/)
- [Video: Advanced Import System](https://www.youtube.com/watch?v=ziC_DlabFto)