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:
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:
[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).
[project]: this allows you to specify your project’s basic metadata, such as deps, your name, etc.
[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:
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.
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 packageinstance = 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.
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:
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:
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:
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.gzSuccessfully 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.
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.
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:
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 resourcesconfig_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 installuv 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.:
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.1git tag 0.1.1uv build
PyPI project sidebar links
When people land on pypi.org/project/your-package, the metadata on the sidebar comes directly from [project.urls] in pyproject.toml.
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 Enginefrom 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.
__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.
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:
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).
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:
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.