If you've ever opened a Jupyter notebook and seen this error:
ModuleNotFoundError: No module named 'app'
โฆeven though the file clearly exists โ you're running into one of the most common (and frustrating) notebook issues: Python import context.
This usually happens because Jupyter:
- Runs inside an already-started Python process
- Does not treat your code as part of a package
- Depends entirely on the current working directory and
sys.path
So even well-structured projects break when you try to import them from a notebook.
In normal Python execution, the fix is to use:
python -m app.module- or install your project with
pip install -e .
But those solutions donโt apply inside Jupyter notebooks.
When testing or debugging your app in a notebook, most developers fall back to hacking sys.path โ which works, but quickly becomes messy and hard to manage.
Instead, we can fix this cleanly with a reusable, scoped utility:
๐ RelativeImportContext
๐ก Why this matters:
RelativeImportContextgives you package-style imports inside a notebook without polluting global state, restarting the kernel, or restructuring your project.
What It Does
- Temporarily adds directories to
sys.path - Restores everything safely after execution
- Captures logs during import/debugging
- Works as both a context manager and decorator
Install:
pip install relative-import-context
Example 1: Fix Local Imports
with RelativeImportContext("./app"):
from services.user import get_user
No more ModuleNotFoundError when running scripts from outside the project root.
Example 2: Multiple Package Roots
with RelativeImportContext(["./app", "./libs"]):
import service
import utils
Example 3: Capture Logs During Import
with RelativeImportContext("./app") as ctx:
import my_module
print(ctx.get_logs())
Great for debugging import side effects.
Example 4: Target a Specific Logger
import logging
logger = logging.getLogger("my_app")
with RelativeImportContext("./app", logger_name="my_app") as ctx:
logger.info("Starting...")
print(ctx.get_logs())
Example 5: Use as a Decorator
@RelativeImportContext("./app")
def run():
import my_module
print("Loaded")
run()
Example 6: Debug Failing Imports
try:
with RelativeImportContext("./app") as ctx:
import broken_module
except Exception as e:
print("Error:", e)
print(ctx.get_logs())
Why This Is Better Than sys.path Hacks
| Approach | Problem |
|---|---|
sys.path.append() scattered everywhere |
Hard to track, pollutes global state |
| Environment variables (PYTHONPATH) | Not portable, hard to debug |
| RelativeImportContext | Scoped, safe, debuggable |
Where This Shines
This pattern is especially useful in:
- Local development without packaging
- Projects with scripts
- CLI tools
- Testing/Debugging
Key Benefits
- โ Zero global side effects
- โ Safe restoration of state
- โ Debug-friendly logging
- โ Reusable and composable
- โ Works as context manager + decorator
The Better Long-Term Fix: python -m
While RelativeImportContext is useful, the real solution is understanding how Python runs modules.
What -m Does
When you run:
python -m app.tasks
Python:
- Treats
appas a package - Adds the project root to
sys.path - Executes
tasks.pyas part of that package
Instead of treating it as a standalone file.
Why This Fixes Imports
Without -m:
python app/tasks.py
- Python sets
sys.pathto the folder containing the file (app/) - So
appis no longer a top-level package - This breaks imports like:
from app.db import SessionLocal
With -m:
python -m app.tasks
- Python runs from the project root
-
appstays a valid package - All absolute imports work correctly
Mental Model
Think of it like this:
| Command | How Python Thinks |
|---|---|
python file.py |
"Just run this file" |
python -m package.module |
"Run this as part of a package" |
Real Project Example
Project structure:
project/
app/
__init__.py
db.py
tasks.py
Correct way:
cd project
python -m app.tasks
Now this works everywhere:
from app.db import SessionLocal
When to Use Each Approach
| Situation | Best Tool |
|---|---|
| Running real app / workers | python -m |
| Production-ready projects | install with pip install -e .
|
| Quick scripts / testing/debugging | RelativeImportContext |
Does python -m Work in Jupyter Notebooks?
Short answer: No โ not directly.
Why Not?
Jupyter notebooks do not run code the same way as the terminal.
When you execute a cell, Python:
- Runs code inside an already-running interpreter
- Does not treat cells as modules or packages
- Does not support
python -mexecution semantics
So this will NOT work inside a notebook:
# โ This does NOT behave like terminal execution
python -m app.tasks
What Actually Happens in Jupyter
Jupyter behaves more like running:
python
>>> # interactive session
So imports depend entirely on:
- Current working directory (
os.getcwd()) - Existing
sys.path
How to Handle Imports in Jupyter
Option 1: Adjust sys.path
import sys
sys.path.append("/path/to/project")
Works, but it's a global hack.
Option 2: Use RelativeImportContext (Cleanest for Notebooks)
๐ Important: This is one of the best real-world uses for RelativeImportContext.
Unlike production apps where python -m or proper packaging is preferred, Jupyter runs in an already-active interpreter. That means you canโt rely on module execution semantics โ but you still need clean, scoped imports.
RelativeImportContext solves this perfectly by:
- Avoiding global
sys.pathpollution - Keeping imports scoped to a single cell or block
- Making debugging easier with captured logs
with RelativeImportContext("./app"):
from app.db import SessionLocal
This gives you package-style imports inside a notebook, without restructuring your entire project or restarting the kernel.
with RelativeImportContext("./app"):
from app.db import SessionLocal
This is actually a perfect use case for your utility.
Option 3: Launch Jupyter from Project Root
Best practice:
cd project
jupyter notebook
Then imports like this will work:
from app.db import SessionLocal
Pro Tip
If you frequently use notebooks in a project:
- Always open Jupyter from the project root
- Or install your project:
pip install -e .
Then imports work everywhere โ notebook, scripts, workers.
Summary
| Environment | Best Approach |
|---|---|
| Terminal scripts | python -m |
| Jupyter notebooks | project root OR RelativeImportContext
|
| Production | pip install -e . |
Instead of fighting Pythonโs import systemโฆ
control it โ safely and locally.
RelativeImportContext turns a messy problem into a clean, reusable pattern.
Python Implementation
import logging
import sys
from contextlib import ContextDecorator
from io import StringIO
from pathlib import Path
from types import TracebackType
from typing import Iterable, Optional, Self, Type
class RelativeImportContext(ContextDecorator):
def __init__(
self,
package_directories: str | Path | Iterable[str | Path],
*,
log_level: int = logging.DEBUG,
logger_name: Optional[str] = None,
) -> None:
if isinstance(package_directories, (str, Path)):
package_directories = [package_directories]
self.package_directories = [
str(Path(directory).expanduser().resolve())
for directory in package_directories
]
self.log_level = log_level
self.logger_name = logger_name
self._original_sys_path: list[str] = []
self._log_stream = StringIO()
self._log_handler = logging.StreamHandler(self._log_stream)
self._log_handler.setLevel(log_level)
self._log_handler.setFormatter(
logging.Formatter("%(levelname)s:%(name)s:%(message)s")
)
self._logger: logging.Logger | None = None
self._old_logger_level: int | None = None
self._entered = False
def __enter__(self) -> Self:
if self._entered:
raise RuntimeError("Cannot re-enter the same context instance")
self._entered = True
self._original_sys_path = list(sys.path)
for directory in reversed(self.package_directories):
if directory not in sys.path:
sys.path.insert(0, directory)
self._logger = logging.getLogger(self.logger_name)
self._old_logger_level = self._logger.level
if self._logger.level > self.log_level or self._logger.level == logging.NOTSET:
self._logger.setLevel(self.log_level)
self._logger.addHandler(self._log_handler)
return self
def __exit__(
self,
exc_type: Type[BaseException] | None,
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> bool:
sys.path[:] = self._original_sys_path
if self._logger is not None:
self._logger.removeHandler(self._log_handler)
if self._old_logger_level is not None:
self._logger.setLevel(self._old_logger_level)
self._log_handler.flush()
self._entered = False
return False
def get_logs(self) -> str:
return self._log_stream.getvalue()
def clear_logs(self) -> None:
self._log_stream.seek(0)
self._log_stream.truncate(0)
Top comments (0)