Structuring Python projects properly, especially when developing packages, can often be confusing.
Many developers struggle with common questions:
- How exactly should project folders be organised?
- Should tests be inside or outside the package directory?
- Does the package itself belong at the root level or in a special src directory?
- And how do you properly import and test package functionality from scripts or external test files?
To help clarify these common challenges, I’ll show how I typically set up Python projects and organise package structures using the Python package and environment manager, uv.
The challenge
A typical and recurring problem in Python is how to import code that lives in a different place from where it is called. There are two natural ways to organise your code: modules and packages.
Things are fairly straightforward in the beginning, when you start to organise your code and put some functionality into different modules (aka Python files), but keep all the files in the same directory. This works because Python looks in several places when it resolves import statements, and one of those places is the current working directory, where all the modules are:
$ tree
.
├── main.py
└── utils.py
from utils import helper
print(helper())
def helper():
return "I am a helper function"
$ python main.py
I am a helper function
But things get a bit tricky once you have enough code and decide to organise your code into folders. Let’s say you’ve moved your helper code into a src
directory and you still want to import it into main.py
, which is outside the src
folder. Will this work? Well, that depends… on your Python version!
With Python 3.3 or higher, you will not see any error:
$ tree
.
├── main.py
└── src
└── utils.py
from src.utils import helper
print(helper())
def helper():
return "I am a helper function"
$ python main.py
I am a helper function
But if we run the same example using a Python version prior to 3.3, we will encounter the infamous ModuleNotFoundError
. This error only occurs prior 3.3 because Python 3.3 introduced implicit namespace packages. As a result, Python can treat directories without an __init__.py
as packages when used in import statements—under certain conditions. I won’t go into further detail here, but you can learn more about namespace packages in PEP 420.
Since namespace packages behave slightly differently from standard packages, we should explicitly include an __init__.py
file.
However, does this solve all our problems? For this one case, maybe. But, once we move away from the simple assumption that all caller modules reside in the root directory, we encounter the next issue:
$ tree
.
├── scripts
│ └── main.py
└── src
├── __init__.py
└── utils.py
from src.utils import helper
print(helper()) # I am a helper function
def helper():
return "I am a helper function"
$ uv run python scripts/main.py
Traceback (most recent call last):
File "/Users/miay/uv_test/default/scripts/main.py", line 2, in <module>
from src.utils import helper
ModuleNotFoundError: No module named 'src'
You can solve this problem with some path manipulation, but this is fragile and also frowned upon. I will spend the rest of this article giving you a recipe and some best practices for solving this problem with uv
in such a way that you will hopefully never have to experience this problem again…
What is uv
?
uv
is a powerful and fast Python package and project manager designed as a successor to traditional tools like pip
and virtualenv
. I’ve found it particularly helpful in providing fast package resolution and seamless environment management, improving my overall development efficiency. It is the closest thing we have to a unified and community accepted way of setting up and managing Python projects.
Astral is doing a great job of both respecting and serving the community, and I hope that we finally have a good chance of putting the old turf wars about the best Python tool behind us and concentrating on the things that really matter: Writing good code!
Setting Up Your Python Project Using uv
Step 1: Installation
The entry point to uv
is quite simple. First, you follow the installation instructions provided by the official documentation for your operating system.
In my case, as I am on MacOS and like to use Homebrew wherever possible, it comes down to a single command: brew install uv
.
And the really great thing here about uv
is: You don’t need to install Python first to install uv
! You can install uv
in an active Python environment using pip install uv
, but you shouldn’t. The reason is simple: uv
is written in Rust and is meant to be a self-contained tool. You do not want to be dependent on a specific Python version and you want to use uv
without the overhead or potential conflicts introduced by pip
’s Python ecosystem.
It is much better to have uv
available as a global command line tool that lives outside your Python setup. And that is what you get by installing it via curl
, wget
, brew
or any other way except using pip
.
Step 2: Creating a Project
Instead of creating folders manually, I use the uv init
command to efficiently set up my project. This command already has a lot of helpful parameters, so let’s try them out to understand what the different options mean for the actual project structure.
The basic command
$ uv init
sets up your current folder as a Python project. What does this mean? Well, create an empty folder and try it out. After that you can always run the tree
command (if that is available to you) to see a nice tree view of your project structure. In my case, I get the following output for an empty folder initialized with uv
:
$ tree -a -L 1
.
├── .git
├── .gitignore
├── .python-version
├── README.md
├── hello.py
├── pyproject.toml
└── uv.lock
As you can see, uv init
has already taken care of a number of things and provided us with a good starting point. Specifically, it did the following:
- Initialising a git repository,
- Recording the current Python version with a
.python-version
file, which will be used by other developers (or our future selves) to initialise the project with the same Python version as we have used, - Creating a
README.md
file, - Providing a starting point for development with the
hello.py
module, - Providing a way to manage your project’s metadata with the
pyproject.toml
file, and finally, - Resolving the exact versions of the dependencies with the
uv.lock
file (currently mostly empty, but it does contain some information about the required Python version).
The pyproject.toml
file is special in a number of ways. One important thing to know is that uv
recognises a uv
-managed project by detecting and inspecting the pyproject.toml
file. As always, check the documentation for more information.
Let’s understand the uv init
command a little better. As I said, uv init
initialises a Python project in the current working directory as the root folder for the project. You can specify a project folder with uv init <project-name>
, which will give you a new folder in your current working directory named after your project and with the same contents as we discussed earlier.
However, as I want to develop a package, uv
directly supports this use case directly with the --package
option:
$ uv init --package my_package
Initialized project `my-package` at `/Users/miay/uv_test/my_package`
Looking again at the project structure (I will not include hidden files and folders from now on),
$ tree
.
├── README.md
├── pyproject.toml
└── src
└── my_package
└── __init__.py
there is an interesting change to the uv init
command without the --package
option: Instead of a hello.py
module in the project’s root directory, we have an src
folder containing a Python package named after our project (because of the __init__.py
file, I guess you remember that about packages, don’t you?).
uv
does one more thing so let’s have a look at the pyproject.toml
file:
[project]
name = "my-package"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
authors = [
{ name = "Michael Aydinbas", email = "michael.aydinbas@gmail.com" }
]
requires-python = ">=3.13"
dependencies = []
[project.scripts]
my-package = "my_package:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
As well as the “usual” things you would expect to see here, there is also a build-system section, which is only present when using the --package
option, which instructs uv
to install the code under my_package
into the virtual environment managed by uv
. In other words, uv
will install your package and all its dependencies into the virtual environment so that it can be used by all other scripts and modules, wherever they are.
Let’s summarise the different options. However, for our purposes, we will always use the --package
option when working with packages.
Option | Description |
--app | Create a project for an application. Seems to be the default if nothing else is mentioned. |
--bare | Only create a pyproject.toml and nothing else. |
--lib | Create a project for a library. A library is a project that is intended to be built and distributed as a Python package. Works similar as --package but creates an additional py.typed marker file, used to support typing in a package (based on PEP 561). |
--package | Set up the project to be built as a Python package. Defines a [build-system] for the project.This is the default behavior when using --lib or --build-backend .Includes a [project.scripts] entrypoint and use a src/ project structure. |
--script | Add the dependency to the specified Python script, rather than to a project. |
Step 3: Managing the Virtual Environment
This section is quite short.
You can create the virtual environment with all defined dependencies and the source code developed under src
by running
$ uv venv
Using CPython 3.13.2 interpreter at: /opt/homebrew/Caskroom/miniforge/base/bin/python
Creating virtual environment at: .venv
Activate with: source .venv/bin/activate
You can also run uv sync
, which will also create the virtual environment if there is none, and synchronise the environment with the latest dependencies from the pyproject.toml
file on top.
Step 4: Testing Package Functionalities
Congratulations, you’ve basically reached the point from where it doesn’t really matter where you want to import the code you’re developing under my_package
, because it’s installed in the virtual environment and thus known to all Python modules, no matter where they are in the file system. Your package is basically like any other third-party or system package.
To see this in action, let’s demonstrate the two main use cases: testing our package with pytest
and importing our package into some scripts in the scripts
folder. First, the project structure:
$ tree
.
├── scripts
│ └── main.py
├── src
│ └── my_package
│ ├── __init__.py
│ └── utils.py
└── tests
└── test_utils.py
from my_package.utils import helper
def main():
print(helper())
if __name__ == "__main__":
main()
def helper():
return "helper"
from my_package.utils import helper
def test_helper():
assert helper() == "helper"
Note what happens when you create or update your venv:
$ uv sync
Resolved 1 package in 9ms
Built my-package @ file:///Users/miay/uv_test/my_package
Prepared 1 package in 514ms
Installed 1 package in 1ms
+ my-package==0.1.0 (from file:///Users/miay/uv_test/my_package)
See that last line? So uv
installs the package my-package
along with all other dependencies and thus it can be used in any other Python script or module just like any other dependency or library.
As a first step, I am adding pytest
as a dev dependency, meaning it will be added in a separate section in the pyproject.toml
file as it is meant for development and not needed to run the actual package. You will notice that uv add
is not only adding the dependency but also installing it at the same time. With pytest
being available, we can run the tests:
$ uv add --dev pytest
$ uv run pytest
============================= test session starts ==============================
platform darwin -- Python 3.13.2, pytest-8.3.5, pluggy-1.5.0
rootdir: /Users/miay/uv_test/my_package
configfile: pyproject.toml
collected 1 item
tests/test_utils.py . [100%]
============================== 1 passed in 0.01s ===============================
Running scripts/main.py
:
$ uv run scripts/main.py
helper
Everything works the same and effortlessly.
What happens if I update my package code? Do I have to run uv sync
again? Actually, no. It turns out that uv sync
installs the package in the same way as uv pip install -e .
would.
Editable install means that changes to the source directory will immediately affect the installed package, without the need to reinstall. Just change your source code and try it out again:
def helper():
return "helper function"
$ uv run scripts/main.py
helper function
$ uv run pytest
============================= test session starts ==============================
platform darwin -- Python 3.13.2, pytest-8.3.5, pluggy-1.5.0
rootdir: /Users/miay/uv_test/my_package
configfile: pyproject.toml
collected 1 item
tests/test_utils.py F [100%]
=================================== FAILURES ===================================
_________________________________ test_helper __________________________________
def test_helper():
> assert helper() == "helper"
E AssertionError: assert 'helper function' == 'helper'
E
E - helper
E + helper function
tests/test_utils.py:5: AssertionError
The main script returning successfully the new return value of the helper()
function of the utils
module in the my_package
package without the need to reinstall the package first. Likewise, the tests are failing now.
On a final mark: Can you use uv
with a monorepo setup? This means that uv
is used to manage several packages in the same repository. It seems possible, although I have not tried it out, but there are good resources on how to get started using the concept of workspaces.
I hope you have enjoyed following me on this little journey and, as always, I welcome your comments, discussions and questions.
Keep Calm and Code in Python!