Skip to content

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 for linting and formatting, ty for type checking, pytest for testing, wire them into GitHub Actions, and publish to TestPyPI using trusted publishing. By the end, every push runs four quality gates and every GitHub release lands on TestPyPI without a token.

Prerequisites

Create the Project

$ 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, 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:

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:

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:

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:

$ 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:

$ 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 pins every dependency to an exact version so CI installs the same packages every time.

Run the linter:

$ 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:

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

Now format the code:

$ 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:

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

I enforces sorted imports (isort rules). B enables flake8-bugbear rules that catch common Python gotchas. For a more comprehensive set of rules, see How to configure recommended Ruff defaults.

Type-check with ty

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

Run the type checker:

$ 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:

            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:

$ uv run ty check
All checks passed!

For more on ty, see How to try the ty type checker.

Test with pytest

$ 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:

$ mkdir tests
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:

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

Run the tests:

$ 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.

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:

$ mkdir -p .github/workflows
.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@v7
        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. --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.

Push to GitHub

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

$ 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 lets GitHub Actions upload packages to PyPI 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, 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 and go to your account publishing settings. 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

.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@v7
      - 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 and a source distribution 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

$ 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:

$ 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 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.

Verify the Published Package

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

$ 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

    • pyproject.toml
    • uv.lock
    • README.md
    • .python-version
        • ci.yml
        • publish.yml
        • __init__.py
        • formatter.py
        • py.typed
      • test_formatter.py

Next Steps

Last updated on