I’m working on a Python project using uv as my package manager and struggling with ModuleNotFoundError when importing local packages. My goal is to make config/ and packages/src/ available throughout the project (e.g., from config.connection_config import database_config
) after an editable install with uv pip install -e .
.
Here’s my project structure:
dashboard/
├── config/
│ ├── __init__.py
│ └── connection_config.py
│ └── env/
├── packages/
│ └── src/
│ └── (other packages/modules with __init__.py)
├── services/
│ └── data_ingestion/
│ └── ingest_data.py
├── pyproject.toml
└── .venv/
And my pyproject.toml:
[project]
name = "dashboard"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"loguru>=0.7.3",
"mysql-connector>=2.2.9",
"pandas>=2.2.3",
"paramiko>=3.5.1",
"polars>=1.23.0",
"pydantic-settings>=2.8.0",
"streamlit>=1.41.1",
]
[project.optional-dependencies]
test = ["pytest>=8.3.5"]
[tool.setuptools.packages.find]
where = ["packages/src", "config"] # Search in both directories
include = ["*"] # Include all packages found
exclude = ["tests*"] # Exclude test directories
[tool.pytest.ini_options]
pythonpath = "."
I run uv pip install -e .
from the root (dashboard/), expecting config and packages/src to be importable anywhere in the project. However, when running python services/data_ingestion/ingest_data.py (which does from config.connection_config import database_config), I get:
ModuleNotFoundError: No module named 'config'
I suspect I’m misconfiguring pyproject.toml or misunderstanding how uv and setuptools handle editable installs. Things I’ve tried:
Running uv pip install -e .
from the root.
Verifying init.py exists in config/.
Running the script from different directories (root and services/data_ingestion/), same error.
How should I configure pyproject.toml to make config/ and packages/src/ importable project-wide? Are there best practices for structuring and running such a project with uv, I would really like to understand this better? Any help is appreciated!
I’m working on a Python project using uv as my package manager and struggling with ModuleNotFoundError when importing local packages. My goal is to make config/ and packages/src/ available throughout the project (e.g., from config.connection_config import database_config
) after an editable install with uv pip install -e .
.
Here’s my project structure:
dashboard/
├── config/
│ ├── __init__.py
│ └── connection_config.py
│ └── env/
├── packages/
│ └── src/
│ └── (other packages/modules with __init__.py)
├── services/
│ └── data_ingestion/
│ └── ingest_data.py
├── pyproject.toml
└── .venv/
And my pyproject.toml:
[project]
name = "dashboard"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"loguru>=0.7.3",
"mysql-connector>=2.2.9",
"pandas>=2.2.3",
"paramiko>=3.5.1",
"polars>=1.23.0",
"pydantic-settings>=2.8.0",
"streamlit>=1.41.1",
]
[project.optional-dependencies]
test = ["pytest>=8.3.5"]
[tool.setuptools.packages.find]
where = ["packages/src", "config"] # Search in both directories
include = ["*"] # Include all packages found
exclude = ["tests*"] # Exclude test directories
[tool.pytest.ini_options]
pythonpath = "."
I run uv pip install -e .
from the root (dashboard/), expecting config and packages/src to be importable anywhere in the project. However, when running python services/data_ingestion/ingest_data.py (which does from config.connection_config import database_config), I get:
ModuleNotFoundError: No module named 'config'
I suspect I’m misconfiguring pyproject.toml or misunderstanding how uv and setuptools handle editable installs. Things I’ve tried:
Running uv pip install -e .
from the root.
Verifying init.py exists in config/.
Running the script from different directories (root and services/data_ingestion/), same error.
How should I configure pyproject.toml to make config/ and packages/src/ importable project-wide? Are there best practices for structuring and running such a project with uv, I would really like to understand this better? Any help is appreciated!
2 Answers
Reset to default 1It seems that you want a full project folder structure with configs and services for your app.
In that case you can simply use the arkalos framework, which is built on the top of the uv, and will give you .env file and config folder with other batteries out of the box, and imports will just work.
https://arkalos/docs/structure/
And then you could just use the config() function to get any setting you need.
Or without using it, you simply can replicate the settings in the pyproject.toml file and the folder structure.
You do NOT need to create src subfolders inside the app project. Only use them if you are developing a separate uv stand-alone package.
Just have all your app code inside the app folder.
This is what Arkalos init will add to your pyproject.toml
[tool.setuptools.packages.find]
where = ["."]
include = ["app", "config", "scripts", "src", "tests"]
So you messed up where
and include
rules. They shall be swapped.
The correct pyproject.toml
for your case will be:
[project]
name = "dashboard"
version = "0.1.0"
description = "Dashboard with modular packages"
readme = "README.md"
requires-python = ">=3.9"
dependencies = []
[project.scripts]
dashboard-ingest = "services.data_ingestion.ingest_data:main"
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["config", "packages/src", "services"]
include = ["*"]
exclude = ["tests*"]
[tool.pytest.ini_options]
pythonpath = ["config", "packages/src", "services"]
You need [tool.pytest.ini_options]
so that pytest
can find your packages.
The where = ["config", "packages/src", "services"]
tells setuptools
where to search for new modules.
It is very important, where you place a __init__.py
so that the module system works
correctly.
You can create your project structure by:
# Step 1: Create base project folder and enter it
mkdir dashboard
cd dashboard
# Step 2: Create required folders
mkdir -p config
mkdir -p services/data_ingestion
mkdir -p packages/src/pkg1
mkdir -p packages/src/pkg2
mkdir -p tests
# Step 3: Create __init__.py files
touch config/__init__.py
touch services/data_ingestion/__init__.py
touch packages/src/__init__.py
touch packages/src/pkg1/__init__.py
touch packages/src/pkg2/__init__.py
And you can try this:
nano config/connection_config.py
and save this:
def get_connection_info():
return {"host": "localhost", "port": 5432}
nano packages/src/pkg1/utils.py
and save:
def fetch_data(connection):
return f"Fetched data from {connection['host']}:{connection['port']}"
nano packages/src/pkg2/core.py
and save:
def process_data(data):
return f"Processed: {data}"
nano services/data_ingestion/ingest_data.py
and save:
from config.connection_config import get_connection_info
from pkg1.utils import fetch_data
from pkg2.core import process_data
def ingest():
conn = get_connection_info()
raw_data = fetch_data(conn)
return process_data(raw_data)
def main():
result = ingest()
print(f"Data ingestion result: {result}")
So as you can see, the config
and pkg1
, pkg2
are on the primary level.
Not under dashboard
so NOT from dashboard.config.connection_config import get_connection_info
.
You can add a test repo by:
nano tests/test_ingest.py
from services.data_ingestion.ingest_data import ingest
def test_ingest_returns_something():
result = ingest()
assert result is not None
The
[project.scripts]
dashboard-ingest = "services.data_ingestion.ingest_data:main"
allows you to start the main
function in the ingest_data
file directly
from the command line - this is very cool with uv
.
And you can write more such CLI commands by adding such definitions.
Note, you have to give the full path connected with .
to the file.
Starting from the points you have given under where =
.
But this works only from inside the folder - because you need to be in that virtual environment from uv
.
For me, my brain wants to import always starting from dashboard
. But setuptools
doesn't start from the outer folder but from where we told it to start to look for.
This is the reason, why mostly the setting is:
src/dashboard/
and then to put everything inside src/dashboard/
, the config
, packages
, services
.
And to set as entry point where = "src"
.
In this way, you would import starting from import dashboard
and all the other ocmponents would be then submodules from dashboard
: dashboard.config. ...
, dashboard.services. ...
and so on.
So maybe this is the more logical structuring which you had actually in mind?
Also I find it quite arbitrary, why you do packages/src/
while not services/src/
.
More logical package structure
# Step 1: Create root project folder and go inside
mkdir dashboard && cd dashboard
# Step 2: Create src layout
mkdir -p src/dashboard/config
mkdir -p src/dashboard/packages/pkg1
mkdir -p src/dashboard/packages/pkg2
mkdir -p src/dashboard/services/data_ingestion
mkdir -p tests
# Step 3: Create __init__.py files
touch src/dashboard/__init__.py
touch src/dashboard/config/__init__.py
touch src/dashboard/packages/__init__.py
touch src/dashboard/packages/pkg1/__init__.py
touch src/dashboard/packages/pkg2/__init__.py
touch src/dashboard/services/__init__.py
touch src/dashboard/services/data_ingestion/__init__.py
And the file contents:
# nano pyproject.toml
[project]
name = "dashboard"
version = "0.1.0"
description = "Clean src-based dashboard"
readme = "README.md"
requires-python = ">=3.9"
dependencies = []
[project.scripts]
dashboard-ingest = "dashboard.services.data_ingestion.ingest_data:main"
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["src"]
include = ["dashboard*"]
exclude = ["tests*"]
[tool.pytest.ini_options]
pythonpath = ["src"]
# nano src/dashboard/__init__.py
from . import config, packages, services
# nano src/dashboard/config/__init__.py
from .connection_config import get_connection_info
# nano src/dashboard/config/connection_config.py
def get_connection_info():
return {"host": "localhost", "port": 5432}
# nano src/dashboard/packages/__init__.py
from . import pkg1, pkg2
# nano src/dashboard/packages/pkg1/__init__.py
from .utils import fetch_data
# nano src/dashboard/packages/pkg1/utils.py
def fetch_data(connection):
return f"Fetched data from {connection['host']}:{connection['port']}"
# nano src/dashboard/packages/pkg2/__init__.py
from .core import process_data
# nano src/dashboard/packages/pkg2/core.py
def process_data(data):
return f"Processed: {data}"
# nano src/dashboard/services/__init__.py
from . import data_ingestion
# nano src/dashboard/services/data_ingestion/__init__.py
from .ingest_data import ingest
# nano src/dashboard/services/data_ingestion/ingest_data.py
from dashboard.config import get_connection_info
from dashboard.packages.pkg1 import fetch_data
from dashboard.packages.pkg2 import process_data
def ingest():
conn = get_connection_info()
raw_data = fetch_data(conn)
return process_data(raw_data)
def main():
result = ingest()
print(f"Ingestion Result: {result}")
# nano tests/test_ingest.py
from dashboard.services.data_ingestion import ingest
def test_ingest():
result = ingest()
assert "Processed: Fetched" in result
Install by:
uv pip install -e .
uv pip install pytest
Run the test by:
pytest
The output should be like:
============================================= test session starts ==============================================
platform darwin -- Python 3.12.2, pytest-8.3.5, pluggy-1.5.0
rootdir: ~/projects/python/dashboard
configfile: pyproject.toml
collected 1 item
tests/test_ingest.py . [100%]
============================================== 1 passed in 0.01s ===============================================
The [project.scripts]
sections makes an entry into
~/.venv/bin/
under the command name dashboard-ingest
, thus creates an executable file:
~/.venv/bin/dashboard-ingest
with a script to run it.
#!/Users/josephus/.venv/bin/python3
# -*- coding: utf-8 -*-
import sys
from dashboard.services.data_ingestion import main
if __name__ == "__main__":
if sys.argv[0].endswith("-script.pyw"):
sys.argv[0] = sys.argv[0][:-11]
elif sys.argv[0].endswith(".exe"):
sys.argv[0] = sys.argv[0][:-4]
sys.exit(main())
Since ~/.venv/bin
is added to your PATH
variable when you installed uv
(by using the global script - not by pip install uv
or within a virtual environment) - you can call the CLI command from everywhere after the install!
So, finally you would have a more logical package structure.
although for this, you have to make some entries into the __init__.py
files - some imports - to manipulate the final composition of the namespace (e.g. you don't want certain file names to appear in the module structure visible from outside).