# Build and Publish a Python Package with uv, Ruff, ty, pytest, and GitHub Actions

A Python library needs more than working code. It needs linting to catch bugs before they land, type checking to verify contracts between functions, tests to confirm behavior, a CI pipeline to enforce all of that on every commit, and a publishing pipeline that puts new releases on PyPI without manual uploads.

This tutorial builds all of it from an empty directory. You will create a small library, add [Ruff](https://pydevtools.com/handbook/reference/ruff.md) for linting and formatting, [ty](https://pydevtools.com/handbook/reference/ty.md) for type checking, [pytest](https://pydevtools.com/handbook/reference/pytest.md) for testing, wire them into GitHub Actions, and publish to TestPyPI using [trusted publishing](https://pydevtools.com/handbook/explanation/why-use-trusted-publishing-for-pypi.md). By the end, every push runs four quality gates and every GitHub release lands on TestPyPI without a token.

If you only need the local development loop without CI or publishing, start with [Set up a complete Python project with uv, Ruff, ty, pytest, and pre-commit](https://pydevtools.com/handbook/tutorial/set-up-a-complete-python-project.md). This tutorial extends that stack with GitHub Actions and PyPI.

## Prerequisites

* [uv](https://pydevtools.com/handbook/reference/uv.md) installed ([installation guide](https://docs.astral.sh/uv/getting-started/installation/))
* A GitHub account
* A [TestPyPI](https://test.pypi.org) account ([register here](https://test.pypi.org/account/register/))

## Create the Project

```console
$ uv init --lib byteformat
Initialized project `byteformat` at `/path/to/byteformat`
$ cd byteformat
```

`uv init --lib` scaffolds a library project with a `src/` layout, a [pyproject.toml](https://pydevtools.com/handbook/reference/pyproject.toml.md), a `.python-version` file, and a `py.typed` marker for type checkers. If you see `error: Failed to create project`, the directory already exists; pick a fresh name.

Open `pyproject.toml`. The `requires-python` field says `>=3.14` because uv defaults to the newest Python on your system. That constraint is too tight for this tutorial, which tests against 3.12, 3.13, and 3.14 in CI. Widen it:

```toml
requires-python = ">=3.12"
```

The `.python-version` file stays at 3.14 for local development. `requires-python` controls which versions your package declares support for; `.python-version` controls which version uv uses on your machine.

## Write the Library Code

The library formats byte counts into human-readable strings (`1048576` becomes `"1 MB"`) and parses them back.

Replace the contents of `src/byteformat/__init__.py` with:

```python {filename="src/byteformat/__init__.py"}
from byteformat.formatter import format_bytes, parse_bytes

__all__ = ["format_bytes", "parse_bytes"]
```

Create `src/byteformat/formatter.py` with the following code. Type it as shown, including the unused `import math` and the raw `return number` in `parse_bytes`; the next two sections catch both:

```python {filename="src/byteformat/formatter.py"}
import math

UNITS = ("B","KB","MB","GB","TB","PB")

def format_bytes(num_bytes:int) -> str:
    if num_bytes < 0:
        raise ValueError(f"Byte count must be non-negative, got {num_bytes}")
    value = float(num_bytes)
    for unit in UNITS[:-1]:
        if value < 1024:
            return f"{int(value)} {unit}" if value == int(value) else f"{value:.1f} {unit}"
        value /= 1024
    return f"{int(value)} {UNITS[-1]}" if value == int(value) else f"{value:.1f} {UNITS[-1]}"

def parse_bytes(text:str) -> int:
    text = text.strip().upper()
    for i in range(len(UNITS) - 1,-1,-1):
        if text.endswith(UNITS[i]):
            number = text[:-len(UNITS[i])].strip()
            return number
    raise ValueError(f"Unknown byte format: {text!r}")
```

The `parse_bytes` function iterates the units longest-first so that `"KB"` matches before `"B"`. It has a type error: `number` is a `str` (from slicing the input), but the function declares `-> int`. Ruff catches the unused import in the next section; ty catches the type error in the section after that.

Verify the code runs:

```console
$ uv run python -c "from byteformat import format_bytes; print(format_bytes(1_048_576))"
1 MB
```

If you see `ModuleNotFoundError: No module named 'byteformat.formatter'`, the file is in the wrong location. It belongs at `src/byteformat/formatter.py`, inside the package directory that `uv init --lib` created.

## Format and Lint with Ruff

Add Ruff as a development dependency:

```console
$ uv add --dev ruff
Resolved 2 packages in 136ms
Installed 1 package in 4ms
 + ruff==0.15.13
```

Notice that `uv add` also created `uv.lock` in the project root. The [lockfile](https://pydevtools.com/handbook/explanation/what-is-a-lock-file.md) pins every dependency to an exact version so CI installs the same packages every time.

Run the linter:

```console
$ uv run ruff check .
src/byteformat/formatter.py:1:8: F401 [*] `math` imported but unused
  |
1 | import math
  |        ^^^^ F401
2 |
3 | UNITS = ("B","KB","MB","GB","TB","PB")
  |
  = help: Remove unused import: `math`

Found 1 error.
[*] 1 fixable with the `--fix` option.
```

Ruff caught the unused import. Apply the fix:

```console
$ uv run ruff check --fix .
Found 1 error (1 fixed, 0 remaining).
```

Now format the code:

```console
$ uv run ruff format .
1 file reformatted
```

Open `formatter.py` to see the changes. Ruff added spaces after colons in type annotations, spaces after commas, and blank lines between top-level definitions. The code is readable and consistent without any manual effort.

Add Ruff configuration to `pyproject.toml` so the linter enforces import sorting and catches common bugs on every run:

```toml
[tool.ruff.lint]
extend-select = ["I", "B"]
```

`I` enforces sorted imports ([isort](https://pydevtools.com/handbook/how-to/how-to-sort-python-imports-with-ruff.md) rules). `B` enables [flake8-bugbear](https://docs.astral.sh/ruff/rules/#flake8-bugbear-b) rules that catch common Python gotchas. For a more comprehensive set of rules, see [How to configure recommended Ruff defaults](https://pydevtools.com/handbook/how-to/how-to-configure-recommended-ruff-defaults.md).

## Type-check with ty

```console
$ uv add --dev ty
Resolved 3 packages in 89ms
Installed 1 package in 4ms
 + ty==0.0.38
```

Run the type checker:

```console
$ uv run ty check
error[invalid-return-type]: Return type does not match returned value
  --> src/byteformat/formatter.py:15:31
   |
15 | def parse_bytes(text: str) -> int:
   |                               --- Expected `int` because of return type
16 |     text = text.strip().upper()
17 |     for i in range(len(UNITS) - 1, -1, -1):
18 |         if text.endswith(UNITS[i]):
19 |             number = text[: -len(UNITS[i])].strip()
20 |             return number
   |                    ^^^^^^ expected `int`, found `str`
   |

Found 1 diagnostic
```

ty found a real bug. `parse_bytes` declares `-> int`, but `number` is a `str` (produced by slicing the input text). Python would not raise an error until a caller tries to do arithmetic with the result. The type annotation caught it statically.

Open `src/byteformat/formatter.py` and replace `return number` with the conversion:

```python
            return int(float(number) * (1024**i))
```

This parses the numeric string, multiplies by the appropriate power of 1024, and returns an `int` as declared.

Re-run the type checker:

```console
$ uv run ty check
All checks passed!
```

For more on ty, see [How to try the ty type checker](https://pydevtools.com/handbook/how-to/how-to-try-the-ty-type-checker.md).

## Test with pytest

```console
$ uv add --dev pytest
Resolved 8 packages in 115ms
Installed 5 packages in 12ms
 + iniconfig==2.3.0
 + packaging==26.0
 + pluggy==1.6.0
 + pytest==9.0.2
 + pygments==2.19.2
```

Create a test directory and a test file:

```console
$ mkdir tests
```

```python {filename="tests/test_formatter.py"}
import pytest

from byteformat import format_bytes, parse_bytes


def test_format_zero():
    assert format_bytes(0) == "0 B"


def test_format_kilobytes():
    assert format_bytes(1024) == "1 KB"


def test_format_megabytes():
    assert format_bytes(1_048_576) == "1 MB"


def test_format_fractional():
    assert format_bytes(1536) == "1.5 KB"


def test_format_negative_raises():
    with pytest.raises(ValueError, match="non-negative"):
        format_bytes(-1)


def test_parse_kilobytes():
    assert parse_bytes("1 KB") == 1024


def test_parse_unknown_raises():
    with pytest.raises(ValueError, match="Unknown"):
        parse_bytes("5 XY")


def test_roundtrip():
    for n in [0, 1, 1024, 1_048_576, 1_073_741_824]:
        assert parse_bytes(format_bytes(n)) == n
```

The `test_roundtrip` function verifies that formatting a number and parsing the result gives back the original value, catching rounding and truncation bugs that isolated assertions on each function would miss.

Add pytest configuration to `pyproject.toml`:

```toml
[tool.pytest.ini_options]
testpaths = ["tests"]
```

Run the tests:

```console
$ uv run pytest
============================= test session starts ==============================
platform darwin -- Python 3.14.4, pytest-9.0.2, pluggy-1.6.0
rootdir: /path/to/byteformat
configfile: pyproject.toml
collected 8 items

tests/test_formatter.py ........                                         [100%]

============================== 8 passed in 0.01s ===============================
```

Eight dots, eight passing tests. The platform line shows `darwin` on macOS; you will see `linux` or `win32` on those systems. For detailed test output including individual test names, add the `-v` flag: `uv run pytest -v`.

For more on pytest setup, see [Setting up testing with pytest and uv](https://pydevtools.com/handbook/tutorial/setting-up-testing-with-pytest-and-uv.md).

## Create the CI Workflow

The CI workflow runs every check you ran locally: lint, format verification, type checking, and tests. It runs them across Python 3.12, 3.13, and 3.14 so a version-specific failure shows up in CI rather than in a user's bug report.

Create the workflow directory and file:

```console
$ mkdir -p .github/workflows
```

```yaml {filename=".github/workflows/ci.yml"}
name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  check:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ["3.12", "3.13", "3.14"]
    steps:
      - uses: actions/checkout@v6
      - uses: astral-sh/setup-uv@v8.1.0
        with:
          python-version: ${{ matrix.python-version }}
          enable-cache: true
      - run: uv sync --locked --dev
      - run: uv run ruff check .
      - run: uv run ruff format --check .
      - run: uv run ty check
      - run: uv run pytest
```

Five steps after setup: sync dependencies from the lockfile, lint, verify formatting, type-check, and test. Each step runs the same command you used locally. `astral-sh/setup-uv` uses a full version tag (`@v8.1.0`) because setup-uv no longer publishes moving tags like `@v8` (see [how to upgrade setup-uv from v7 to v8](https://pydevtools.com/handbook/how-to/how-to-upgrade-setup-uv-from-v7-to-v8.md)). `--locked` ensures CI uses the exact dependency versions from `uv.lock` without modifying it; if someone changes a dependency without updating the lockfile, the step fails.

The `strategy.matrix` section runs three parallel jobs, one per Python version. Since `requires-python` is `>=3.12`, the matrix covers the full supported range. A failure on one version does not cancel the others, so a single CI run tells you which versions broke.

`ruff format --check` verifies formatting without modifying files. If someone pushes unformatted code, the step fails and the PR gets a red check.

For more on GitHub Actions configuration including caching and matrix options, see [Setting up GitHub Actions with uv](https://pydevtools.com/handbook/tutorial/setting-up-github-actions-with-uv.md).

## Push to GitHub

Create a new repository on GitHub (name it `byteformat` or whatever matches your project). Then commit and push:

```console
$ git add .
$ git commit -m "Initial commit: byteformat library with Ruff, ty, and pytest"
$ git remote add origin https://github.com/<your-username>/byteformat.git
$ git push -u origin main
```

`git add .` stages `uv.lock` alongside your source code. The lockfile is what makes `uv sync --locked` work in CI; without it, the CI step fails.

Open the **Actions** tab on your GitHub repository. The CI workflow starts as soon as the push lands on the default branch. Once it completes, you see green checkmarks for each Python version.

If `uv sync --locked` fails, the lockfile was not committed or is out of date. Run `uv lock` locally and push the updated `uv.lock`. If `ruff format --check` fails, run `uv run ruff format .` locally, commit the result, and push.

## Publish with Trusted Publishing

[Trusted publishing](https://pydevtools.com/handbook/explanation/why-use-trusted-publishing-for-pypi.md) lets GitHub Actions upload packages to [PyPI](https://pydevtools.com/handbook/explanation/what-is-pypi.md) without storing any secrets. Instead of creating an API token and pasting it into repository settings, you tell TestPyPI which repository and workflow are allowed to publish. The workflow proves its identity using an OIDC token that lives only for the duration of the run.

This tutorial uses [TestPyPI](https://test.pypi.org), a separate index for testing the publishing workflow. Switching to real PyPI requires one URL change, described at the end of this section.

> [!NOTE]
> Your package name must be unique on TestPyPI. Browse `https://test.pypi.org/project/byteformat/` first; if it returns a project page rather than a 404, pick a different name in your `pyproject.toml` and rebuild.

### Register the trusted publisher on TestPyPI

Log in to [TestPyPI](https://test.pypi.org) and go to your [account publishing settings](https://test.pypi.org/manage/account/publishing/). Under "Create a new pending publisher," enter:

| Field | Value |
|---|---|
| PyPI project name | `byteformat` (or your package name from `pyproject.toml`) |
| Owner | Your GitHub username |
| Repository name | Your repository name on GitHub |
| Workflow name | `publish.yml` |
| Environment name | Leave blank |

Click **Add**.

### Create the publish workflow

```yaml {filename=".github/workflows/publish.yml"}
name: Publish to TestPyPI

on:
  release:
    types: [published]

jobs:
  publish:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
    steps:
      - uses: actions/checkout@v6
      - uses: astral-sh/setup-uv@v8.1.0
      - run: uv build
      - run: uv publish --publish-url https://test.pypi.org/legacy/
```

`permissions: id-token: write` allows the workflow to request an OIDC token from GitHub. Without it, `uv publish` has no credential to present to TestPyPI and the step fails with a 403 error.

`uv build` creates a [wheel](https://pydevtools.com/handbook/reference/wheel.md) and a [source distribution](https://pydevtools.com/handbook/reference/sdist.md) in `dist/`. `uv publish` detects the GitHub Actions environment, requests an OIDC token, exchanges it with TestPyPI for a short-lived upload credential, and publishes both files. No API token is stored anywhere.

### Commit and push the workflow

```console
$ git add .github/workflows/publish.yml
$ git commit -m "Add trusted publish workflow for TestPyPI"
$ git push
```

### Cut a release

Tag the current commit and push the tag:

```console
$ git tag v0.1.0
$ git push origin v0.1.0
```

Open your repository on GitHub, click **Releases**, then **Draft a new release**. Select the `v0.1.0` tag and click **Publish release**.

The publish workflow starts. Open the **Actions** tab to watch it run. `uv build` runs in under a second, and `uv publish` authenticates via OIDC and uploads both distribution files.

To publish to real PyPI instead of TestPyPI: configure the trusted publisher on [pypi.org](https://pypi.org/manage/account/publishing/) and remove the `--publish-url` flag from `publish.yml` so `uv publish` targets the default index.

For troubleshooting (403 errors, environment mismatches, pending publisher issues), see [How to publish to PyPI with trusted publishing](https://pydevtools.com/handbook/how-to/how-to-publish-to-pypi-with-trusted-publishing.md).

## Verify the Published Package

After the publish workflow completes, install your package from TestPyPI:

```console
$ uv run --index https://test.pypi.org/simple --with byteformat --no-project -- python -c "from byteformat import format_bytes; print(format_bytes(1_073_741_824))"
1 GB
```

The command prints `1 GB`, confirming the package installed from TestPyPI and the public API works. `--no-project` tells uv to ignore the local `pyproject.toml` so the package is fetched from TestPyPI into a temporary environment rather than resolved from your working directory.

If you see `No solution found`, TestPyPI may not have indexed the upload yet. Wait a minute and retry.

## Final Project Structure

{{< /filetree/folder >}}
    {{< /filetree/folder >}}
    {{< /filetree/folder >}}
    {{< /filetree/folder >}}
    {{< /filetree/folder >}}
  {{< /filetree/folder >}}
{{< /filetree/container >}}

## Next Steps

* [How to configure recommended Ruff defaults](https://pydevtools.com/handbook/how-to/how-to-configure-recommended-ruff-defaults.md) adds a comprehensive set of lint rules beyond the two categories used here
* [How to use ty in CI](https://pydevtools.com/handbook/how-to/how-to-use-ty-in-ci.md) covers output formats for inline PR annotations
* [How to run tests in parallel with pytest-xdist](https://pydevtools.com/handbook/how-to/how-to-run-tests-in-parallel-with-pytest-xdist.md) speeds up large test suites
* [How to publish Python packages with digital attestations](https://pydevtools.com/handbook/how-to/how-to-publish-python-packages-with-digital-attestations.md) adds cryptographic provenance to your releases
* [How to set up pre-commit hooks for a Python project](https://pydevtools.com/handbook/how-to/how-to-set-up-pre-commit-hooks-for-a-python-project.md) runs Ruff and ty before every commit, not just in CI
* [How to add dynamic versioning to uv projects](https://pydevtools.com/handbook/how-to/how-to-add-dynamic-versioning-to-uv-projects.md) derives the version from Git tags instead of hard-coding it in `pyproject.toml`
