Python Packaging & Publishing

This page will discuss common topics around publishing your Python projects for others to use.

All packaging and publishing functionality is defined in PublishModule. Start by extending it.

build.mill (download, browse)
import mill.*, pythonlib.*

object `package` extends PythonModule, PublishModule {

  // information about dependencies will be included in the published package
  def pythonDeps = Seq("jinja2==3.1.4")

  def publishMeta = PublishMeta(
    name = "testpkg-mill",
    description = "an example package",
    requiresPython = ">= 3.12",
    license = License.MIT,
    authors = Seq(Developer("John Doe", "jdoe@example.org"))
  )

  // the version under which the package will be published
  def publishVersion = "0.0.2"

  // The distribution uses a different name from its Python import package.
  override def publishModuleName = "testpkg"

}

You’ll need to define some metadata in the publishMeta and publishVersion tasks. This metadata is roughly equivalent to what you’d define in a pyproject.toml file.

You’ll also need to create a readme file, which will be bundled in the final package and serves as the landing page seen on PyPI. By default, Mill assumes a file starting with the string readme (in any capitalization), but you can override it to whatever you please.

The version of your package is not included in publishMeta, but rather in its own publishVersion task. This is done so that you can easily override the task to automate the version, such as deriving it from source control.

uv_build normally derives the import package name from the distribution name. Override publishModuleName when those names differ, as they do above.

Building packages locally

You can build a source distribution or wheel by running the following tasks:

> ./mill show sdist
".../out/sdist.dest/dist/testpkg_mill-0.0.2.tar.gz"

> ./mill show wheel
".../out/wheel.dest/dist/testpkg_mill-0.0.2-py3-none-any.whl"

These files can then be installed by other projects with uv pip install or, if you’re using Mill, you can include them in your unmanagedWheels task. Usually however, you’d want to publish them to a package index such as PyPI or your organization’s internal package repository.

Uploading your packages to PyPI (or other repository)

Uploading your packages to PyPI can be done by running mill __.publish.

Mill uses uv publish to upload packages. Configure it with uv’s UV_PUBLISH_* environment variables or the equivalent MILL_UV_PUBLISH_* variables.

> export MILL_UV_PUBLISH_URL=https://test.pypi.org/legacy/
> export MILL_UV_PUBLISH_USERNAME=__token__
> export MILL_UV_PUBLISH_PASSWORD=<apitoken>
> mill __.publish

Mill does not transitively upload all your packages, hence we recommended to use mill __.publish, instead of mill <module>.publish. While it’s technically possible to upload packages of individual Mill modules by calling their publish tasks separately, you’d usually want to ensure all your dependencies are also published.

Check before uploading

You can validate the artifacts with uv publish --dry-run before uploading:

> ./mill __.checkPublish
Checking 2 files against https://upload.pypi.org/legacy/
Checking testpkg_mill-0.0.2.tar.gz (...)
Checking testpkg_mill-0.0.2-py3-none-any.whl (...)

Advanced Packaging

Behind the scenes, Mill delegates most Python packaging tasks to other tools, and only takes care of configuring them with information it has on your build.

By default, it will:

  • create a synthetic pyproject.toml file from its own metadata

  • use uv_build to package the module and uv build as the frontend

  • first create a source distribution and then use that to build a wheel (instead of building a wheel directly)

While this should be sufficient for most projects, sometimes you need a little customization.

Customizing the pyproject.toml and other build files

If you’re happy to use a PEP-518-compliant pyproject.toml to describe how to package your published project, but would like some customization, you can amend or override the pyproject task with your own metadata.

You can also include additional files in the packaging process by adding them to buildFiles. You can then reference these in your pyproject.toml file.

The following example adds a generated build note to the source distribution with uv_build’s source-include setting.

build.mill (download, browse)
import mill.*, pythonlib.*

object `package` extends PythonModule, PublishModule {

  def publishMeta = PublishMeta(
    name = "testpackage",
    description = "an example package",
    requiresPython = ">= 3.12",
    license = License.MIT,
    authors = Seq(Developer("John Doe", "jdoe@example.org"))
  )

  def publishVersion = "0.0.3"

  def buildNote = Task {
    os.write(Task.dest / "build-note.txt", "Generated by Mill")
    PathRef(Task.dest / "build-note.txt")
  }

  override def pyproject = Task {
    super.pyproject() +
      """|
         |source-include=["build-note.txt"]
         |""".stripMargin
  }

  override def buildFiles = Task {
    super.buildFiles() ++ Map("build-note.txt" -> buildNote())
  }

}
> ./mill sdist
...testpackage-0.0.3.tar.gz

> tar tzf out/sdist.dest/dist/testpackage-0.0.3.tar.gz
...testpackage-0.0.3/build-note.txt

Changing the packaging process entirely

In case customizing of pyproject is too cumbersome, or you cannot use it for some reason, you can always override the sdist and wheel tasks with your own packaging implementation. Publishing with __.publish will still work as usual.