I don’t have pip installed on my system, as I use uv for everything. It turns out that packaging an application with uv is really easy. But before I go into that, let me summarise the “normal” and manual way of doing this.

First, you will create the following file structure locally:

dir_name/
├── LICENSE
├── pyproject.toml
├── README.md
├── src/
│   └── package_name/
│       ├── __init__.py
│       └── module.py
└── tests/

Some things to clarify here:

  • Yes, the dir_name doesn’t matter, this is NOT what your package would be known as. More on this later, when we discuss pyproject.toml.
  • tests/ is a placeholder for test files. A more detailed version of how you could structure this is given below.
  • Creating the __init__.py file is recommended because its existence allows users to import the directory as a regular package, for ex. import package_name.
  • The name is NOT controlled by the package_name, more details in pyproject.toml.

Here, the end user would do pip install whatever_name and would use it something like this:

from whatever_name import module
# module.function()

The pyproject.toml

This is the configuration file used by packaging tools, as well as linters, type checkers, etc.
There are three tables possible here:

  1. [build-system]: it allows you to declare a build backend (a library that takes a source tree and builds a source distribution or a build distribution, ex. uv_build) which is delegated a build by a frontend (a tool that users might run that takes arbitrary source trees and builds soure distributions or wheels from them, which is a standard built distribution format, ex. pip and uv).
  2. [project]: this allows you to specify your project’s basic metadata, such as deps, your name, etc.
  3. [tool]: this table has tool-specific subtables, ex. [tool.hatch], [tool.black], etc.

If you are using uv, a generated pyproject.toml after using uv init --lib example looks like this, for example:

[project]
name = "example"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
authors = [
    { name = "Me", email = "me@example.com" }
]
requires-python = ">=3.13"
dependencies = []
 
[build-system]
requires = ["uv_build>=0.11.17,<0.12.0"]
build-backend = "uv_build"

Naming your package

This name filed here is exactly what pip or uv would require to be the name of your package.
So if you want to do uv add example, you need to have name = "example" in your TOML file.

Eariler, I also said this about the directory inside src/:

The name is NOT controlled by the package_name/.

This is only partially true. If you’re using a build backend like uv build, you would get an error like:

Building source distribution (uv build backend)...
  × Failed to build `/home/username/Projects/packaging-test`
  ╰─▶ Expected a Python module at: src/package_name/__init__.py

This is because I have set the package name as example in pyproject.toml:

[project]
name = "example"
description = "demo test to see if directory thing works"
version = "0.1.0"
requires-python = ">=3.13"
dependencies = []
 
[build-system]
requires = ["uv_build>=0.11.17,<0.12.0"]
build-backend = "uv_build"

So to make this work as expected, you would need to create a structure like the one given below, or just change the name field to package_name so that it matches your src/package_name folder.

└── src
    └── package_name 
        ├── __init__.py
        └── module.py

A detailed example

Let’s say you’re working on a relatively complex application; the bare-bones structure for the package should look something like this:

my_project/                      # Repository / project root
├── LICENSE
├── README.md
├── pyproject.toml               # Project metadata & build backend config
├── src/
│   └── my_simple_package/
│       ├── __init__.py
│       ├── __main__.py
│       ├── module_a.py
│       ├── module_b.py
│       ├── -----------
│       ├── module_z.py
│       └── my_simple_sub_package/
│           ├── __init__.py      # Subpackages also need __init__.py
│           ├── module_a.py
│           └── module_b.py
└── tests/
    ├── test_module_a.py
    ├── test_module_b.py
    ├── ----------------
    ├── test_module_z.py
    └── test_my_simple_sub_package/
        ├── test_module_a.py
        └── test_module_b.py

Let’s look at everything in detail:

Project root and src/

The repository root contains your configuration, metadata, and tests. The src/ directory is NOT the project root.

Using a src layout (standardized across modern packaging) solves a subtle but nasty issue: Python prepends the current working directory (sys.path[0] == '') to its search path. Without src/, running tests or scripts from the project root could accidentally import the unbuilt, local directory instead of your installed package or wheel. Putting the code inside src/ forces tests and tools to run against the properly built and installed package.

my_simple_package

Core folder containing the code that goes into the package.

Continuing the naming discussion from before, there’s actually a difference between the import name and the distribution name:

  • Import name: Must be a valid Python identifier (letters, digits, underscores: my_simple_package).
  • Distribution name (PyPI): Usually kebab-case in pyproject.toml (name = "my-simple-package"), though PEP 508 normalizes hyphens and underscores interchangeably during installation (pip install my-simple-package or pip install my_simple_package).

__init__.py

Refer to Modules; in short, the presence of __init__.py marks a directory as a regular package and executes its initialization code when loaded into sys.modules. We will discuss more about this in the next heading.

To give you a teaser, an empty __init__.py will not let you do this:

import package
instance = package.subpackage.Class()  # AttributeError: module 'package' has no attribute 'subpackage'

Importing package only executes the stuff inside package/__init__.py. Unless that file explicitly exposes subpackage using something like from . import subpackage, the attribute is not bound to package.

However, doing this works:

import package.subpackage
instance = package.subpackage.Class()

Because explicitly importing package.subpackage loads the subpackage module and binds it as an attribute on package.

We will also discuss later how to make all imports (i.e. *) work, using __all__.

__main__.py

Used to define the entry point to a package. This is called when you run your application using the -m module flag.

You already know about the “dundername” variable in Python - it gets set to the value "__main__" whenever you run a program by calling the Python interpreter on it. So you are already familiar with this:

def main():
  print("hello world!")
 
if __name__ == "__main__":
  main()

When you run python test.py it runs perfectly fine.

Now think of __main__.py as the file that contains the entrypoint to the entire package, with multiple modules. This file is called when you do python -m; you can think of it as the place where you can put whatever would be in your body of your if __name__ == "__main__" statement.

For the given project, you can run it as:

python -m my_simple_package

CLI Binaries via [project.scripts]

If you want to provide a standalone CLI command installed directly into the user’s PATH (so they can run my-cli instead of python -m my_simple_package), you define an entry point in pyproject.toml:

[project.scripts]
my-cli = "my_simple_package.__main__:main"

Modules

Refer to Modules again; essentially they will allow users to do stuff like:

from my_simple_package import module_a

It’s standard convention to use leading underscores to denote internal/private attributes or helper functions. We have discussed this in Object Oriented Python, but here’s a modified (and LLM-verified) version of what we discussed:

Python does not have compiler-enforced access modifiers (like private or protected in C++/Java). A single underscore (e.g. _helper()) is purely an advisory naming convention under PEP 008 signaling “internal use”. Mechanically, it also prevents those members from being exported when someone runs from module import * (unless explicitly listed in __all__).

Tests

See Testing for more detail. Tests are kept outside src/ at the repository root so that they are not packaged inside your wheel distributions.

Misc (README.md, LICENSE, Images)

These include your documentation files, maybe examples/ or recipes/, and a
README.md along with a LICENSE.

PyPI supports GitHub flavored markdown, so you can use that for your README.

Note that PyPI does NOT host your files for you, so if you want to add images in your README you would have to use absolute paths to some place you’ve hosted your assets at, like GitHub or Codeberg, instead of relative paths.

That is, doing something like this works:

<!-- Codeberg / Gitea: use /raw/ -->
<img src="https://codeberg.org/user/pkg/raw/commit/<commit-hash>/assets/demo.png" alt="demo" />
 
<!-- GitHub: use raw.githubusercontent.com -->
<img src="https://raw.githubusercontent.com/user/pkg/<commit-hash>/assets/demo.png" alt="demo" />

Pinning to a specific commit hash or release tag guarantees the link won’t break if files are moved or deleted on main.

Build Frontends vs. Backends

Modern packaging follows PEP 517 / 518 / 621. Tools like pip and uv are frontends (they resolve dependencies and install wheels). Build backends (like hatchling, flit_core, setuptools, or uv_build) do the actual work of turning source code into source distributions (.tar.gz) and wheels (.whl). Legacy files like setup.py or setup.cfg belonged to older setuptools workflows and are unnecessary today.

Using uv for packaging

uv is the tool I use for packaging python applications.

For initialising a library project (usually for publishing to PyPI), you can use uv init --lib example. This results in a directory structure like this:

├── example
   ├── pyproject.toml
   ├── README.md
   └── src
       └── example
           ├── __init__.py
           └── py.typed

uv always defines a build system in the case of a library, which determines how projects should be packaged and installed.

The default pyproject.toml looks something like this:

[project]
name = "example"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
authors = [
    { name = "Me", email = "me@example.com" }
]
requires-python = ">=3.13"
dependencies = []
 
[build-system]
requires = ["uv_build>=0.11.17,<0.12.0"]
build-backend = "uv_build"

Building your package

You can build your package using uv build. This results in built artifacts inside dist/.
Here’s what uv build does:

Building source distribution (uv build backend)...
Building wheel from source distribution (uv build backend)...
Successfully built dist/example-0.1.0.tar.gz
Successfully built dist/example-0.1.0-py3-none-any.whl

Wheels (.whl) are internally zip files, but with a specific naming scheme, in accordance with a PEP. sdists (.tar.gz) are the raw source code tarballs, which require the user to build/pack on install if a wheel isn’t available.

It’s recommended that you use uv build --no-sources flag to build packages for release because in local development you might use [tool.uv.sources] to pin deps to local path dirs or git repos.

Updating your version

You can use uv version to statically update your project version. For ex. uv version 0.2.0 changes the version inside pyproject.toml. Using uv version by itself shows you the current version of the package.

To increase the version of your package semantics, use the --bump option. For ex. uv version --bump patch|minor|major|dev|stable|alpha|beta|rc.

  • major bumps up the version by a full digit in the first place, ex. 0.1.0 to 1.0.0.
  • minor bumps up the version by a full digit in the second place, ex. 0.1.0 to 0.2.0.
  • patch bumps up the version by a full digit in the third place, ex. 0.0.1 to 0.0.2.
  • To move from a pre-release to a new pre-release, you can bump the relevant pre-release component, ex. uv version --bump patch --bump beta makes example 0.0.1 => 0.0.2b1.
  • To go from a stable version to a beta version, you must use the above command (ex. bump patch and then bump to beta), as you can’t use beta by itself when you’re on a stable release.
  • You can use stable to clear the pre-release component.

TestPyPI testing

It’s useful to use TestPyPI for dry-runs because once a package version is uploaded to PyPI, it’s immutable forever; even if you delete a release from PyPI, that specific version number is permanently burned and can never be re-uploaded. I myself had to delete two versions of some package because I made some mistakes.

In your pyproject.toml add this:

[[tool.uv.index]]
name = "testpypi"
url = "https://test.pypi.org/simple/"
publish-url = "https://test.pypi.org/legacy/"
explicit = true

Then you can publish with:

uv publish --index testpypi

Publishing your package

When you’re ready to publish to PyPI, you can use uv publish for this. In my case, for username I usually fill in __token__ and for the PyPI token I use my token from PyPI’s website. This can however be streamlined using the uv publish --token option. You can even set UV_PUBLISH_TOKEN and UV_PUBLISH_USERNAME in your environment variables.

For publishing to PyPI via GitHub Actions or any other Trusted Publisher, you don’t need any credentials, and instead need to add that trusted publisher to your PyPI project.

That’s it, your package should now be visible on https://pypi.org/project/{name}/!

Local verification

To test that your package can be installed and imported, you can use uv run:

uv run --with <PACKAGE> --no-project -- python -c "import <PACKAGE>"

Some miscellaneous stuff

We will cover some things here that might not be necessary for your toy library, but are kind of essential for any modern library written in Python.

Enabling type checking

See Typing for more details. If someone installs your package and runs mypy or basedpyright on their code, the default type checker fail, telling you that the module is untyped. To circumvent this, you add zero-byte file named py.typed inside your package root, NOT your project root.

For ex., the simplest package structure could look something like this:

src/my_simple_package/
   ├── __init__.py
   ├── py.typed
   └── module_a.py

Including static package assets

If you want to bundle non-Python files such as, config.json or something, you can do this inside the wheel:

src/my_simple_package/
   ├── data/
   │   └── default_config.json

But doing open("data/default_config.json") breaks instantly, as the relative path depends on the user’s cwd.

The standard way of doing this is using importlib.resources:

from importlib import resources
 
config_text = resources.files("my_simple_package.data").joinpath("default_config.json").read_text()

Adding optional dependencies

You can add optional dependencies to your project without bloating the default install inside your pyproject.toml.

[project.optional-dependencies]
   cli = [
       "rich>=13.0",
       "typer>=0.12.0",
   ]
   test = [
       "pytest>=8.0",
       "pytest-cov>=4.0",
   ]
   all = [
       "my_package[cli,test]",
   ]

You can later install these using:

uv add package-name # standard install
uv add "package-name[cli]" # extras for CLI

Dynamic versioning via git tags

What we had covered before was static versioning, i.e. manually editing the pyproject.toml file to change the version, or use uv version --bump to change it. But we can make this better - say we’ve hosted the package on GitHub and we use GitHub Actions and tags for new releases.

Then, we can use the dynamic field in pyproject.toml to denote a value that changes. For ex.:

[project]
name = "package"
dynamic = ["version"]
 
[build-system]
requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build"
 
[tool.hatch.version]
source = "vcs"

Note that this does NOT work with uv_build as the backend. This only works with something like hatchling and hatch-vcs which automatically detect package versions from git tags. If you want to keep using uv_build you can definitely do:

uv version --bump patch # 0.1.1
git tag 0.1.1
uv build

When people land on pypi.org/project/your-package, the metadata on the sidebar comes directly from [project.urls] in pyproject.toml.

So you can use this table in your TOML file:

[project.urls]
Homepage = "https://github.com/user/pkg"
Documentation = "https://pkg.readthedocs.io"
Repository = "https://github.com/user/pkg.git"
Issues = "https://github.com/user/pkg/issues"
Changelog = "https://github.com/user/pkg/blob/main/CHANGELOG.md"

Making imports work using __init__.py

We have discussed about __init__.py before, but now let’s discuss how an end user could import modules in our package properly using this.

One option is to leave it blank. This means zero startup cost, but now your users will have to do:

from package.module import Engine
from package import module # also works
# doing this does NOT work:
# import package
# package.module.Engine()
# you will have to import package.module first.

Another approach is to expose only the public API surface, ex.

from .module_a import Engine
from .module_b import ConfigError
 
__all__ = [
    "Engine",
    "ConfigError"
]

__all__ makes sure that wildcard imports (using *) only imports the strings listed in __all__, while also assisting type checkers.

You can also do lazy imports, but that’s for relatively large packages, so I won’t cover that here.

Personally, I like Geohot’s way of writing the __init__.py, which keeps internals deep in their own modules, and keeps the initialisation short by only exporting primitives that most users would ever actually need.
ex.

from package.core import Engine          # noqa: F401
from package.config import Settings      # noqa: F401
from package.errors import PackageError  # noqa: F401

Note that this does mean users would have to do from package.backend import RareFunction, as it does NOT import everything to keep things fast.

Suppressing linters using # noqa: F401

Rule F401 in Ruff and other linters flags “imported but unused” as linters assume if a symbol is imported in __init__.py and unused, it’s dead code.
Using the comment # noqa: F401 we suppress the linter instead of maintaining an explicit __all__ list.

Keep this in mind for your own imports:

  1. Attribute access (package.subpackage.file) requires that file was imported somewhere along the import chain (either in package/__init__.py or explicitly via import package.subpackage.file).

  2. from package.subpackage import name only looks inside subpackage/__init__.py (or for a submodule file named name.py). If the init file is empty, you must import directly from the specific module using from package.subpackage.module import name. If you want the former to work just don’t make the init file empty.

His actual __init__.py file for tinygrad is given below:

import os
if int(os.getenv("TYPED", "0")):
  from typeguard import install_import_hook
  install_import_hook(__name__)
from tinygrad.tensor import Tensor                                    # noqa: F401
from tinygrad.engine.jit import TinyJit                               # noqa: F401
from tinygrad.function import function                                # noqa: F401
from tinygrad.uop.ops import UOp
Variable = UOp.variable
from tinygrad.dtype import dtypes                                     # noqa: F401
from tinygrad.helpers import GlobalCounters, fetch, Context, getenv   # noqa: F401
from tinygrad.device import Device                                    # noqa: F401

Also, see how he uses absolute imports? This is because tinygrad’s directory structure is like this:

tinygrad/
├── ... # omitted a lot of stuff
├── engine
│   ├── __init__.py
│   ├── jit.py
│   ├── realize.py
│   └── worker.py
├── function.py
├── helpers.py
├── __init__.py # you were looking at this before.
├── py.typed
├── tensor.py
└── ... # omitted a lot of stuff again

So imports like from tinygrad.tensor import Tensor or from tinygrad.engine.jit import TinyJit work here.

Even PEP8 recommends absolute imports:

Absolute imports are recommended, as they are usually more readable and tend to be better behaved (or at least give better error messages) if the import system is incorrectly configured.