Python Library Dependencies

This page goes into more detail about configuring package-index, wheel, and local-project dependencies for `PythonModule`s.

Adding Dependencies

build.mill (download, browse)
package build

import mill.*, pythonlib.*

object `package` extends PythonModule {
  def pythonDeps = Seq(
    "numpy==2.1.2",
    "pandas~=2.2.3",
    "jinja2 @ https://github.com/pallets/jinja/releases/download/3.1.4/jinja2-3.1.4-py3-none-any.whl"
  )
}

You can define the pythonDeps field to add dependencies to your module, which will be installed via uv. Dependencies can include standard dependency specifications, such as <package>==<version> constraints, or even direct references to wheels.

> ./mill run
[10 20 30 40 50]

Adding Dependencies via requirements.txt files

You can also read dependencies from requirements.txt files. This can be useful if you’re migrating an existing project to mill.

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

object `package` extends PythonModule {
  def pythonRequirementFiles = Task.Sources("requirements.txt")
}
> ./mill run
[10 20 30 40 50]

> ./mill show bundle
".../out/bundle.dest/bundle"

> out/bundle.dest/bundle
[10 20 30 40 50]

Unmanaged Wheels

In most scenarios you should rely on pythonDeps/moduleDeps and let Mill manage the downloading and caching of wheels for you. But in the rare case you receive a wheel or folder-full-of-wheels from somewhere and need to include it in your project, unmanagedWheels is the way to do it.

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

object `package` extends PythonModule {
  def unmanagedWheels: T[Seq[PathRef]] = Task.Input {
    Seq.from(os.list(moduleDir / "lib").map(PathRef(_)))
  }
}

You can override unmanagedWheels to point it at a wheel (.whl file) or source distribution (.tar.gz with a pyproject.toml file) you place on the filesystem, e.g. in the above snippet any files that happen to live in the lib/ folder.

> ./mill run
Hello, world!

> ./mill show bundle
".../out/bundle.dest/bundle"

> out/bundle.dest/bundle
Hello, world!

Downloading Unmanaged Wheels

You can also override unmanagedWheels to point it at wheels that you want to download from arbitrary URLs. requests.get comes from the Requests-Scala library, one of Mill’s Bundled Libraries.

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

object `package` extends PythonModule {
  def unmanagedWheels = Task {
    if (Task.offline) Task.fail("Cannot download classpath when in offline-mode")
    else {
      val name = "jinja2-3.1.4-py3-none-any.whl"
      val url = s"https://github.com/pallets/jinja/releases/download/3.1.4/$name"
      os.write(Task.dest / name, requests.get.stream(url))
      Seq(PathRef(Task.dest / name))
    }
  }
}
> ./mill run
Hello, world!

Tasks like unmanagedWheels and pythonDeps are cached, so your wheel is downloaded only once and re-used indefinitely after that. This is usually not a problem, because usually URLs follow the rule that Cool URIs don’t change, and so files downloaded from the same URL will always contain the same contents.

> ./mill --offline run
Hello, world!
An unmanaged wheel downloaded via requests.get is still unmanaged: even though you downloaded it from somewhere, requests.get does not know how to pull in transitive dependencies or de-duplicate different versions on the classpath. All the same caveats you need to worry about when dealing with unmanaged wheels apply here as well. In case you do want mill to take care of managing dependencies of a package which is not available on PyPI, you shouldn’t get that package in unmanagedWheels (like we did in the example above). Instead, you can declare the dependency as an entry in pythonDeps using the standard direct-URL syntax.

Using Custom Package Indexes

By default, dependencies are resolved from the Python Package Index (PyPI), the standard package index for python projects. You can also add your own package indexes by overriding the indexes task in the module:

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

object foo extends PythonModule {

  def pythonDeps = Seq(
    "testpkg-jodersky==0.0.1" // a test package, only available on test.pypi.org
  )

  // override this task to add or replace the package indexes
  def indexes = super.indexes() ++ Seq("https://test.pypi.org/simple/")
}

Mill uses uv to find and install dependencies.

You can configure uv through uv.toml files and environment variables.

Private indexes

You can read up in more detail on how to configure uv to authenticate to private indexes. Name the index in the URL and provide credentials through uv’s environment variables so that secrets never become Mill task values or cached task output:

object bar extends PythonModule {
  def indexes = Seq(
    "company=https://pypi.company.com/simple",
    "https://pypi.org/simple"
  )
}

export UV_INDEX_COMPANY_USERNAME=username export UV_INDEX_COMPANY_PASSWORD="$COMPANY_PASSWORD"

More advanced authentication techniques are available through uv’s named-index credentials.

> ./mill foo.run
2

Depending on Local Python Projects

build.mill (download, browse)
package build

import mill.*, pythonlib.*
import mill.api.BuildCtx

object `package` extends PythonModule {
  def pythonProjectDeps = Task.Sources(BuildCtx.workspaceRoot / "raw-project")
  object test extends PythonTests, TestModule.Unittest
}

pythonProjectDeps adds cache-tracked Python projects that are not Mill modules. uv reads the standard project metadata in each directory, installs the project into this module’s virtual environment, and rebuilds the environment when that project changes. The dependency is inherited by downstream modules and included in standalone bundles. Because this setting is part of the shared uv environment configuration, it works for regular and bare modules as well as their test, lint, coverage, and publishing mixins. Local filesystem paths are not portable package metadata, so a PublishModule should separately declare any published dependency in pythonDeps.

raw-project/pyproject.toml (download, browse)
[project]
name = "raw-uv-project"
version = "0.1.0"

[build-system]
requires = ["uv_build>=0.12.12,<0.13"]
build-backend = "uv_build"

[tool.uv.build-backend]
module-name = "raw_uv_project"
raw-project/src/raw_uv_project/init.py (download, browse)
def raw_message() -> str:
    return "Hello from a raw uv project!"
src/main.py (download, browse)
from raw_uv_project import raw_message

print(raw_message())
test/src/test_raw_project.py (download, browse)
import unittest

from raw_uv_project import raw_message


class RawProjectTests(unittest.TestCase):
    def test_dependency_is_inherited(self) -> None:
        self.assertEqual(raw_message(), "Hello from a raw uv project!")
> ./mill run
Hello from a raw uv project!

> ./mill test
...test_dependency_is_inherited...ok...
...Ran 1 test...
...OK...

> ./mill show bundle
".../out/bundle.dest/bundle"

> out/bundle.dest/bundle
Hello from a raw uv project!

Debugging

In case anything goes wrong, or if you’re just curious, you can see what arguments Mill passes to uv pip install by looking at the output of the uvInstallArgs task.

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

object `package` extends PythonModule {
  def pythonDeps = Seq(
    "numpy==2.1.2",
    "pandas~=2.2.3",
    "jinja2 @ https://github.com/pallets/jinja/releases/download/3.1.4/jinja2-3.1.4-py3-none-any.whl"
  )

  def indexes = Seq("invalid_index")
}
> ./mill show uvInstallArgs
{
  "args": [
    "--default-index",
    "invalid_index",
    "numpy==2.1.2",
    "pandas~=2.2.3",
    "jinja2 @ https://github.com/pallets/jinja/releases/download/3.1.4/jinja2-3.1.4-py3-none-any.whl"
  ],
  "sig": ...
}