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
October 20, 2026
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.
From here on we create a lot of small modules and packages. Rather than scattering them through the folder that holds these notes, everything happens in a throwaway directory. We change into it so that plain filenames land there, and we put it on sys.path so that Python can import from it — which is itself a preview of what the second half of this lecture is about. At the very end of the lecture we delete it again.
As you follow along you would simply make these files yourself, in a folder of your own.
import os
import shutil
import sys
import tempfile
from pathlib import Path
original_cwd = Path.cwd()
scratch = Path(tempfile.mkdtemp(prefix='import-demo-'))
os.chdir(scratch) # relative filenames now land here
sys.path.insert(0, str(scratch)) # ... and Python can import from here
scratchPosixPath('/tmp/import-demo-kpfbg5wy')
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:
__future__ (see the note below)Within 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.
from __future__ import annotations
You will meet this line at the top of a great deal of existing Python, so it is worth knowing what it does. The __future__ module is how Python ships a change before making it the default: importing from it switches the new behaviour on for that one file.
This particular one comes from PEP 563. It stops Python from evaluating type annotations when a function or class is defined, storing them as plain strings instead. People used it for two reasons: to refer to a class inside its own definition without quoting it, and to avoid paying the runtime cost of imports that only annotations needed.
It was originally scheduled to become the default in Python 3.10. That was postponed, and then cancelled outright. Instead, Python 3.14 solved the same problem a better way (PEP 649 and PEP 749): annotations are now evaluated lazily — not at definition time, but only if something actually asks for them. You get the benefits without the annotations being reduced to strings.
So: in new code targeting Python 3.14 or later you almost certainly don’t need this line. It is not removed and not formally deprecated — the documentation says it will never become mandatory, and will eventually be deprecated and removed — but it has no future. Recognise it in old code; don’t reach for it in new code.
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. They form a level of structure above the classes, and 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 urge 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.
python -mThere are two ways to run Python code from the command line, and the difference matters:
With -m, Python looks the name up through the import system instead of treating it as a path. You have almost certainly used this already:
The practical differences:
python script.py puts the script’s own folder at the front of sys.path. python -m puts the current working directory there instead. This is the usual reason a program runs one way and not the other.-m finds the module wherever it is installed, so it works no matter which folder you are standing in.-m, using python -m pip rather than plain pip guarantees you get the pip belonging to that interpreter — a genuinely useful habit when several Pythons are installed.In both cases the module being run is given the name __main__, which is exactly why the if __name__ == '__main__': idiom works for either.
__main__.py: making a package runnableA package can be run with -m too, if it contains a file called __main__.py. That file is what gets executed:
Now python -m runnable_package runs that file. Let’s actually do it, by launching a second Python process:
import subprocess
result = subprocess.run(
[sys.executable, '-m', 'runnable_package'],
capture_output=True,
text=True,
cwd=scratch,
)
print(result.stdout, end='')Running runnable_package version 1.0
This is how tools ship a command line entry point that works before anything is installed, and it is the mechanism behind python -m http.server.
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, into the scratch folder we set up earlier. As you are following along, it would make more sense to create these files yourself manually, in a folder of your own.
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 '/tmp/import-demo-kpfbg5wy/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.
importlib.resourcesSooner or later a package needs a file that isn’t code: a default configuration, a template, a small lookup table. The tempting thing is to locate it relative to __file__:
It works right up until it doesn’t. If your package is installed from a zip file, or bundled by a tool that doesn’t unpack it onto disk, there is no __file__ to be relative to.
importlib.resources is the supported way. It asks the package for its data, and lets the import system worry about where that actually lives:
from importlib.resources import files
content = files('organized_package').joinpath('default.toml').read_text(encoding='utf-8')
print(content)greeting = "hello"
files(package) gives you a Traversable — an object that behaves much like a Path, supporting / and joinpath, read_text, read_bytes and open. If you need a genuine filesystem path (because you are handing it to code that insists on one), there is importlib.resources.as_file, which will unpack the resource to a temporary file if it has to.
For this to work in a distributed package, the data file also has to be included in the distribution. That is a packaging question, and we return to it in the lectures on Architecture.
__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[71], 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[74], line 1 ----> 1 import circular_a File /tmp/import-demo-kpfbg5wy/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 /tmp/import-demo-kpfbg5wy/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 partially initialized module 'circular_a' (most likely due to a circular import) (/tmp/import-demo-kpfbg5wy/circular_a.py)
Module B tries to import a_func, but A hasn’t defined it yet!
Solution: Don’t create circular imports. Restructure your code:
TYPE_CHECKINGThere is a case where the cycle is not a design flaw. Sometimes module A needs a name from module B only to write a type annotation, and B genuinely needs A at runtime. Nothing is wrong with the design; the import is simply not needed while the program runs.
typing.TYPE_CHECKING is a constant that is False at runtime but which every type checker pretends is True:
So an import placed under it never executes, and the cycle never happens — but the type checker still sees the name:
# invoice.py
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from customer import Customer # not imported at runtime
class Invoice:
def send_to(self, customer: "Customer") -> None:
...Note the quotes around "Customer". Because the import didn’t happen, the name does not exist at runtime, so the annotation has to be a string that Python never evaluates. Since Python 3.14 annotations are evaluated lazily anyway, so in new code the quotes are no longer required — see the lecture on type checkers.
Use this sparingly, and only for annotations. If you find yourself wanting a TYPE_CHECKING import so that some runtime code can work, you are back to having a real circular dependency and should restructure instead.
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 0x777ab4936ba0>,
<_virtualenv._Finder at 0x777ab4936a50>,
_frozen_importlib.BuiltinImporter,
_frozen_importlib.FrozenImporter,
_frozen_importlib_external.PathFinder,
<six._SixMetaPathImporter at 0x777ab387cd70>]
Each finder has a find_spec() method that tries to locate a module:
Do not index into this list by position. What you see above is whatever your environment happens to contain: a virtual environment, setuptools or a debugger may all insert finders of their own at the front. The three finders Python itself installs are available by name in importlib.machinery, so grab them from there:
import importlib.machinery as machinery
builtin_finder = machinery.BuiltinImporter
frozen_finder = machinery.FrozenImporter
pathfinder = machinery.PathFinder
# these are the very same objects that appear in sys.meta_path
all(f in sys.meta_path for f in (builtin_finder, frozen_finder, pathfinder))True
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:
['/tmp/import-demo-kpfbg5wy',
'/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',
'']
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. Again we pick it out by name rather than by position — the other hook in the list handles zip archives:
file_finder_factory = next(
hook for hook in sys.path_hooks
if 'FileFinder' in getattr(hook, '__qualname__', '')
)
file_finder_factory<function _frozen_importlib_external.FileFinder.path_hook.<locals>.path_hook_for_FileFinder(path)>
This creates a finder for a specific directory:
FileFinder('/tmp/import-demo-kpfbg5wy')
This finder can locate modules:
ModuleSpec(name='my_module', loader=<_frozen_importlib_external.SourceFileLoader object at 0x777a840df830>, origin='/tmp/import-demo-kpfbg5wy/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 0x777a840dfa70>, origin='/tmp/import-demo-kpfbg5wy/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 0x777a840dfcb0>, 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 programmaticallyFinally, we undo the scratch folder we created at the start: change back to where we were, take the folder off sys.path again, and delete it.
os.chdir(original_cwd)
sys.path.remove(str(scratch))
shutil.rmtree(scratch, ignore_errors=True)
print(f"Scratch folder still there? {scratch.exists()}")Scratch folder still there? False
Note that the modules we imported from it are still sitting in sys.modules. Deleting the files does not unimport anything — once a module object exists, it lives on independently of the file it came from.
---
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
::: {.callout-warning}
## A scratch folder for the rest of this lecture
From here on we create a lot of small modules and packages. Rather than scattering them
through the folder that holds these notes, everything happens in a throwaway directory. We
change into it so that plain filenames land there, and we put it on `sys.path` so that Python
can import from it — which is itself a preview of what the second half of this lecture is
about. At the very end of the lecture we delete it again.
As you follow along you would simply make these files yourself, in a folder of your own.
:::
```{python}
import os
import shutil
import sys
import tempfile
from pathlib import Path
original_cwd = Path.cwd()
scratch = Path(tempfile.mkdtemp(prefix='import-demo-'))
os.chdir(scratch) # relative filenames now land here
sys.path.insert(0, str(scratch)) # ... and Python can import from here
scratch
```
Here's a crucial behavior you'll encounter immediately: **modules are only executed once**.
```{python}
#| echo: false
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 from `__future__` (see the note below)
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.
::: {.callout-note}
## About that `from __future__ import annotations`
You will meet this line at the top of a great deal of existing Python, so it is worth knowing
what it does. The `__future__` module is how Python ships a change before making it the
default: importing from it switches the new behaviour on for that one file.
This particular one comes from [PEP 563](https://peps.python.org/pep-0563/). It stops Python
from evaluating type annotations when a function or class is defined, storing them as plain
strings instead. People used it for two reasons: to refer to a class inside its own
definition without quoting it, and to avoid paying the runtime cost of imports that only
annotations needed.
It was originally scheduled to become the default in Python 3.10. That was postponed, and
then cancelled outright. Instead, Python 3.14 solved the same problem a better way
([PEP 649](https://peps.python.org/pep-0649/) and
[PEP 749](https://peps.python.org/pep-0749/)): annotations are now evaluated *lazily* — not
at definition time, but only if something actually asks for them. You get the benefits
without the annotations being reduced to strings.
So: **in new code targeting Python 3.14 or later you almost certainly don't need this line.**
It is not removed and not formally deprecated — the documentation says it will never become
mandatory, and will eventually be deprecated and removed — but it has no future. Recognise
it in old code; don't reach for it in new code.
:::
# 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. They form a level of structure above the classes, and 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 urge 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.
:::
### Running a module with `python -m`
There are two ways to run Python code from the command line, and the difference matters:
```bash
python path/to/script.py # run a FILE
python -m some_module # run a MODULE, found via sys.path
```
With `-m`, Python looks the name up through the import system instead of treating it as a
path. You have almost certainly used this already:
```bash
python -m pip install requests
python -m http.server 8000
python -m json.tool data.json
```
The practical differences:
- `python script.py` puts the *script's own folder* at the front of `sys.path`.
`python -m` puts the *current working directory* there instead. This is the usual reason
a program runs one way and not the other.
- `-m` finds the module wherever it is installed, so it works no matter which folder you
are standing in.
- With `-m`, using `python -m pip` rather than plain `pip` guarantees you get the pip
belonging to *that* interpreter — a genuinely useful habit when several Pythons are
installed.
In both cases the module being run is given the name `__main__`, which is exactly why the
`if __name__ == '__main__':` idiom works for either.
### `__main__.py`: making a package runnable
A *package* can be run with `-m` too, if it contains a file called `__main__.py`. That file
is what gets executed:
```{python}
os.makedirs('runnable_package', exist_ok=True)
with open('runnable_package/__init__.py', 'w') as f:
f.write('VERSION = "1.0"')
with open('runnable_package/__main__.py', 'w') as f:
f.write('''
from runnable_package import VERSION
print(f"Running runnable_package version {VERSION}")
''')
```
Now `python -m runnable_package` runs that file. Let's actually do it, by launching a second
Python process:
```{python}
import subprocess
result = subprocess.run(
[sys.executable, '-m', 'runnable_package'],
capture_output=True,
text=True,
cwd=scratch,
)
print(result.stdout, end='')
```
This is how tools ship a command line entry point that works before anything is installed,
and it is the mechanism behind `python -m http.server`.
## 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, into the scratch folder we set up
earlier. As you are following along, it would make more sense to create these files yourself
manually, in a folder of your own.
:::
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.
:::
## Shipping data files: `importlib.resources`
Sooner or later a package needs a file that isn't code: a default configuration, a template,
a small lookup table. The tempting thing is to locate it relative to `__file__`:
```python
# DON'T do this
config_path = os.path.join(os.path.dirname(__file__), 'default.toml')
```
It works right up until it doesn't. If your package is installed from a zip file, or bundled
by a tool that doesn't unpack it onto disk, there is no `__file__` to be relative to.
`importlib.resources` is the supported way. It asks the package for its data, and lets the
import system worry about where that actually lives:
```{python}
with open('organized_package/default.toml', 'w', encoding='utf-8') as f:
f.write('greeting = "hello"\n')
```
```{python}
from importlib.resources import files
content = files('organized_package').joinpath('default.toml').read_text(encoding='utf-8')
print(content)
```
`files(package)` gives you a `Traversable` — an object that behaves much like a `Path`,
supporting `/` and `joinpath`, `read_text`, `read_bytes` and `open`. If you need a genuine
filesystem path (because you are handing it to code that insists on one), there is
`importlib.resources.as_file`, which will unpack the resource to a temporary file if it has
to.
::: {.callout-note}
For this to work in a distributed package, the data file also has to be *included* in the
distribution. That is a packaging question, and we return to it in the lectures on
Architecture.
:::
## 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 one legitimate exception: `TYPE_CHECKING`
There is a case where the cycle is not a design flaw. Sometimes module `A` needs a name from
module `B` *only* to write a type annotation, and `B` genuinely needs `A` at runtime. Nothing
is wrong with the design; the import is simply not needed while the program runs.
`typing.TYPE_CHECKING` is a constant that is `False` at runtime but which every type checker
pretends is `True`:
```{python}
from typing import TYPE_CHECKING
TYPE_CHECKING
```
So an import placed under it never executes, and the cycle never happens — but the type
checker still sees the name:
```python
# invoice.py
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from customer import Customer # not imported at runtime
class Invoice:
def send_to(self, customer: "Customer") -> None:
...
```
Note the quotes around `"Customer"`. Because the import didn't happen, the name does not
exist at runtime, so the annotation has to be a string that Python never evaluates. Since
Python 3.14 annotations are evaluated lazily anyway, so in new code the quotes are no longer
required — see the [lecture on type checkers](12-linters-types.qmd).
Use this sparingly, and only for annotations. If you find yourself wanting a
`TYPE_CHECKING` import so that some *runtime* code can work, you are back to having a real
circular dependency and should restructure instead.
# 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:
Do not index into this list by position. What you see above is whatever your environment
happens to contain: a virtual environment, `setuptools` or a debugger may all insert finders
of their own at the front. The three finders Python itself installs are available by name in
`importlib.machinery`, so grab them from there:
```{python}
import importlib.machinery as machinery
builtin_finder = machinery.BuiltinImporter
frozen_finder = machinery.FrozenImporter
pathfinder = machinery.PathFinder
# these are the very same objects that appear in sys.meta_path
all(f in sys.meta_path for f in (builtin_finder, frozen_finder, pathfinder))
```
```{python}
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`. Again we pick it out by name rather than by
position — the other hook in the list handles zip archives:
```{python}
file_finder_factory = next(
hook for hook in sys.path_hooks
if 'FileFinder' in getattr(hook, '__qualname__', '')
)
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
# Cleaning up
Finally, we undo the scratch folder we created at the start: change back to where we were,
take the folder off `sys.path` again, and delete it.
```{python}
os.chdir(original_cwd)
sys.path.remove(str(scratch))
shutil.rmtree(scratch, ignore_errors=True)
print(f"Scratch folder still there? {scratch.exists()}")
```
Note that the modules we imported from it are still sitting in `sys.modules`. Deleting the
files does not unimport anything — once a module object exists, it lives on independently of
the file it came from.
```{python}
'my_module' in sys.modules
```
# 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)