Case Study: Developing and Testing Python Packages with uv

By on 24 March 2025

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.

OptionDescription
--appCreate a project for an application. Seems to be the default if nothing else is mentioned.
--bareOnly create a pyproject.toml and nothing else.
--libCreate 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).
--packageSet 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.
--scriptAdd 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!

Want a career as a Python Developer but not sure where to start?